Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fep-2613-isolate-loader-data.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions integrations/react/src/loader/LoaderDataContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createContext, useContext } from "react";
import type { SyncInspectablePromise } from "../utils/SyncInspectablePromise";

export const LoaderDataContext = createContext<
SyncInspectablePromise<unknown> | undefined
>(undefined);

export const LoaderDataProvider = LoaderDataContext.Provider;

export function useLoaderDataPromise() {
return useContext(LoaderDataContext);
}
184 changes: 126 additions & 58 deletions integrations/react/src/loader/loaderPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type SyncInspectableDeferred,
type SyncInspectablePromise,
} from "../utils/SyncInspectablePromise";
import { LoaderDataProvider } from "./LoaderDataContext";

export function loaderPlugin<
T extends ActivityDefinition<RegisteredActivityName>,
Expand All @@ -29,32 +30,71 @@ export function loaderPlugin<
loadData: (activityName: string, activityParams: {}) => unknown,
): StackflowReactPlugin {
return () => {
const loadPathDeferreds = new WeakMap<
SyncInspectablePromise<unknown>,
// 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<unknown>
>();
const runtimeLoaderDataByActivityId = new Map<
string,
SyncInspectablePromise<unknown>
>();
const loadPathDeferreds = new Map<
string,
SyncInspectableDeferred<unknown>
>();

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<unknown> | 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({
Expand All @@ -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,
});
});
};
Expand All @@ -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 (
<LoaderDataProvider
value={
loaderDataByEventId.get(activity.enteredBy.id) ??
runtimeLoaderDataByActivityId.get(activity.id)
}
>
{activity.render()}
</LoaderDataProvider>
);
},
overrideInitialEvents({ initialEvents, initialContext, initInfo }) {
loaderDataByEventId.clear();
runtimeLoaderDataByActivityId.clear();
loadPathDeferreds.clear();

if (initialEvents.length === 0) {
return [];
}
Expand All @@ -120,15 +176,10 @@ export function loaderPlugin<
}

const loaderData = defer<unknown>();
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;
});
}

Expand All @@ -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;
Expand All @@ -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,
}),
};
};
}
Expand All @@ -202,15 +257,22 @@ function createBeforeRouteHandler<
R extends {
[activityName in RegisteredActivityName]: ActivityComponentType<any>;
},
>(
input: StackflowInput<T, R>,
loadData: (activityName: string, activityParams: {}) => unknown,
): OnBeforeRoute {
return ({
actionParams,
actions: { overrideActionParams, pause, resume },
}) => {
const { activityName, activityParams, activityContext } = actionParams;
>({
input,
loadData,
runtimeLoaderDataByActivityId,
}: {
input: StackflowInput<T, R>;
loadData: (activityName: string, activityParams: {}) => unknown;
runtimeLoaderDataByActivityId: Map<string, SyncInspectablePromise<unknown>>;
}): 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,
Expand Down Expand Up @@ -241,7 +303,7 @@ function createBeforeRouteHandler<
(shouldRenderImmediately !== true ||
"loading" in matchActivityComponent === false)
) {
pause();
actions.pause();

Promise.allSettled([loaderData, lazyComponentPromise])
.then(([loaderDataPromiseResult, lazyComponentPromiseResult]) => {
Expand All @@ -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);
}
});
}
};
}

Expand Down
4 changes: 2 additions & 2 deletions integrations/react/src/loader/useLoaderData.ts
Original file line number Diff line number Diff line change
@@ -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>) => any,
>(): Awaited<ReturnType<T>> {
return useThenable(resolve((useActivity().context as any)?.loaderData));
return useThenable(resolve(useLoaderDataPromise())) as Awaited<ReturnType<T>>;
}
Loading