diff --git a/.changeset/fep-2613-loader-result-reference.md b/.changeset/fep-2613-loader-result-reference.md new file mode 100644 index 000000000..f0b91578f --- /dev/null +++ b/.changeset/fep-2613-loader-result-reference.md @@ -0,0 +1,5 @@ +--- +"@stackflow/react": patch +--- + +Keep loader results out of navigation snapshots by storing opaque references in activity context and resolving them within the React loader plugin. diff --git a/integrations/react/src/loader/LoaderResultContext.tsx b/integrations/react/src/loader/LoaderResultContext.tsx new file mode 100644 index 000000000..c7aba1a1a --- /dev/null +++ b/integrations/react/src/loader/LoaderResultContext.tsx @@ -0,0 +1,6 @@ +import { createContext } from "react"; +import type { SyncInspectablePromise } from "../utils/SyncInspectablePromise"; + +export const LoaderResultContext = createContext< + SyncInspectablePromise | undefined +>(undefined); diff --git a/integrations/react/src/loader/LoaderResultStore.ts b/integrations/react/src/loader/LoaderResultStore.ts new file mode 100644 index 000000000..b7779de33 --- /dev/null +++ b/integrations/react/src/loader/LoaderResultStore.ts @@ -0,0 +1,94 @@ +import { + defer, + type SyncInspectablePromise, +} from "../utils/SyncInspectablePromise"; + +const LOADER_RESULT_ID_KEY = "@stackflow/react/loaderResultId"; + +export type LoaderResultId = string; + +type LoaderResultEntry = { + promise: SyncInspectablePromise; + start?: (load: () => unknown) => boolean; +}; + +let nextLoaderResultId = 0; + +export class LoaderResultStore { + private readonly entries = new Map(); + + add(promise: SyncInspectablePromise): LoaderResultId { + const loaderResultId = makeLoaderResultId(); + this.entries.set(loaderResultId, { promise }); + return loaderResultId; + } + + addDeferred(): LoaderResultId { + const loaderData = defer(); + let started = false; + const loaderResultId = makeLoaderResultId(); + + this.entries.set(loaderResultId, { + promise: loaderData.promise, + start(load) { + if (started) { + return false; + } + + started = true; + + try { + loaderData.resolve(load()); + } catch (error) { + loaderData.reject(error); + } + + return true; + }, + }); + + return loaderResultId; + } + + get(loaderResultId: LoaderResultId | undefined) { + return loaderResultId + ? this.entries.get(loaderResultId)?.promise + : undefined; + } + + start(loaderResultId: LoaderResultId, load: () => unknown) { + const entry = this.entries.get(loaderResultId); + + if (!entry?.start || !entry.start(load)) { + return undefined; + } + + return entry.promise; + } + + getId(activityContext: unknown): LoaderResultId | undefined { + if (typeof activityContext !== "object" || activityContext === null) { + return undefined; + } + + const loaderResultId = (activityContext as Record)[ + LOADER_RESULT_ID_KEY + ]; + + return typeof loaderResultId === "string" ? loaderResultId : undefined; + } + + withId(activityContext: unknown, loaderResultId: LoaderResultId) { + return { + ...(typeof activityContext === "object" && activityContext !== null + ? activityContext + : {}), + [LOADER_RESULT_ID_KEY]: loaderResultId, + }; + } +} + +function makeLoaderResultId(): LoaderResultId { + nextLoaderResultId += 1; + return nextLoaderResultId.toString(); +} diff --git a/integrations/react/src/loader/loaderPlugin.tsx b/integrations/react/src/loader/loaderPlugin.tsx index a06623cfe..00e2037cd 100644 --- a/integrations/react/src/loader/loaderPlugin.tsx +++ b/integrations/react/src/loader/loaderPlugin.tsx @@ -10,13 +10,12 @@ import { isStructuredActivityComponent, } from "../StructuredActivityComponentType"; import type { StackflowInput } from "../stackflow"; +import { LoaderResultContext } from "./LoaderResultContext"; +import { LoaderResultStore, type LoaderResultId } from "./LoaderResultStore"; import { - defer, inspect, PromiseStatus, resolve, - type SyncInspectableDeferred, - type SyncInspectablePromise, } from "../utils/SyncInspectablePromise"; export function loaderPlugin< @@ -29,32 +28,31 @@ export function loaderPlugin< loadData: (activityName: string, activityParams: {}) => unknown, ): StackflowReactPlugin { return () => { - const loadPathDeferreds = new WeakMap< - SyncInspectablePromise, - SyncInspectableDeferred - >(); + const loaderResultStore = new LoaderResultStore(); const resolveDeferredLoaderData = ({ activityName, activityParams, - loaderData, + loaderResultId, }: { activityName: string; activityParams: {}; - loaderData: SyncInspectablePromise | undefined; + loaderResultId: LoaderResultId | undefined; }) => { const matchActivity = input.config.activities.find( (candidate) => candidate.name === activityName, ); - const deferred = loaderData - ? loadPathDeferreds.get(loaderData) - : undefined; - - if (!matchActivity?.loader || !loaderData || !deferred) { + if (!matchActivity?.loader || !loaderResultId) { return; } - loadPathDeferreds.delete(loaderData); + const loaderData = loaderResultStore.start(loaderResultId, () => + loadData(activityName, activityParams), + ); + + if (!loaderData) { + return; + } Promise.allSettled([loaderData]).then(([loaderDataPromiseResult]) => { printLoaderDataPromiseError({ @@ -62,12 +60,6 @@ export function loaderPlugin< activityName: matchActivity.name, }); }); - - try { - deferred.resolve(loadData(activityName, activityParams)); - } catch (error) { - deferred.reject(error); - } }; const resolveRestoredStackLoaderData = (stack: Stack) => { @@ -77,7 +69,7 @@ export function loaderPlugin< resolveDeferredLoaderData({ activityName: activity.name, activityParams: activity.params, - loaderData: (activity.context as any)?.loaderData, + loaderResultId: loaderResultStore.getId(activity.context), }); }); }; @@ -93,7 +85,7 @@ export function loaderPlugin< resolveDeferredLoaderData({ activityName: event.activityName, activityParams: event.activityParams, - loaderData: (event.activityContext as any)?.loaderData, + loaderResultId: loaderResultStore.getId(event.activityContext), }); }); }; @@ -119,15 +111,14 @@ export function loaderPlugin< return event; } - const loaderData = defer(); - loadPathDeferreds.set(loaderData.promise, loaderData); + const loaderResultId = loaderResultStore.addDeferred(); return { ...event, - activityContext: { - ...event.activityContext, - loaderData: loaderData.promise, - }, + activityContext: loaderResultStore.withId( + event.activityContext, + loaderResultId, + ), }; }); } @@ -137,16 +128,6 @@ export function loaderPlugin< return event; } - if (initialContext.initialLoaderData) { - return { - ...event, - activityContext: { - ...event.activityContext, - loaderData: resolve(initialContext.initialLoaderData), - }, - }; - } - const { activityName, activityParams } = event; const matchActivity = input.config.activities.find( @@ -159,7 +140,22 @@ export function loaderPlugin< return event; } + if (initialContext.initialLoaderData) { + const loaderResultId = loaderResultStore.add( + resolve(initialContext.initialLoaderData), + ); + + return { + ...event, + activityContext: loaderResultStore.withId( + event.activityContext, + loaderResultId, + ), + }; + } + const loaderData = resolve(loadData(activityName, activityParams)); + const loaderResultId = loaderResultStore.add(loaderData); Promise.allSettled([loaderData]).then(([loaderDataPromiseResult]) => { printLoaderDataPromiseError({ @@ -170,10 +166,10 @@ export function loaderPlugin< return { ...event, - activityContext: { - ...event.activityContext, - loaderData, - }, + activityContext: loaderResultStore.withId( + event.activityContext, + loaderResultId, + ), }; }); }, @@ -186,8 +182,27 @@ export function loaderPlugin< resolveRestoredStackLoaderData(stack); resolvePausedEventLoaderData(stack.pausedEvents); }, - onBeforePush: createBeforeRouteHandler(input, loadData), - onBeforeReplace: createBeforeRouteHandler(input, loadData), + onBeforePush: createBeforeRouteHandler( + input, + loadData, + loaderResultStore, + ), + onBeforeReplace: createBeforeRouteHandler( + input, + loadData, + loaderResultStore, + ), + wrapActivity({ activity }) { + const loaderResultPromise = loaderResultStore.get( + loaderResultStore.getId(activity.context), + ); + + return ( + + {activity.render()} + + ); + }, }; }; } @@ -205,6 +220,7 @@ function createBeforeRouteHandler< >( input: StackflowInput, loadData: (activityName: string, activityParams: {}) => unknown, + loaderResultStore: LoaderResultStore, ): OnBeforeRoute { return ({ actionParams, actions }) => { if (actions.isPrevented()) { @@ -261,12 +277,18 @@ function createBeforeRouteHandler< }); } + if (!loaderData) { + return; + } + + const loaderResultId = loaderResultStore.add(loaderData); + overrideActionParams({ ...actionParams, - activityContext: { - ...activityContext, - loaderData, - }, + activityContext: loaderResultStore.withId( + activityContext, + loaderResultId, + ), }); }; } diff --git a/integrations/react/src/loader/useLoaderData.ts b/integrations/react/src/loader/useLoaderData.ts index f7c13944a..001121678 100644 --- a/integrations/react/src/loader/useLoaderData.ts +++ b/integrations/react/src/loader/useLoaderData.ts @@ -1,10 +1,13 @@ import type { ActivityLoaderArgs } from "@stackflow/config"; +import { useContext } from "react"; import { resolve } from "../utils/SyncInspectablePromise"; import { useThenable } from "../utils/useThenable"; -import { useActivity } from "../activity/useActivity"; +import { LoaderResultContext } from "./LoaderResultContext"; export function useLoaderData< T extends (args: ActivityLoaderArgs) => any, >(): Awaited> { - return useThenable(resolve((useActivity().context as any)?.loaderData)); + return useThenable(resolve(useContext(LoaderResultContext))) as Awaited< + ReturnType + >; }