diff --git a/.changeset/fep-2357-prepare-outside-react.md b/.changeset/fep-2357-prepare-outside-react.md new file mode 100644 index 000000000..e855cde12 --- /dev/null +++ b/.changeset/fep-2357-prepare-outside-react.md @@ -0,0 +1,14 @@ +--- +"@stackflow/react": minor +--- + +Expose `prepare` on the `stackflow()` output to preload an activity's component chunk and data loader from outside the React render tree (e.g. at app bootstrap, before the first render), without depending on React Context. + +```ts +const { Stack, actions, stepActions, prepare } = stackflow({ config, components, plugins }); + +prepare("Article", { articleId: "123" }); // warm chunk + fire data loader +prepare("Article"); // warm chunk only +``` + +The signature matches the existing `usePrepare` hook (omitting params warms the chunk only; passing params also fires the loader), and `usePrepare` is now a thin wrapper over the same implementation, so in-tree callers are unchanged. Failures are delivered as a rejection of the returned promise rather than a synchronous throw. diff --git a/integrations/react/src/Prepare.ts b/integrations/react/src/Prepare.ts new file mode 100644 index 000000000..a643e8d20 --- /dev/null +++ b/integrations/react/src/Prepare.ts @@ -0,0 +1,9 @@ +import type { + InferActivityParams, + RegisteredActivityName, +} from "@stackflow/config"; + +export type Prepare = ( + activityName: K, + activityParams?: InferActivityParams, +) => Promise; diff --git a/integrations/react/src/index.ts b/integrations/react/src/index.ts index 08f7d178a..d14ac1cf5 100644 --- a/integrations/react/src/index.ts +++ b/integrations/react/src/index.ts @@ -7,6 +7,7 @@ export * from "./Actions"; export * from "./ActivityComponentType"; export * from "./lazy"; export * from "./loader/useLoaderData"; +export * from "./Prepare"; export * from "./StackComponentType"; export * from "./StaticActivityComponentType"; export * from "./StepActions"; diff --git a/integrations/react/src/makePrepare.ts b/integrations/react/src/makePrepare.ts new file mode 100644 index 000000000..a128301d6 --- /dev/null +++ b/integrations/react/src/makePrepare.ts @@ -0,0 +1,62 @@ +import type { + ActivityDefinition, + Config, + InferActivityParams, + RegisteredActivityName, +} from "@stackflow/config"; +import type { ActivityComponentType } from "./BaseActivityComponentType"; +import type { Prepare } from "./Prepare"; +import { + getContentComponent, + isStructuredActivityComponent, +} from "./StructuredActivityComponentType"; + +export type MakePrepareInput = { + config: Config>; + loadData: (activityName: string, activityParams: {}) => unknown; + activityComponentMap: { + [activityName in RegisteredActivityName]: ActivityComponentType; + }; +}; + +export function makePrepare({ + config, + loadData, + activityComponentMap, +}: MakePrepareInput): Prepare { + return async function prepare( + activityName: K, + activityParams?: InferActivityParams, + ) { + const activityConfig = config.activities.find( + ({ name }) => name === activityName, + ); + const prefetchTasks: Promise[] = []; + + if (!activityConfig) + throw new Error(`Activity ${activityName} is not registered.`); + + if (activityParams && activityConfig.loader) { + prefetchTasks.push( + Promise.resolve(loadData(activityName, activityParams)), + ); + } + + if ("_load" in activityComponentMap[activityName]) { + prefetchTasks.push( + Promise.resolve(activityComponentMap[activityName]._load?.()), + ); + } + + if ( + isStructuredActivityComponent(activityComponentMap[activityName]) && + typeof activityComponentMap[activityName].content === "function" + ) { + prefetchTasks.push( + getContentComponent(activityComponentMap[activityName]).preload(), + ); + } + + await Promise.all(prefetchTasks); + }; +} diff --git a/integrations/react/src/stackflow.tsx b/integrations/react/src/stackflow.tsx index 5cbab9846..61332554e 100644 --- a/integrations/react/src/stackflow.tsx +++ b/integrations/react/src/stackflow.tsx @@ -24,9 +24,11 @@ import type { Actions } from "./Actions"; import { ConfigProvider } from "./ConfigProvider"; import { DataLoaderProvider, loaderPlugin } from "./loader"; import { makeActions } from "./makeActions"; +import { makePrepare } from "./makePrepare"; import { makeStepActions } from "./makeStepActions"; import type { StackComponentType } from "./StackComponentType"; import type { StepActions } from "./StepActions"; +import type { Prepare } from "./Prepare"; export type StackflowPluginsEntry = | StackflowReactPlugin @@ -47,6 +49,7 @@ export type StackflowOutput = { Stack: StackComponentType; actions: Actions; stepActions: StepActions; + prepare: Prepare; }; export function stackflow< @@ -201,5 +204,10 @@ export function stackflow< Stack, actions: makeActions(() => getCoreStore()?.actions), stepActions: makeStepActions(() => getCoreStore()?.actions), + prepare: makePrepare({ + config: input.config, + loadData, + activityComponentMap: input.components, + }), }; } diff --git a/integrations/react/src/usePrepare.ts b/integrations/react/src/usePrepare.ts index 379c97df6..fcb108558 100644 --- a/integrations/react/src/usePrepare.ts +++ b/integrations/react/src/usePrepare.ts @@ -1,62 +1,24 @@ -import type { - InferActivityParams, - RegisteredActivityName, -} from "@stackflow/config"; -import { useCallback } from "react"; +import { useMemo } from "react"; import { useActivityComponentMap } from "./ActivityComponentMapProvider"; -import { - getContentComponent, - isStructuredActivityComponent, -} from "./StructuredActivityComponentType"; import { useDataLoader } from "./loader"; +import { makePrepare } from "./makePrepare"; +import type { Prepare } from "./Prepare"; import { useConfig } from "./useConfig"; -export type Prepare = ( - activityName: K, - activityParams?: InferActivityParams, -) => Promise; - +/** + * `stackflow()` 출력의 `prepare`와 동일 로직을 감싸는 얇은 래퍼. + * + * React Context에서 파생되는 세 입력(`config`, `loadData`, `activityComponentMap`)을 + * `makePrepare`에 그대로 넘긴다. 셋 중 하나라도 바뀌지 않는 한 반환 함수의 참조가 + * 안정적이도록 메모이즈한다. + */ export function usePrepare(): Prepare { const config = useConfig(); const loadData = useDataLoader(); const activityComponentMap = useActivityComponentMap(); - return useCallback( - async function prepare( - activityName: K, - activityParams?: InferActivityParams, - ) { - const activityConfig = config.activities.find( - ({ name }) => name === activityName, - ); - const prefetchTasks: Promise[] = []; - - if (!activityConfig) - throw new Error(`Activity ${activityName} is not registered.`); - - if (activityParams && activityConfig.loader) { - prefetchTasks.push( - Promise.resolve(loadData(activityName, activityParams)), - ); - } - - if ("_load" in activityComponentMap[activityName]) { - prefetchTasks.push( - Promise.resolve(activityComponentMap[activityName]._load?.()), - ); - } - - if ( - isStructuredActivityComponent(activityComponentMap[activityName]) && - typeof activityComponentMap[activityName].content === "function" - ) { - prefetchTasks.push( - getContentComponent(activityComponentMap[activityName]).preload(), - ); - } - - await Promise.all(prefetchTasks); - }, + return useMemo( + () => makePrepare({ config, loadData, activityComponentMap }), [config, loadData, activityComponentMap], ); }