Skip to content
Merged
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-loader-result-reference.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions integrations/react/src/loader/LoaderResultContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createContext } from "react";
import type { SyncInspectablePromise } from "../utils/SyncInspectablePromise";

export const LoaderResultContext = createContext<
SyncInspectablePromise<unknown> | undefined
>(undefined);
94 changes: 94 additions & 0 deletions integrations/react/src/loader/LoaderResultStore.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
start?: (load: () => unknown) => boolean;
};

let nextLoaderResultId = 0;

export class LoaderResultStore {
private readonly entries = new Map<LoaderResultId, LoaderResultEntry>();

add(promise: SyncInspectablePromise<unknown>): LoaderResultId {
const loaderResultId = makeLoaderResultId();
this.entries.set(loaderResultId, { promise });
return loaderResultId;
}

addDeferred(): LoaderResultId {
const loaderData = defer<unknown>();
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<string, unknown>)[
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();
}
120 changes: 71 additions & 49 deletions integrations/react/src/loader/loaderPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -29,45 +28,38 @@ export function loaderPlugin<
loadData: (activityName: string, activityParams: {}) => unknown,
): StackflowReactPlugin {
return () => {
const loadPathDeferreds = new WeakMap<
SyncInspectablePromise<unknown>,
SyncInspectableDeferred<unknown>
>();
const loaderResultStore = new LoaderResultStore();

const resolveDeferredLoaderData = ({
activityName,
activityParams,
loaderData,
loaderResultId,
}: {
activityName: string;
activityParams: {};
loaderData: SyncInspectablePromise<unknown> | 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({
promiseResult: loaderDataPromiseResult,
activityName: matchActivity.name,
});
});

try {
deferred.resolve(loadData(activityName, activityParams));
} catch (error) {
deferred.reject(error);
}
};

const resolveRestoredStackLoaderData = (stack: Stack) => {
Expand All @@ -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),
});
});
};
Expand All @@ -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),
});
});
};
Expand All @@ -119,15 +111,14 @@ export function loaderPlugin<
return event;
}

const loaderData = defer<unknown>();
loadPathDeferreds.set(loaderData.promise, loaderData);
const loaderResultId = loaderResultStore.addDeferred();

return {
...event,
activityContext: {
...event.activityContext,
loaderData: loaderData.promise,
},
activityContext: loaderResultStore.withId(
event.activityContext,
loaderResultId,
),
};
});
}
Expand All @@ -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(
Expand All @@ -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({
Expand All @@ -170,10 +166,10 @@ export function loaderPlugin<

return {
...event,
activityContext: {
...event.activityContext,
loaderData,
},
activityContext: loaderResultStore.withId(
event.activityContext,
loaderResultId,
),
};
});
},
Expand All @@ -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 (
<LoaderResultContext.Provider value={loaderResultPromise}>
{activity.render()}
</LoaderResultContext.Provider>
);
},
};
};
}
Expand All @@ -205,6 +220,7 @@ function createBeforeRouteHandler<
>(
input: StackflowInput<T, R>,
loadData: (activityName: string, activityParams: {}) => unknown,
loaderResultStore: LoaderResultStore,
): OnBeforeRoute {
return ({ actionParams, actions }) => {
if (actions.isPrevented()) {
Expand Down Expand Up @@ -261,12 +277,18 @@ function createBeforeRouteHandler<
});
}

if (!loaderData) {
return;
}

const loaderResultId = loaderResultStore.add(loaderData);

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