Skip to content

Commit f0987d1

Browse files
fix(react): support core v3 load path in built-in loaderPlugin (#732)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b97074f commit f0987d1

5 files changed

Lines changed: 368 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stackflow/react": patch
3+
---
4+
5+
fix: Refresh loader data for renderable activities restored from core v3 snapshots.

integrations/react/src/loader/loaderPlugin.tsx

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,22 @@ import type {
22
ActivityDefinition,
33
RegisteredActivityName,
44
} from "@stackflow/config";
5+
import type { Stack } from "@stackflow/core";
56
import type { ActivityComponentType } from "../BaseActivityComponentType";
67
import type { StackflowReactPlugin } from "../StackflowReactPlugin";
78
import {
89
getContentComponent,
910
isStructuredActivityComponent,
1011
} from "../StructuredActivityComponentType";
11-
import { isPromiseLike } from "../utils/isPromiseLike";
12+
import type { StackflowInput } from "../stackflow";
1213
import {
14+
defer,
1315
inspect,
1416
PromiseStatus,
1517
resolve,
18+
type SyncInspectableDeferred,
19+
type SyncInspectablePromise,
1620
} from "../utils/SyncInspectablePromise";
17-
import type { StackflowInput } from "../stackflow";
1821

1922
export function loaderPlugin<
2023
T extends ActivityDefinition<RegisteredActivityName>,
@@ -26,15 +29,111 @@ export function loaderPlugin<
2629
loadData: (activityName: string, activityParams: {}) => unknown,
2730
): StackflowReactPlugin {
2831
return () => {
32+
const loadPathDeferreds = new WeakMap<
33+
SyncInspectablePromise<unknown>,
34+
SyncInspectableDeferred<unknown>
35+
>();
36+
37+
const resolveDeferredLoaderData = ({
38+
activityName,
39+
activityParams,
40+
loaderData,
41+
}: {
42+
activityName: string;
43+
activityParams: {};
44+
loaderData: SyncInspectablePromise<unknown> | undefined;
45+
}) => {
46+
const matchActivity = input.config.activities.find(
47+
(candidate) => candidate.name === activityName,
48+
);
49+
const deferred = loaderData
50+
? loadPathDeferreds.get(loaderData)
51+
: undefined;
52+
53+
if (!matchActivity?.loader || !loaderData || !deferred) {
54+
return;
55+
}
56+
57+
loadPathDeferreds.delete(loaderData);
58+
59+
Promise.allSettled([loaderData]).then(([loaderDataPromiseResult]) => {
60+
printLoaderDataPromiseError({
61+
promiseResult: loaderDataPromiseResult,
62+
activityName: matchActivity.name,
63+
});
64+
});
65+
66+
try {
67+
deferred.resolve(loadData(activityName, activityParams));
68+
} catch (error) {
69+
deferred.reject(error);
70+
}
71+
};
72+
73+
const resolveRestoredStackLoaderData = (stack: Stack) => {
74+
stack.activities
75+
.filter((activity) => activity.transitionState !== "exit-done")
76+
.forEach((activity) => {
77+
resolveDeferredLoaderData({
78+
activityName: activity.name,
79+
activityParams: activity.params,
80+
loaderData: (activity.context as any)?.loaderData,
81+
});
82+
});
83+
};
84+
85+
const resolvePausedEventLoaderData = (
86+
pausedEvents: Stack["pausedEvents"],
87+
) => {
88+
pausedEvents?.forEach((event) => {
89+
if (event.name !== "Pushed" && event.name !== "Replaced") {
90+
return;
91+
}
92+
93+
resolveDeferredLoaderData({
94+
activityName: event.activityName,
95+
activityParams: event.activityParams,
96+
loaderData: (event.activityContext as any)?.loaderData,
97+
});
98+
});
99+
};
100+
29101
return {
30102
key: "plugin-loader",
31-
overrideInitialEvents({ initialEvents, initialContext }) {
103+
overrideInitialEvents({ initialEvents, initialContext, initInfo }) {
32104
if (initialEvents.length === 0) {
33105
return [];
34106
}
35107

108+
if (initInfo?.kind === "load") {
109+
return initialEvents.map((event) => {
110+
if (event.name !== "Pushed" && event.name !== "Replaced") {
111+
return event;
112+
}
113+
114+
const matchActivity = input.config.activities.find(
115+
(activity) => activity.name === event.activityName,
116+
);
117+
118+
if (!matchActivity?.loader) {
119+
return event;
120+
}
121+
122+
const loaderData = defer<unknown>();
123+
loadPathDeferreds.set(loaderData.promise, loaderData);
124+
125+
return {
126+
...event,
127+
activityContext: {
128+
...event.activityContext,
129+
loaderData: loaderData.promise,
130+
},
131+
};
132+
});
133+
}
134+
36135
return initialEvents.map((event) => {
37-
if (event.name !== "Pushed") {
136+
if (event.name !== "Pushed" && event.name !== "Replaced") {
38137
return event;
39138
}
40139

@@ -78,6 +177,15 @@ export function loaderPlugin<
78177
};
79178
});
80179
},
180+
onInit({ actions, initInfo }) {
181+
if (initInfo?.kind !== "load") {
182+
return;
183+
}
184+
185+
const stack = actions.getStack();
186+
resolveRestoredStackLoaderData(stack);
187+
resolvePausedEventLoaderData(stack.pausedEvents);
188+
},
81189
onBeforePush: createBeforeRouteHandler(input, loadData),
82190
onBeforeReplace: createBeforeRouteHandler(input, loadData),
83191
};
@@ -88,6 +196,7 @@ type OnBeforeRoute = NonNullable<
88196
| ReturnType<StackflowReactPlugin>["onBeforePush"]
89197
| ReturnType<StackflowReactPlugin>["onBeforeReplace"]
90198
>;
199+
91200
function createBeforeRouteHandler<
92201
T extends ActivityDefinition<RegisteredActivityName>,
93202
R extends {

integrations/react/src/utils/SyncInspectablePromise.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ export interface SyncInspectablePromise<T> extends Promise<T> {
66
reason?: unknown;
77
}
88

9+
export interface SyncInspectableDeferred<T> {
10+
promise: SyncInspectablePromise<T>;
11+
resolve: (value: T | PromiseLike<T>) => void;
12+
reject: (reason: unknown) => void;
13+
}
14+
915
export const PromiseStatus = {
1016
PENDING: "pending",
1117
FULFILLED: "fulfilled",
@@ -96,3 +102,80 @@ export function reject(error: unknown): SyncInspectablePromise<never> {
96102
reason: error,
97103
}) as SyncInspectablePromise<never>;
98104
}
105+
106+
export function defer<T>(): SyncInspectableDeferred<T> {
107+
let resolvePromise!: (value: T | PromiseLike<T>) => void;
108+
let rejectPromise!: (reason: unknown) => void;
109+
let isSettled = false;
110+
111+
const promise = Object.assign(
112+
new Promise<T>((resolvePromise_, rejectPromise_) => {
113+
resolvePromise = resolvePromise_;
114+
rejectPromise = rejectPromise_;
115+
}),
116+
{ status: PromiseStatus.PENDING },
117+
) as SyncInspectablePromise<T>;
118+
119+
const fulfill = (value: T) => {
120+
if (promise.status !== PromiseStatus.PENDING) {
121+
return;
122+
}
123+
124+
promise.status = PromiseStatus.FULFILLED;
125+
promise.value = value;
126+
resolvePromise(value);
127+
};
128+
129+
const rejectPromiseState = (reason: unknown) => {
130+
if (promise.status !== PromiseStatus.PENDING) {
131+
return;
132+
}
133+
134+
promise.status = PromiseStatus.REJECTED;
135+
promise.reason = reason;
136+
rejectPromise(reason);
137+
};
138+
139+
const reject = (reason: unknown) => {
140+
if (isSettled) {
141+
return;
142+
}
143+
144+
isSettled = true;
145+
rejectPromiseState(reason);
146+
};
147+
148+
return {
149+
promise,
150+
resolve(value) {
151+
if (isSettled) {
152+
return;
153+
}
154+
155+
isSettled = true;
156+
157+
try {
158+
const source = resolve(value) as SyncInspectablePromise<T>;
159+
160+
if (source === promise) {
161+
rejectPromiseState(
162+
new TypeError("A promise cannot be resolved with itself"),
163+
);
164+
return;
165+
}
166+
167+
const state = inspect(source);
168+
if (state.status === PromiseStatus.FULFILLED) {
169+
fulfill(state.value);
170+
} else if (state.status === PromiseStatus.REJECTED) {
171+
rejectPromiseState(state.reason);
172+
} else {
173+
source.then(fulfill, rejectPromiseState);
174+
}
175+
} catch (error) {
176+
rejectPromiseState(error);
177+
}
178+
},
179+
reject,
180+
};
181+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# FEP-2598 작업 계획 — react 내장 plugin-loader의 core v3 지원
2+
3+
- 이슈: [FEP-2598](https://linear.app/daangn/issue/FEP-2598) `stackflow/react에서 stackflow/core v3 지원하기`
4+
- 상태: 스펙 확정 (2026-07-21, 인터뷰 완료)
5+
6+
## 배경
7+
8+
core v3에서 스냅샷 기반 초기화(load path)가 추가되면서 `overrideInitialEvents`가 받는
9+
이벤트가 `PushedEvent | StepPushedEvent`에서 `SnapshotEvent`(전체 replay 시퀀스 +
10+
`initInfo` 판별자)로 확장됐다. react 내장 loaderPlugin
11+
(`integrations/react/src/loader/loaderPlugin.tsx`)은 v2 시절 가정(초기 이벤트 = 진입
12+
`Pushed` 몇 개)으로 작성되어, load path에서 다음이 깨진다:
13+
14+
- 죽은 activity(이후 pop/replace로 사라진)의 `Pushed`에도 loader가 전부 실행된다 —
15+
불필요한 네트워크 요청 등 사이드이펙트.
16+
- `Replaced`로 진입한 alive activity는 loaderData를 받지 못해 `useLoaderData`가 깨진다.
17+
- SSR용 `initialContext.initialLoaderData`가 replay 내 모든 `Pushed`에 잘못 붙는다.
18+
19+
참고: react의 peerDep은 이미 `core ^2 || ^3`(FEP-2590)이고, `StackflowReactPlugin`
20+
core 플러그인 타입을 그대로 확장하므로 v3 훅 타입 표면은 자동으로 따라온다. 이번 작업의
21+
대상은 loaderPlugin의 동작이다.
22+
23+
## 확정 스펙
24+
25+
1. **불변식**: 복원된 스택에서 렌더 가능한 모든 activity는, loader가 정의돼 있다면
26+
fresh한 `loaderData`를 가진다. 이벤트 단위가 아닌 **최종 재구성 스택의 activity
27+
단위** 보장이다.
28+
2. **렌더 가능 경계**: `transitionState !== "exit-done"` — 렌더러
29+
(`basicRendererPlugin`)의 실제 렌더 기준과 일치. `exit-active`(mid-pop 복원) 포함.
30+
3. **경로 판별**: `initInfo?.kind === "load"`일 때만 load 동작. `initInfo` 부재
31+
(= core v2 런타임)나 `"create"`는 기존 create 동작을 바이트 단위로 보존한다.
32+
버전/기능 감지는 하지 않는다.
33+
4. **stale 데이터 불신**: 스냅샷에 저장된 `activityContext.loaderData`는 절대
34+
재사용하지 않고 fresh loader 실행으로 덮어쓴다. `activityContext`의 나머지 필드는
35+
보존. loader가 없는 activity의 이벤트는 stale 필드가 있어도 건드리지 않는다.
36+
5. **`initialLoaderData`는 create 전용**: load path에서는 완전히 무시한다. 서버가
37+
계산한 대상과 복원 스택의 대응을 일반화할 수 없고, 그로 인한 hydration mismatch는
38+
snapshot provider의 책임 영역이다.
39+
6. **loader 실패 ≠ load 실패**: `SnapshotLoadError`로 승격하지 않고 `onLoadError`
40+
트리거하지 않는다. create path와 동일하게 콘솔 에러 + 렌더 시점 에러 바운더리로
41+
처리한다.
42+
7. **create path도 `Replaced`를 진입 이벤트로 처리한다**: v3에서
43+
`overrideInitialEvents` 체인의 타입이 `SnapshotEvent[]`로 넓어져 create path에서도
44+
앞선 플러그인이 `Replaced`를 포함한 시퀀스를 반환할 수 있다. 기존 **eager 방식
45+
그대로** `Replaced``Pushed`와 동일하게 취급한다(`initialLoaderData` 부착 규칙
46+
포함) — deferred+onInit으로 통일하지 않는 이유는 create path의 SSR이 `init()`
47+
없이 `overrideInitialEvents`의 eager 부착에 의존하기 때문. `[Pushed A,
48+
Replaced B]`처럼 죽는 activity(A)의 loader가 실행되는 낭비는 수용한다 —
49+
aliveness 판정에 core 재구성 로직 복제가 필요해지는 것보다 낫고, create
50+
시퀀스는 짧다. v2 타입상 create 체인에 `Replaced`가 등장할 수 없으므로 v2-합법
51+
입력에 대한 동작은 변하지 않는다(스펙 3과 양립).
52+
53+
## load path 메커니즘
54+
55+
두 단계로 나뉜다: `overrideInitialEvents`에서 deferred를 심고, `onInit`에서 core가
56+
실제로 계산한 최종 스택을 보고 resolve한다. react 플러그인 안에서 static 이벤트
57+
합성·backdate·aggregate로 core의 load 재구성 로직을 복제하지 않기 위함이다 —
58+
aliveness 판정의 진실 원천은 core가 계산한 스택 하나뿐이다.
59+
60+
1. **`overrideInitialEvents` (load일 때만)**: loader가 정의된 activity의 모든
61+
`Pushed`/`Replaced` 이벤트의 `activityContext.loaderData`**sync-inspectable
62+
deferred**를 심는다. loader가 없는 activity의 이벤트와 그 외 이벤트(`Popped`/
63+
step 계열/`Paused`/`Resumed`)는 무조건 통과. 이벤트의 id/date/순서/구성원은
64+
절대 변경하지 않는다.
65+
2. **`onInit` (load일 때만)**: `getStack()`으로 core가 계산한 최종 스택을 읽어,
66+
`transitionState !== "exit-done"`이고 loader가 정의된 activity마다 loader를
67+
**1회** 실행한다 — 인자는 해당 activity의 **최종** name/params. 실행 결과로
68+
`activity.context.loaderData`(= 1에서 심은 deferred)를 resolve한다. aggregate가
69+
진입 이벤트의 `activityContext``activity.context`로 채택하므로
70+
(`makeActivityFromEvent` 확인) activityId 부기는 불필요하다.
71+
3. **죽은 activity의 deferred는 pending으로 방치한다.** exit-done은 렌더되지 않아
72+
소비자가 없고, `undefined` resolve보다 "이 데이터는 오지 않는다"를 정직하게
73+
표현한다. 이벤트 로그가 앱 수명 동안 유지되므로 메모리 델타도 없다.
74+
4. loader promise 실패는 create path와 동일하게 `printLoaderDataPromiseError`
75+
출력한다.
76+
77+
### 타이밍·의미 보존 근거
78+
79+
- `store.init()`(→ `onInit`)은 `stackflow.tsx`에서 store 생성 직후 같은 `useMemo`
80+
안에서 동기 호출된다 — activity 첫 렌더 전에 resolve가 완료된다.
81+
- `useLoaderData``useThenable`로 promise의 `status` 필드를 동기 inspect한다.
82+
따라서 deferred는 naked Promise가 아니라 **SyncInspectablePromise 규약을 따르는
83+
커스텀 deferred**여야 한다: resolve 시 비-thenable 값이면 `status`/`value`
84+
동기로 갱신한다. 이로써 동기 loader가 첫 렌더에서 suspend 없이 그려지는 create
85+
path와의 의미 대칭이 유지된다 (naked Promise는 adoption이 마이크로태스크를 거쳐
86+
동기 loader조차 Suspense fallback이 한 번 번쩍인다).
87+
- `onInit``initInfo`를 받으므로 load 게이팅이 가능하고, v2에서는 `initInfo`
88+
부재로 자연스럽게 비활성화된다(스펙 3과 일관).
89+
90+
## 비목표
91+
92+
- 런타임 훅(`onBeforePush`/`onBeforeReplace`)의 pause/resume·lazy preload 동작 변경
93+
- create path 동작 변경 — 단, `Replaced` 진입 이벤트 처리 확장(스펙 7)은 예외.
94+
`initialLoaderData`를 모든 진입 이벤트에 붙이는 기존 quirk은 유지
95+
- 시퀀스 재구성/re-dating — "settled 복원 보장" 같은 load policy는 제공하지 않음
96+
(snapshot provider나 별도 플러그인의 몫)
97+
- load path에서의 lazy 컴포넌트 preload — 초기화엔 보호할 전환이 없음, Suspense가 처리
98+
- plugin-history-sync의 load path 대응 (FEP-2001 영역)
99+
- **load path + SSR**`store.init()``isBrowser()`일 때만 호출되므로 서버 렌더
100+
중 스냅샷이 제공되면 loader deferred가 pending으로 남는다. 스냅샷 복원은
101+
본질적으로 클라이언트 시나리오이고(`initialLoaderData`를 create 전용으로 정한
102+
것과 일관) 현 persister도 서버에선 스냅샷을 제공하지 않으므로 미지원으로 명시
103+
104+
## 테스트 계획
105+
106+
`makeCoreStore` + `provideSnapshot` 플러그인으로 실제 load path를 구동(`store.init()`
107+
호출 포함)하는 spec 추가:
108+
109+
- alive activity에만 loader 실행 (죽은 activity의 loader 미실행 검증)
110+
- `Replaced`로 진입한 alive activity의 loaderData resolve
111+
- 동기 loader의 결과가 `init()` 직후 동기 inspect로 FULFILLED (suspend 없는 첫 렌더
112+
보장 — create path와의 의미 대칭)
113+
- 죽은 activity의 deferred는 pending 유지
114+
- 저장된 stale loaderData 덮어쓰기 / loader 없는 activity 통과
115+
- load path에서 `initialLoaderData` 무시, create path에서는 기존 동작 유지
116+
- create path에서 `Replaced` 포함 시퀀스: `Replaced` 진입 activity에 fresh loader
117+
실행 및 `initialLoaderData` 부착 (eager — `init()` 호출 없이도 동작)
118+
- `initInfo` 부재 시(v2 시뮬레이션) create 동작 보존 (deferred 미주입)
119+
- loader reject가 `SnapshotLoadError`로 승격되지 않음
120+
121+
## 릴리즈·커밋
122+
123+
- changeset: `@stackflow/react` **patch** (`fix:` — 선언된 `^2 || ^3` 호환성 대비
124+
버그픽스, FEP-2590 선례와 동일)
125+
- 커밋은 작은 의미 단위로 분리: 구현+테스트 → changeset

0 commit comments

Comments
 (0)