Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
c447299
test
ENvironmentSet Jul 31, 2025
479fa84
optimize
ENvironmentSet Jul 31, 2025
a1d42ba
fix
ENvironmentSet Jul 31, 2025
d97b03e
fix
ENvironmentSet Jul 31, 2025
833d47d
format
ENvironmentSet Jul 31, 2025
6ec8c73
format
ENvironmentSet Jul 31, 2025
e52974e
fix
ENvironmentSet Jul 31, 2025
2658640
refactor
ENvironmentSet Jul 31, 2025
cb1ab37
refactor: reduce complexivity
Aug 10, 2025
6a8333b
fix: type error
Aug 10, 2025
9f952f7
feat: Detach prepare logic from useFlow
Aug 10, 2025
31c2f62
add exports
Aug 10, 2025
8530765
changeset
Aug 10, 2025
53e5454
chore: run format
orionmiz Aug 11, 2025
3926564
Update changeset
Aug 13, 2025
e6a009a
Handle thenable
Aug 13, 2025
e2aed6f
optimize cache gc
Aug 13, 2025
a8dcadf
Optimize cache entry creation
Aug 13, 2025
792a17b
use activityComponentMapContext
Aug 13, 2025
5545142
do not cache rejected promise
Aug 13, 2025
978ba32
accept thenable
Aug 13, 2025
410b4b9
fix replace id bug
Aug 13, 2025
74ce220
fix import
Aug 13, 2025
dfd5291
fix import
Aug 13, 2025
ece2e9d
separate prepare from actions
Aug 13, 2025
2dee06d
update changeset
Aug 13, 2025
9ceb57f
spec compat
Aug 13, 2025
85868a3
use promiselike
Aug 13, 2025
9b2a4b5
revert
Aug 13, 2025
322f64d
run format
Aug 13, 2025
29b613b
don't cache rejected promise
Aug 13, 2025
19be7f7
throw error
Aug 13, 2025
b155672
Merge branch 'main' into route-preload
ENvironmentSet Aug 18, 2025
cbd7cfd
remove unused import
Aug 18, 2025
24c1600
add loader constructor
Aug 18, 2025
1ef17c7
fix
Aug 18, 2025
55f23c8
update
Aug 18, 2025
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
7 changes: 7 additions & 0 deletions .changeset/slick-heads-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@stackflow/react": minor
---

Add prefetch API for lazy activity component and loader data.
- A hook `usePrepare()` which returns `prepare(activityName[, activityParams])` is added for navigation warmup.
- A hook `useActivityPreparation(activities)` for preparing navigations inside a component is added.
19 changes: 16 additions & 3 deletions config/src/ActivityLoader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import type { ActivityLoaderArgs } from "./ActivityLoaderArgs";
import type { RegisteredActivityName } from "./RegisteredActivityName";

export type ActivityLoader<ActivityName extends RegisteredActivityName> = (
args: ActivityLoaderArgs<ActivityName>,
) => any;
export type ActivityLoader<ActivityName extends RegisteredActivityName> = {
(args: ActivityLoaderArgs<ActivityName>): any;
loaderCacheMaxAge?: number;
};

