diff --git a/.changeset/slick-heads-tan.md b/.changeset/slick-heads-tan.md new file mode 100644 index 000000000..98b1766f3 --- /dev/null +++ b/.changeset/slick-heads-tan.md @@ -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. \ No newline at end of file diff --git a/config/src/ActivityLoader.ts b/config/src/ActivityLoader.ts index b7fbf9ef4..fcf56c403 100644 --- a/config/src/ActivityLoader.ts +++ b/config/src/ActivityLoader.ts @@ -1,6 +1,19 @@ import type { ActivityLoaderArgs } from "./ActivityLoaderArgs"; import type { RegisteredActivityName } from "./RegisteredActivityName"; -export type ActivityLoader = ( - args: ActivityLoaderArgs, -) => any; +export type ActivityLoader = { + (args: ActivityLoaderArgs): any; + loaderCacheMaxAge?: number; +}; + +export function loader( + loaderFn: (args: ActivityLoaderArgs) => any, + options?: { + loaderCacheMaxAge?: number; + }, +): ActivityLoader { + return Object.assign( + (args: ActivityLoaderArgs) => loaderFn(args), + options, + ); +} diff --git a/integrations/react/src/__internal__/ActivityComponentMapProvider.tsx b/integrations/react/src/__internal__/ActivityComponentMapProvider.tsx new file mode 100644 index 000000000..1db397429 --- /dev/null +++ b/integrations/react/src/__internal__/ActivityComponentMapProvider.tsx @@ -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 ( + + {children} + + ); +} diff --git a/integrations/react/src/__internal__/MainRenderer.tsx b/integrations/react/src/__internal__/MainRenderer.tsx index 1b63d47a6..b8141e235 100644 --- a/integrations/react/src/__internal__/MainRenderer.tsx +++ b/integrations/react/src/__internal__/MainRenderer.tsx @@ -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 = ({ - activityComponentMap, - initialContext, -}) => { +const MainRenderer: React.FC = ({ initialContext }) => { const coreState = useCoreState(); const plugins = usePlugins(); @@ -40,7 +34,6 @@ const MainRenderer: React.FC = ({ {renderingPlugins.map((plugin) => ( diff --git a/integrations/react/src/__internal__/PluginRenderer.tsx b/integrations/react/src/__internal__/PluginRenderer.tsx index cf179ee78..6702b33df 100644 --- a/integrations/react/src/__internal__/PluginRenderer.tsx +++ b/integrations/react/src/__internal__/PluginRenderer.tsx @@ -1,5 +1,5 @@ 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"; @@ -7,17 +7,14 @@ import type { StackflowReactPlugin } from "./StackflowReactPlugin"; import type { WithRequired } from "./utils"; interface PluginRendererProps { - activityComponentMap: { - [key: string]: ActivityComponentType; - }; plugin: WithRequired, "render">; initialContext: any; } const PluginRenderer: React.FC = ({ - activityComponentMap, plugin, initialContext, }) => { + const activityComponentMap = useActivityComponentMap(); const coreState = useCoreState(); const plugins = usePlugins(); diff --git a/integrations/react/src/__internal__/utils/isPromiseLike.ts b/integrations/react/src/__internal__/utils/isPromiseLike.ts new file mode 100644 index 000000000..6e2fe6d04 --- /dev/null +++ b/integrations/react/src/__internal__/utils/isPromiseLike.ts @@ -0,0 +1,8 @@ +export function isPromiseLike(value: unknown): value is PromiseLike { + return ( + typeof value === "object" && + value !== null && + "then" in value && + typeof value.then === "function" + ); +} diff --git a/integrations/react/src/future/index.ts b/integrations/react/src/future/index.ts index 64a1b4c6f..9147599ed 100644 --- a/integrations/react/src/future/index.ts +++ b/integrations/react/src/future/index.ts @@ -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"; diff --git a/integrations/react/src/future/lazy.ts b/integrations/react/src/future/lazy.ts index 01a6ec36a..0887ae1fa 100644 --- a/integrations/react/src/future/lazy.ts +++ b/integrations/react/src/future/lazy.ts @@ -5,12 +5,17 @@ import type { StaticActivityComponentType } from "../__internal__/StaticActivity export function lazy( load: () => Promise<{ default: StaticActivityComponentType }>, ): LazyActivityComponentType { - let cachedValue: { default: StaticActivityComponentType } | null = null; + let cachedValue: Promise<{ default: StaticActivityComponentType }> | 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; }; diff --git a/integrations/react/src/future/loader/DataLoaderContext.tsx b/integrations/react/src/future/loader/DataLoaderContext.tsx new file mode 100644 index 000000000..8a02edef6 --- /dev/null +++ b/integrations/react/src/future/loader/DataLoaderContext.tsx @@ -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 ( + + {children} + + ); +} + +export function useDataLoader() { + const loadData = useContext(DataLoaderContext); + + if (!loadData) { + throw new Error("useDataLoader() must be used within a DataLoaderProvider"); + } + + return loadData; +} diff --git a/integrations/react/src/future/loader/index.ts b/integrations/react/src/future/loader/index.ts index 034f3f9e5..077c5f7b9 100644 --- a/integrations/react/src/future/loader/index.ts +++ b/integrations/react/src/future/loader/index.ts @@ -1,2 +1,3 @@ +export * from "./DataLoaderContext"; export * from "./loaderPlugin"; export * from "./useLoaderData"; diff --git a/integrations/react/src/future/loader/loaderPlugin.tsx b/integrations/react/src/future/loader/loaderPlugin.tsx index 744768db8..2a0de28f4 100644 --- a/integrations/react/src/future/loader/loaderPlugin.tsx +++ b/integrations/react/src/future/loader/loaderPlugin.tsx @@ -4,6 +4,7 @@ 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< @@ -11,67 +12,71 @@ export function loaderPlugin< R extends { [activityName in RegisteredActivityName]: ActivityComponentType; }, ->(input: StackflowInput): 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, + 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< @@ -83,7 +88,10 @@ function createBeforeRouteHandler< R extends { [activityName in RegisteredActivityName]: ActivityComponentType; }, ->(input: StackflowInput): OnBeforeRoute { +>( + input: StackflowInput, + loadData: (activityName: string, activityParams: {}) => unknown, +): OnBeforeRoute { return ({ actionParams, actions: { overrideActionParams, pause, resume }, @@ -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?.() diff --git a/integrations/react/src/future/makeActions.ts b/integrations/react/src/future/makeActions.ts index 4ff8c188b..2d21056be 100644 --- a/integrations/react/src/future/makeActions.ts +++ b/integrations/react/src/future/makeActions.ts @@ -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, diff --git a/integrations/react/src/future/stackflow.tsx b/integrations/react/src/future/stackflow.tsx index a24ad9604..61dc92183 100644 --- a/integrations/react/src/future/stackflow.tsx +++ b/integrations/react/src/future/stackflow.tsx @@ -11,6 +11,8 @@ import { type PushedEvent, } from "@stackflow/core"; import React, { useMemo } from "react"; +import isEqual from "react-fast-compare"; +import { ActivityComponentMapProvider } from "../__internal__/ActivityComponentMapProvider"; import type { ActivityComponentType } from "../__internal__/ActivityComponentType"; import { makeActivityId } from "../__internal__/activity"; import { CoreProvider } from "../__internal__/core"; @@ -20,7 +22,7 @@ import { isBrowser, makeRef } from "../__internal__/utils"; import type { StackflowReactPlugin } from "../stable"; import type { Actions } from "./Actions"; import { ConfigProvider } from "./ConfigProvider"; -import { loaderPlugin } from "./loader"; +import { DataLoaderProvider, loaderPlugin } from "./loader"; import { makeActions } from "./makeActions"; import { makeStepActions } from "./makeStepActions"; import type { StackComponentType } from "./StackComponentType"; @@ -47,12 +49,74 @@ export type StackflowOutput = { stepActions: StepActions; }; +const DEFAULT_LOADER_CACHE_MAX_AGE = 1000 * 30; + export function stackflow< T extends ActivityDefinition, R extends { [activityName in RegisteredActivityName]: ActivityComponentType; }, >(input: StackflowInput): StackflowOutput { + const loaderDataCacheMap = new Map(); + const loadData = (activityName: string, activityParams: {}) => { + const cache = loaderDataCacheMap.get(activityName); + const cacheEntry = cache?.find((entry) => + isEqual(entry.params, activityParams), + ); + + if (cacheEntry) { + return cacheEntry.data; + } + + const activityConfig = input.config.activities.find( + (activity) => activity.name === activityName, + ); + + if (!activityConfig) { + throw new Error(`Activity ${activityName} is not registered.`); + } + + const loaderData = activityConfig.loader?.({ + params: activityParams, + config: input.config, + }); + const newCacheEntry = { + params: activityParams, + data: loaderData, + }; + + if (cache) { + cache.push(newCacheEntry); + } else { + loaderDataCacheMap.set(activityName, [newCacheEntry]); + } + + const clearCache = () => { + const cache = loaderDataCacheMap.get(activityName); + + if (!cache) return; + + loaderDataCacheMap.set( + activityName, + cache.filter((entry) => entry !== newCacheEntry), + ); + }; + const clearCacheAfterMaxAge = () => { + setTimeout( + clearCache, + activityConfig.loader?.loaderCacheMaxAge ?? + DEFAULT_LOADER_CACHE_MAX_AGE, + ); + }; + + Promise.resolve(loaderData).then(clearCacheAfterMaxAge, (error) => { + clearCache(); + + throw error; + }); + + return loaderData; + }; const plugins = [ ...(input.plugins ?? []) .flat(Number.POSITIVE_INFINITY as 0) @@ -61,7 +125,7 @@ export function stackflow< /** * `loaderPlugin()` must be placed after `historySyncPlugin()` */ - loaderPlugin(input), + loaderPlugin(input, loadData), ]; const enoughPastTime = () => @@ -164,10 +228,11 @@ export function stackflow< - + + + + + diff --git a/integrations/react/src/future/useActivityPreparation.ts b/integrations/react/src/future/useActivityPreparation.ts new file mode 100644 index 000000000..53b4e4cc4 --- /dev/null +++ b/integrations/react/src/future/useActivityPreparation.ts @@ -0,0 +1,12 @@ +import type { RegisteredActivityName } from "@stackflow/config"; +import { usePrepare } from "./usePrepare"; + +export function useActivityPreparation( + activities: { activityName: RegisteredActivityName }[], +) { + const prepare = usePrepare(); + + for (const { activityName } of activities) { + prepare(activityName); + } +} diff --git a/integrations/react/src/future/useFlow.ts b/integrations/react/src/future/useFlow.ts index c46d8aa11..fdd16dccc 100644 --- a/integrations/react/src/future/useFlow.ts +++ b/integrations/react/src/future/useFlow.ts @@ -8,5 +8,6 @@ export type FlowOutput = { export function useFlow(): Actions { const coreActions = useCoreActions(); + return makeActions(() => coreActions); } diff --git a/integrations/react/src/future/usePrepare.ts b/integrations/react/src/future/usePrepare.ts new file mode 100644 index 000000000..1ab3d1523 --- /dev/null +++ b/integrations/react/src/future/usePrepare.ts @@ -0,0 +1,45 @@ +import type { + InferActivityParams, + RegisteredActivityName, +} from "@stackflow/config"; +import { useActivityComponentMap } from "../__internal__/ActivityComponentMapProvider"; +import { useDataLoader } from "./loader"; +import { useConfig } from "./useConfig"; + +export type Prepare = ( + activityName: K, + activityParams?: InferActivityParams, +) => Promise; + +export function usePrepare(): Prepare { + const config = useConfig(); + const loadData = useDataLoader(); + const activityComponentMap = useActivityComponentMap(); + + return async function prepare( + activityName: K, + activityParams?: InferActivityParams, + ) { + const activityConfig = config.activities.find( + ({ name }) => name === activityName, + ); + const prefetchTasks: Promise[] = []; + + if (!activityConfig) + throw new Error(`Activity ${activityName} is not registered.`); + + if (activityParams && activityConfig.loader) { + prefetchTasks.push( + Promise.resolve(loadData(activityName, activityParams)), + ); + } + + if ("_load" in activityComponentMap[activityName]) { + const lazyComponent = activityComponentMap[activityName]; + + prefetchTasks.push(Promise.resolve(lazyComponent._load?.())); + } + + await Promise.all(prefetchTasks); + }; +} diff --git a/integrations/react/src/stable/stackflow.tsx b/integrations/react/src/stable/stackflow.tsx index ccf5ea1f5..90697e0fb 100644 --- a/integrations/react/src/stable/stackflow.tsx +++ b/integrations/react/src/stable/stackflow.tsx @@ -7,6 +7,7 @@ import type { import { makeCoreStore, makeEvent } from "@stackflow/core"; import { memo, useMemo } from "react"; +import { ActivityComponentMapProvider } from "../__internal__/ActivityComponentMapProvider"; import type { ActivityComponentType } from "../__internal__/ActivityComponentType"; import { findActivityById, @@ -234,10 +235,9 @@ export function stackflow( return ( - + + + );