Skip to content

Commit 510a287

Browse files
feat(react): support activity component/data preload outside React runtime context (#716)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 416b65d commit 510a287

6 files changed

Lines changed: 106 additions & 50 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@stackflow/react": minor
3+
---
4+
5+
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.
6+
7+
```ts
8+
const { Stack, actions, stepActions, prepare } = stackflow({ config, components, plugins });
9+
10+
prepare("Article", { articleId: "123" }); // warm chunk + fire data loader
11+
prepare("Article"); // warm chunk only
12+
```
13+
14+
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.

integrations/react/src/Prepare.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type {
2+
InferActivityParams,
3+
RegisteredActivityName,
4+
} from "@stackflow/config";
5+
6+
export type Prepare = <K extends RegisteredActivityName>(
7+
activityName: K,
8+
activityParams?: InferActivityParams<K>,
9+
) => Promise<void>;

integrations/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from "./Actions";
77
export * from "./ActivityComponentType";
88
export * from "./lazy";
99
export * from "./loader/useLoaderData";
10+
export * from "./Prepare";
1011
export * from "./StackComponentType";
1112
export * from "./StaticActivityComponentType";
1213
export * from "./StepActions";
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type {
2+
ActivityDefinition,
3+
Config,
4+
InferActivityParams,
5+
RegisteredActivityName,
6+
} from "@stackflow/config";
7+
import type { ActivityComponentType } from "./BaseActivityComponentType";
8+
import type { Prepare } from "./Prepare";
9+
import {
10+
getContentComponent,
11+
isStructuredActivityComponent,
12+
} from "./StructuredActivityComponentType";
13+
14+
export type MakePrepareInput = {
15+
config: Config<ActivityDefinition<RegisteredActivityName>>;
16+
loadData: (activityName: string, activityParams: {}) => unknown;
17+
activityComponentMap: {
18+
[activityName in RegisteredActivityName]: ActivityComponentType;
19+
};
20+
};
21+
22+
export function makePrepare({
23+
config,
24+
loadData,
25+
activityComponentMap,
26+
}: MakePrepareInput): Prepare {
27+
return async function prepare<K extends RegisteredActivityName>(
28+
activityName: K,
29+
activityParams?: InferActivityParams<K>,
30+
) {
31+
const activityConfig = config.activities.find(
32+
({ name }) => name === activityName,
33+
);
34+
const prefetchTasks: Promise<unknown>[] = [];
35+
36+
if (!activityConfig)
37+
throw new Error(`Activity ${activityName} is not registered.`);
38+
39+
if (activityParams && activityConfig.loader) {
40+
prefetchTasks.push(
41+
Promise.resolve(loadData(activityName, activityParams)),
42+
);
43+
}
44+
45+
if ("_load" in activityComponentMap[activityName]) {
46+
prefetchTasks.push(
47+
Promise.resolve(activityComponentMap[activityName]._load?.()),
48+
);
49+
}
50+
51+
if (
52+
isStructuredActivityComponent(activityComponentMap[activityName]) &&
53+
typeof activityComponentMap[activityName].content === "function"
54+
) {
55+
prefetchTasks.push(
56+
getContentComponent(activityComponentMap[activityName]).preload(),
57+
);
58+
}
59+
60+
await Promise.all(prefetchTasks);
61+
};
62+
}

integrations/react/src/stackflow.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ import type { Actions } from "./Actions";
2424
import { ConfigProvider } from "./ConfigProvider";
2525
import { DataLoaderProvider, loaderPlugin } from "./loader";
2626
import { makeActions } from "./makeActions";
27+
import { makePrepare } from "./makePrepare";
2728
import { makeStepActions } from "./makeStepActions";
2829
import type { StackComponentType } from "./StackComponentType";
2930
import type { StepActions } from "./StepActions";
31+
import type { Prepare } from "./Prepare";
3032

3133
export type StackflowPluginsEntry =
3234
| StackflowReactPlugin<never>
@@ -47,6 +49,7 @@ export type StackflowOutput = {
4749
Stack: StackComponentType;
4850
actions: Actions;
4951
stepActions: StepActions<ActivityBaseParams>;
52+
prepare: Prepare;
5053
};
5154

5255
export function stackflow<
@@ -201,5 +204,10 @@ export function stackflow<
201204
Stack,
202205
actions: makeActions(() => getCoreStore()?.actions),
203206
stepActions: makeStepActions(() => getCoreStore()?.actions),
207+
prepare: makePrepare({
208+
config: input.config,
209+
loadData,
210+
activityComponentMap: input.components,
211+
}),
204212
};
205213
}
Lines changed: 12 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,24 @@
1-
import type {
2-
InferActivityParams,
3-
RegisteredActivityName,
4-
} from "@stackflow/config";
5-
import { useCallback } from "react";
1+
import { useMemo } from "react";
62
import { useActivityComponentMap } from "./ActivityComponentMapProvider";
7-
import {
8-
getContentComponent,
9-
isStructuredActivityComponent,
10-
} from "./StructuredActivityComponentType";
113
import { useDataLoader } from "./loader";
4+
import { makePrepare } from "./makePrepare";
5+
import type { Prepare } from "./Prepare";
126
import { useConfig } from "./useConfig";
137

14-
export type Prepare = <K extends RegisteredActivityName>(
15-
activityName: K,
16-
activityParams?: InferActivityParams<K>,
17-
) => Promise<void>;
18-
8+
/**
9+
* `stackflow()` 출력의 `prepare`와 동일 로직을 감싸는 얇은 래퍼.
10+
*
11+
* React Context에서 파생되는 세 입력(`config`, `loadData`, `activityComponentMap`)을
12+
* `makePrepare`에 그대로 넘긴다. 셋 중 하나라도 바뀌지 않는 한 반환 함수의 참조가
13+
* 안정적이도록 메모이즈한다.
14+
*/
1915
export function usePrepare(): Prepare {
2016
const config = useConfig();
2117
const loadData = useDataLoader();
2218
const activityComponentMap = useActivityComponentMap();
2319

24-
return useCallback(
25-
async function prepare<K extends RegisteredActivityName>(
26-
activityName: K,
27-
activityParams?: InferActivityParams<K>,
28-
) {
29-
const activityConfig = config.activities.find(
30-
({ name }) => name === activityName,
31-
);
32-
const prefetchTasks: Promise<unknown>[] = [];
33-
34-
if (!activityConfig)
35-
throw new Error(`Activity ${activityName} is not registered.`);
36-
37-
if (activityParams && activityConfig.loader) {
38-
prefetchTasks.push(
39-
Promise.resolve(loadData(activityName, activityParams)),
40-
);
41-
}
42-
43-
if ("_load" in activityComponentMap[activityName]) {
44-
prefetchTasks.push(
45-
Promise.resolve(activityComponentMap[activityName]._load?.()),
46-
);
47-
}
48-
49-
if (
50-
isStructuredActivityComponent(activityComponentMap[activityName]) &&
51-
typeof activityComponentMap[activityName].content === "function"
52-
) {
53-
prefetchTasks.push(
54-
getContentComponent(activityComponentMap[activityName]).preload(),
55-
);
56-
}
57-
58-
await Promise.all(prefetchTasks);
59-
},
20+
return useMemo(
21+
() => makePrepare({ config, loadData, activityComponentMap }),
6022
[config, loadData, activityComponentMap],
6123
);
6224
}

0 commit comments

Comments
 (0)