From 9d628e783f58bc2e358bf86fe8683c0b5ac611b1 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 23 Jul 2026 12:38:18 +0900 Subject: [PATCH 1/4] fix(react): isolate loader data from activity context (FEP-2613) --- .../react/src/loader/LoaderDataContext.tsx | 12 ++ .../react/src/loader/loaderPlugin.tsx | 204 +++++++++++++----- .../react/src/loader/useLoaderData.ts | 4 +- 3 files changed, 161 insertions(+), 59 deletions(-) create mode 100644 integrations/react/src/loader/LoaderDataContext.tsx diff --git a/integrations/react/src/loader/LoaderDataContext.tsx b/integrations/react/src/loader/LoaderDataContext.tsx new file mode 100644 index 000000000..c9cb9fd48 --- /dev/null +++ b/integrations/react/src/loader/LoaderDataContext.tsx @@ -0,0 +1,12 @@ +import { createContext, useContext } from "react"; +import type { SyncInspectablePromise } from "../utils/SyncInspectablePromise"; + +export const LoaderDataContext = createContext< + SyncInspectablePromise | undefined +>(undefined); + +export const LoaderDataProvider = LoaderDataContext.Provider; + +export function useLoaderDataPromise() { + return useContext(LoaderDataContext); +} diff --git a/integrations/react/src/loader/loaderPlugin.tsx b/integrations/react/src/loader/loaderPlugin.tsx index 59087dd0c..b9e41d3d7 100644 --- a/integrations/react/src/loader/loaderPlugin.tsx +++ b/integrations/react/src/loader/loaderPlugin.tsx @@ -18,6 +18,7 @@ import { type SyncInspectableDeferred, type SyncInspectablePromise, } from "../utils/SyncInspectablePromise"; +import { LoaderDataProvider } from "./LoaderDataContext"; export function loaderPlugin< T extends ActivityDefinition, @@ -29,32 +30,94 @@ export function loaderPlugin< loadData: (activityName: string, activityParams: {}) => unknown, ): StackflowReactPlugin { return () => { - const loadPathDeferreds = new WeakMap< - SyncInspectablePromise, + const loaderDataByEventId = new Map< + string, + SyncInspectablePromise + >(); + const runtimeLoaderDataByActivityId = new Map< + string, + SyncInspectablePromise + >(); + const loadPathDeferreds = new Map< + string, SyncInspectableDeferred >(); + const deleteLoaderData = (eventId: string) => { + loaderDataByEventId.delete(eventId); + loadPathDeferreds.delete(eventId); + }; + + const cleanupLoaderData = (stack: Stack) => { + const retainedEventIds = new Set( + stack.activities + .filter((activity) => activity.transitionState !== "exit-done") + .map((activity) => activity.enteredBy.id), + ); + + stack.pausedEvents?.forEach((event) => { + if (event.name === "Pushed" || event.name === "Replaced") { + retainedEventIds.add(event.id); + } + }); + + loaderDataByEventId.forEach((_, eventId) => { + if (!retainedEventIds.has(eventId)) { + deleteLoaderData(eventId); + } + }); + }; + + const promoteRuntimeLoaderData = (stack: Stack) => { + const promote = (activityId: string, eventId: string) => { + if (loaderDataByEventId.has(eventId)) { + return; + } + + const loaderData = runtimeLoaderDataByActivityId.get(activityId); + if (!loaderData) { + return; + } + + loaderDataByEventId.set(eventId, loaderData); + runtimeLoaderDataByActivityId.delete(activityId); + }; + + // A paused replacement may reuse the current activity ID, so the staged + // value belongs to the newest queued generation rather than its predecessor. + stack.pausedEvents + ?.slice() + .reverse() + .forEach((event) => { + if (event.name === "Pushed" || event.name === "Replaced") { + promote(event.activityId, event.id); + } + }); + stack.activities.forEach((activity) => { + promote(activity.id, activity.enteredBy.id); + }); + }; + const resolveDeferredLoaderData = ({ + eventId, activityName, activityParams, - loaderData, }: { + eventId: string; activityName: string; activityParams: {}; - loaderData: SyncInspectablePromise | undefined; }) => { const matchActivity = input.config.activities.find( (candidate) => candidate.name === activityName, ); - const deferred = loaderData - ? loadPathDeferreds.get(loaderData) - : undefined; + const loaderData = loaderDataByEventId.get(eventId); + const deferred = loadPathDeferreds.get(eventId); if (!matchActivity?.loader || !loaderData || !deferred) { return; } - loadPathDeferreds.delete(loaderData); + loadPathDeferreds.delete(eventId); Promise.allSettled([loaderData]).then(([loaderDataPromiseResult]) => { printLoaderDataPromiseError({ @@ -75,9 +138,9 @@ export function loaderPlugin< .filter((activity) => activity.transitionState !== "exit-done") .forEach((activity) => { resolveDeferredLoaderData({ + eventId: activity.enteredBy.id, activityName: activity.name, activityParams: activity.params, - loaderData: (activity.context as any)?.loaderData, }); }); }; @@ -91,16 +154,32 @@ export function loaderPlugin< } resolveDeferredLoaderData({ + eventId: event.id, activityName: event.activityName, activityParams: event.activityParams, - loaderData: (event.activityContext as any)?.loaderData, }); }); }; return { key: "plugin-loader", + wrapActivity({ activity }) { + return ( + + {activity.render()} + + ); + }, overrideInitialEvents({ initialEvents, initialContext, initInfo }) { + loaderDataByEventId.clear(); + runtimeLoaderDataByActivityId.clear(); + loadPathDeferreds.clear(); + if (initialEvents.length === 0) { return []; } @@ -120,15 +199,10 @@ export function loaderPlugin< } const loaderData = defer(); - loadPathDeferreds.set(loaderData.promise, loaderData); - - return { - ...event, - activityContext: { - ...event.activityContext, - loaderData: loaderData.promise, - }, - }; + loaderDataByEventId.set(event.id, loaderData.promise); + loadPathDeferreds.set(event.id, loaderData); + + return event; }); } @@ -138,13 +212,11 @@ export function loaderPlugin< } if (initialContext.initialLoaderData) { - return { - ...event, - activityContext: { - ...event.activityContext, - loaderData: resolve(initialContext.initialLoaderData), - }, - }; + loaderDataByEventId.set( + event.id, + resolve(initialContext.initialLoaderData), + ); + return event; } const { activityName, activityParams } = event; @@ -168,26 +240,35 @@ export function loaderPlugin< }); }); - return { - ...event, - activityContext: { - ...event.activityContext, - loaderData, - }, - }; + loaderDataByEventId.set(event.id, loaderData); + return event; }); }, onInit({ actions, initInfo }) { - if (initInfo?.kind !== "load") { - return; + const stack = actions.getStack(); + + if (initInfo?.kind === "load") { + resolveRestoredStackLoaderData(stack); + resolvePausedEventLoaderData(stack.pausedEvents); } + cleanupLoaderData(stack); + }, + onChanged({ actions }) { const stack = actions.getStack(); - resolveRestoredStackLoaderData(stack); - resolvePausedEventLoaderData(stack.pausedEvents); + promoteRuntimeLoaderData(stack); + cleanupLoaderData(stack); }, - onBeforePush: createBeforeRouteHandler(input, loadData), - onBeforeReplace: createBeforeRouteHandler(input, loadData), + onBeforePush: createBeforeRouteHandler({ + input, + loadData, + runtimeLoaderDataByActivityId, + }), + onBeforeReplace: createBeforeRouteHandler({ + input, + loadData, + runtimeLoaderDataByActivityId, + }), }; }; } @@ -202,15 +283,18 @@ function createBeforeRouteHandler< R extends { [activityName in RegisteredActivityName]: ActivityComponentType; }, ->( - input: StackflowInput, - loadData: (activityName: string, activityParams: {}) => unknown, -): OnBeforeRoute { - return ({ - actionParams, - actions: { overrideActionParams, pause, resume }, - }) => { - const { activityName, activityParams, activityContext } = actionParams; +>({ + input, + loadData, + runtimeLoaderDataByActivityId, +}: { + input: StackflowInput; + loadData: (activityName: string, activityParams: {}) => unknown; + runtimeLoaderDataByActivityId: Map>; +}): OnBeforeRoute { + return ({ actionParams, actions }) => { + const { activityId, activityName, activityParams, activityContext } = + actionParams; const matchActivity = input.config.activities.find( (activity) => activity.name === activityName, @@ -241,7 +325,7 @@ function createBeforeRouteHandler< (shouldRenderImmediately !== true || "loading" in matchActivityComponent === false) ) { - pause(); + actions.pause(); Promise.allSettled([loaderData, lazyComponentPromise]) .then(([loaderDataPromiseResult, lazyComponentPromiseResult]) => { @@ -255,17 +339,23 @@ function createBeforeRouteHandler< }); }) .finally(() => { - resume(); + actions.resume(); }); } - overrideActionParams({ - ...actionParams, - activityContext: { - ...activityContext, - loaderData, - }, - }); + if (loaderData) { + // Stage after a possible pause so that pause's own change notification + // cannot associate a same-ID replacement with the current generation. + runtimeLoaderDataByActivityId.set(activityId, loaderData); + + Promise.resolve().then(() => { + // Core route dispatch is synchronous. A value that was not promoted by + // the next microtask belongs to an action that never reached the stack. + if (runtimeLoaderDataByActivityId.get(activityId) === loaderData) { + runtimeLoaderDataByActivityId.delete(activityId); + } + }); + } }; } diff --git a/integrations/react/src/loader/useLoaderData.ts b/integrations/react/src/loader/useLoaderData.ts index f7c13944a..98b5a8219 100644 --- a/integrations/react/src/loader/useLoaderData.ts +++ b/integrations/react/src/loader/useLoaderData.ts @@ -1,10 +1,10 @@ import type { ActivityLoaderArgs } from "@stackflow/config"; import { resolve } from "../utils/SyncInspectablePromise"; import { useThenable } from "../utils/useThenable"; -import { useActivity } from "../activity/useActivity"; +import { useLoaderDataPromise } from "./LoaderDataContext"; export function useLoaderData< T extends (args: ActivityLoaderArgs) => any, >(): Awaited> { - return useThenable(resolve((useActivity().context as any)?.loaderData)); + return useThenable(resolve(useLoaderDataPromise())) as Awaited>; } From 87286bac411ba2ddc9010fe49e8b1244d4b0f6cc Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 23 Jul 2026 12:38:38 +0900 Subject: [PATCH 2/4] chore: add FEP-2613 changeset --- .changeset/fep-2613-isolate-loader-data.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fep-2613-isolate-loader-data.md diff --git a/.changeset/fep-2613-isolate-loader-data.md b/.changeset/fep-2613-isolate-loader-data.md new file mode 100644 index 000000000..8c8f0f9fb --- /dev/null +++ b/.changeset/fep-2613-isolate-loader-data.md @@ -0,0 +1,5 @@ +--- +"@stackflow/react": patch +--- + +Keep loader results in loaderPlugin-owned runtime state so activity context and captured stack snapshots remain free of loader promises and return values. From a377c26edcb5b9625c34a955377105d6b8796018 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 18:28:21 +0900 Subject: [PATCH 3/4] fix(react): retain loader data for deferred renders (FEP-2613) --- .../react/src/loader/loaderPlugin.tsx | 34 +++---------------- 1 file changed, 4 insertions(+), 30 deletions(-) diff --git a/integrations/react/src/loader/loaderPlugin.tsx b/integrations/react/src/loader/loaderPlugin.tsx index b9e41d3d7..2a680cf25 100644 --- a/integrations/react/src/loader/loaderPlugin.tsx +++ b/integrations/react/src/loader/loaderPlugin.tsx @@ -30,6 +30,8 @@ export function loaderPlugin< loadData: (activityName: string, activityParams: {}) => unknown, ): StackflowReactPlugin { return () => { + // React may still render an older deferred stack after core advances, so + // entered generations remain available until this plugin instance is released. const loaderDataByEventId = new Map< string, SyncInspectablePromise @@ -43,31 +45,6 @@ export function loaderPlugin< SyncInspectableDeferred >(); - const deleteLoaderData = (eventId: string) => { - loaderDataByEventId.delete(eventId); - loadPathDeferreds.delete(eventId); - }; - - const cleanupLoaderData = (stack: Stack) => { - const retainedEventIds = new Set( - stack.activities - .filter((activity) => activity.transitionState !== "exit-done") - .map((activity) => activity.enteredBy.id), - ); - - stack.pausedEvents?.forEach((event) => { - if (event.name === "Pushed" || event.name === "Replaced") { - retainedEventIds.add(event.id); - } - }); - - loaderDataByEventId.forEach((_, eventId) => { - if (!retainedEventIds.has(eventId)) { - deleteLoaderData(eventId); - } - }); - }; - const promoteRuntimeLoaderData = (stack: Stack) => { const promote = (activityId: string, eventId: string) => { if (loaderDataByEventId.has(eventId)) { @@ -245,19 +222,16 @@ export function loaderPlugin< }); }, onInit({ actions, initInfo }) { - const stack = actions.getStack(); - if (initInfo?.kind === "load") { + const stack = actions.getStack(); + resolveRestoredStackLoaderData(stack); resolvePausedEventLoaderData(stack.pausedEvents); } - - cleanupLoaderData(stack); }, onChanged({ actions }) { const stack = actions.getStack(); promoteRuntimeLoaderData(stack); - cleanupLoaderData(stack); }, onBeforePush: createBeforeRouteHandler({ input, From 63fd19748a03c132096fcbcb123af5b3887f1cc4 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 18:40:33 +0900 Subject: [PATCH 4/4] fix(react): invalidate canceled loader staging (FEP-2613) --- integrations/react/src/loader/loaderPlugin.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/integrations/react/src/loader/loaderPlugin.tsx b/integrations/react/src/loader/loaderPlugin.tsx index 2a680cf25..f4a47648e 100644 --- a/integrations/react/src/loader/loaderPlugin.tsx +++ b/integrations/react/src/loader/loaderPlugin.tsx @@ -270,6 +270,10 @@ function createBeforeRouteHandler< const { activityId, activityName, activityParams, activityContext } = actionParams; + // A canceled attempt has no entry generation, so its alias cannot belong + // to a later route attempt that reuses the same activity ID. + runtimeLoaderDataByActivityId.delete(activityId); + const matchActivity = input.config.activities.find( (activity) => activity.name === activityName, );