export function loader<ActivityName extends RegisteredActivityName>(
loaderFn: (args: ActivityLoaderArgs<ActivityName>) => any,
options?: {
loaderCacheMaxAge?: number;
},
): ActivityLoader<ActivityName> {
return Object.assign(
(args: ActivityLoaderArgs<ActivityName>) => loaderFn(args),
options,
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { RegisteredActivityName } from "@stackflow/config";
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
import type { ActivityComponentType } from "./ActivityComponentType";

const ActivityComponentMapContext = createContext<
| {
[activityName in RegisteredActivityName]: ActivityComponentType;
}
| null
>(null);

export function useActivityComponentMap() {
const context = useContext(ActivityComponentMapContext);
if (context === null) {
throw new Error(
"useActivityComponentMap must be used within ActivityComponentMapProvider",
);
}
return context;
}

type ActivityComponentMapProviderProps = PropsWithChildren<{
value: {
[activityName in RegisteredActivityName]: ActivityComponentType;
};
}>;

export function ActivityComponentMapProvider({
children,
value,
}: ActivityComponentMapProviderProps) {
return (
<ActivityComponentMapContext.Provider value={value}>
{children}
</ActivityComponentMapContext.Provider>
);
}
9 changes: 1 addition & 8 deletions integrations/react/src/__internal__/MainRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,9 @@ import { StackProvider } from "./stack";
import type { WithRequired } from "./utils";

interface MainRendererProps {
activityComponentMap: {
[key: string]: ActivityComponentType;
};
initialContext: any;
}
const MainRenderer: React.FC<MainRendererProps> = ({
activityComponentMap,
initialContext,
}) => {
const MainRenderer: React.FC<MainRendererProps> = ({ initialContext }) => {
const coreState = useCoreState();
const plugins = usePlugins();

Expand All @@ -40,7 +34,6 @@ const MainRenderer: React.FC<MainRendererProps> = ({
{renderingPlugins.map((plugin) => (
<PluginRenderer
key={plugin.key}
activityComponentMap={activityComponentMap}
plugin={plugin}
initialContext={initialContext}
/>
Expand Down
7 changes: 2 additions & 5 deletions integrations/react/src/__internal__/PluginRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
import type React from "react";
import type { ActivityComponentType } from "./ActivityComponentType";
import { useActivityComponentMap } from "./ActivityComponentMapProvider";
import { ActivityProvider } from "./activity";
import { useCoreState } from "./core";
import { usePlugins } from "./plugins";
import type { StackflowReactPlugin } from "./StackflowReactPlugin";
import type { WithRequired } from "./utils";

interface PluginRendererProps {
activityComponentMap: {
[key: string]: ActivityComponentType;
};
plugin: WithRequired<ReturnType<StackflowReactPlugin>, "render">;
initialContext: any;
}
const PluginRenderer: React.FC<PluginRendererProps> = ({
activityComponentMap,
plugin,
initialContext,
}) => {
const activityComponentMap = useActivityComponentMap();
const coreState = useCoreState();
const plugins = usePlugins();

Expand Down
8 changes: 8 additions & 0 deletions integrations/react/src/__internal__/utils/isPromiseLike.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === "object" &&
value !== null &&
"then" in value &&
typeof value.then === "function"
);
}
16 changes: 2 additions & 14 deletions integrations/react/src/future/index.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
/**
* Main
*/

export * from "../__internal__/activity/useActivity";

/**
* Types
*/
export * from "../__internal__/StackflowReactPlugin";
/**
* Hooks
*/
export * from "../__internal__/stack/useStack";
export * from "./Actions";
export * from "./ActivityComponentType";
/**
* Utils
*/
export * from "./lazy";
export * from "./loader/useLoaderData";
export * from "./StackComponentType";
export * from "./StepActions";
export * from "./stackflow";
export * from "./useActivityParams";
export * from "./useActivityPreparation";
export * from "./useConfig";
export * from "./useFlow";
export * from "./usePrepare";
export * from "./useStepFlow";
13 changes: 9 additions & 4 deletions integrations/react/src/future/lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ import type { StaticActivityComponentType } from "../__internal__/StaticActivity
export function lazy<T extends { [K in keyof T]: any } = {}>(
load: () => Promise<{ default: StaticActivityComponentType<T> }>,
): LazyActivityComponentType<T> {
let cachedValue: { default: StaticActivityComponentType<T> } | null = null;
let cachedValue: Promise<{ default: StaticActivityComponentType<T> }> | null =
null;

const cachedLoad = async () => {
const cachedLoad = () => {
if (!cachedValue) {
const value = await load();
cachedValue = value;
cachedValue = load();
cachedValue.catch((error) => {
cachedValue = null;

throw error;
});
}
return cachedValue;
};
Expand Down
29 changes: 29 additions & 0 deletions integrations/react/src/future/loader/DataLoaderContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createContext, type ReactNode, useContext } from "react";

export const DataLoaderContext = createContext<
((activityName: string, activityParams: {}) => unknown) | null
>(null);

export function DataLoaderProvider({
loadData,
children,
}: {
loadData: (activityName: string, activityParams: {}) => unknown;
children: ReactNode;
}) {
return (
<DataLoaderContext.Provider value={loadData}>
{children}
</DataLoaderContext.Provider>
);
}

export function useDataLoader() {
const loadData = useContext(DataLoaderContext);

if (!loadData) {
throw new Error("useDataLoader() must be used within a DataLoaderProvider");
}

return loadData;
}
1 change: 1 addition & 0 deletions integrations/react/src/future/loader/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./DataLoaderContext";
export * from "./loaderPlugin";
export * from "./useLoaderData";
125 changes: 66 additions & 59 deletions integrations/react/src/future/loader/loaderPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,74 +4,79 @@ import type {
} from "@stackflow/config";
import type { ActivityComponentType } from "../../__internal__/ActivityComponentType";
import type { StackflowReactPlugin } from "../../__internal__/StackflowReactPlugin";
import { isPromiseLike } from "../../__internal__/utils/isPromiseLike";
import type { StackflowInput } from "../stackflow";

export function loaderPlugin<
T extends ActivityDefinition<RegisteredActivityName>,
R extends {
[activityName in RegisteredActivityName]: ActivityComponentType<any>;
},
>(input: StackflowInput<T, R>): StackflowReactPlugin {
return () => ({
key: "plugin-loader",
overrideInitialEvents({ initialEvents, initialContext }) {
if (initialEvents.length === 0) {
return [];
}

return initialEvents.map((event) => {
if (event.name !== "Pushed") {
return event;
>(
input: StackflowInput<T, R>,
loadData: (activityName: string, activityParams: {}) => unknown,
): StackflowReactPlugin {
return () => {
return {
key: "plugin-loader",
overrideInitialEvents({ initialEvents, initialContext }) {
if (initialEvents.length === 0) {
return [];
}

if (initialContext.initialLoaderData) {
return initialEvents.map((event) => {
if (event.name !== "Pushed") {
return event;
}

if (initialContext.initialLoaderData) {
return {
...event,
activityContext: {
...event.activityContext,
loaderData: initialContext.initialLoaderData,
},
};
}

const { activityName, activityParams } = event;

const matchActivity = input.config.activities.find(
(activity) => activity.name === activityName,
);

const loader = matchActivity?.loader;

if (!loader) {
return event;
}

const loaderData = loadData(activityName, activityParams);

if (isPromiseLike(loaderData)) {
Promise.allSettled([loaderData]).then(
([loaderDataPromiseResult]) => {
printLoaderDataPromiseError({
promiseResult: loaderDataPromiseResult,
activityName: matchActivity.name,
});
},
);
}

return {
...event,
activityContext: {
...event.activityContext,
loaderData: initialContext.initialLoaderData,
loaderData,
},
};
}

const { activityName, activityParams } = event;

const matchActivity = input.config.activities.find(
(activity) => activity.name === activityName,
);

const loader = matchActivity?.loader;

if (!loader) {
return event;
}

const loaderData = loader({
params: activityParams,
config: input.config,
});

if (loaderData instanceof Promise) {
Promise.allSettled([loaderData]).then(([loaderDataPromiseResult]) => {
printLoaderDataPromiseError({
promiseResult: loaderDataPromiseResult,
activityName: matchActivity.name,
});
});
}

return {
...event,
activityContext: {
...event.activityContext,
loaderData,
},
};
});
},
onBeforePush: createBeforeRouteHandler(input),
onBeforeReplace: createBeforeRouteHandler(input),
});
},
onBeforePush: createBeforeRouteHandler(input, loadData),
onBeforeReplace: createBeforeRouteHandler(input, loadData),
};
};
}

type OnBeforeRoute = NonNullable<
Expand All @@ -83,7 +88,10 @@ function createBeforeRouteHandler<
R extends {
[activityName in RegisteredActivityName]: ActivityComponentType<any>;
},
>(input: StackflowInput<T, R>): OnBeforeRoute {
>(
input: StackflowInput<T, R>,
loadData: (activityName: string, activityParams: {}) => unknown,
): OnBeforeRoute {
return ({
actionParams,
actions: { overrideActionParams, pause, resume },
Expand All @@ -99,13 +107,12 @@ function createBeforeRouteHandler<
return;
}

const loaderData = matchActivity.loader?.({
params: activityParams,
config: input.config,
});
const loaderData =
matchActivity.loader && loadData(activityName, activityParams);

const loaderDataPromise =
loaderData instanceof Promise ? loaderData : undefined;
const loaderDataPromise = isPromiseLike(loaderData)
? loaderData
: undefined;
const lazyComponentPromise =
"_load" in matchActivityComponent
? matchActivityComponent._load?.()
Expand Down
4 changes: 2 additions & 2 deletions integrations/react/src/future/makeActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ export function makeActions(
};
},
replace(activityName, activityParams, options) {
const activityId = makeActivityId();
const activityId = options?.activityId ?? makeActivityId();

getCoreActions()?.replace({
activityId: options?.activityId ?? makeActivityId(),
activityId,
activityName,
activityParams,
skipEnterActiveState: parseActionOptions(options).skipActiveState,
Expand Down
Loading