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. 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..f4a47648e 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,71 @@ export function loaderPlugin< loadData: (activityName: string, activityParams: {}) => unknown, ): StackflowReactPlugin { return () => { - const loadPathDeferreds = new WeakMap< - SyncInspectablePromise, + // 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 + >(); + const runtimeLoaderDataByActivityId = new Map< + string, + SyncInspectablePromise + >(); + const loadPathDeferreds = new Map< + string, SyncInspectableDeferred >(); + 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 +115,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 +131,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 +176,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 +189,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 +217,32 @@ export function loaderPlugin< }); }); - return { - ...event, - activityContext: { - ...event.activityContext, - loaderData, - }, - }; + loaderDataByEventId.set(event.id, loaderData); + return event; }); }, onInit({ actions, initInfo }) { - if (initInfo?.kind !== "load") { - return; - } + if (initInfo?.kind === "load") { + const stack = actions.getStack(); + resolveRestoredStackLoaderData(stack); + resolvePausedEventLoaderData(stack.pausedEvents); + } + }, + onChanged({ actions }) { const stack = actions.getStack(); - resolveRestoredStackLoaderData(stack); - resolvePausedEventLoaderData(stack.pausedEvents); + promoteRuntimeLoaderData(stack); }, - onBeforePush: createBeforeRouteHandler(input, loadData), - onBeforeReplace: createBeforeRouteHandler(input, loadData), + onBeforePush: createBeforeRouteHandler({ + input, + loadData, + runtimeLoaderDataByActivityId, + }), + onBeforeReplace: createBeforeRouteHandler({ + input, + loadData, + runtimeLoaderDataByActivityId, + }), }; }; } @@ -202,15 +257,22 @@ 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; + + // 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, @@ -241,7 +303,7 @@ function createBeforeRouteHandler< (shouldRenderImmediately !== true || "loading" in matchActivityComponent === false) ) { - pause(); + actions.pause(); Promise.allSettled([loaderData, lazyComponentPromise]) .then(([loaderDataPromiseResult, lazyComponentPromiseResult]) => { @@ -255,17 +317,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>; }