diff --git a/.changeset/happy-ghosts-peel.md b/.changeset/happy-ghosts-peel.md new file mode 100644 index 000000000..19d4b120b --- /dev/null +++ b/.changeset/happy-ghosts-peel.md @@ -0,0 +1,5 @@ +--- +"@stackflow/react": minor +--- + +utilize synchronously inspectable promise to optimize suspense fallback rendering diff --git a/integrations/react/src/__internal__/LazyActivityComponentType.ts b/integrations/react/src/__internal__/LazyActivityComponentType.ts index 19ce2d676..f9a238ddc 100644 --- a/integrations/react/src/__internal__/LazyActivityComponentType.ts +++ b/integrations/react/src/__internal__/LazyActivityComponentType.ts @@ -1,6 +1,6 @@ import type { StaticActivityComponentType } from "./StaticActivityComponentType"; export type LazyActivityComponentType = - React.LazyExoticComponent> & { + StaticActivityComponentType & { _load?: () => Promise<{ default: StaticActivityComponentType }>; }; diff --git a/integrations/react/src/__internal__/StructuredActivityComponentType.tsx b/integrations/react/src/__internal__/StructuredActivityComponentType.tsx index 82258624a..9b6394148 100644 --- a/integrations/react/src/__internal__/StructuredActivityComponentType.tsx +++ b/integrations/react/src/__internal__/StructuredActivityComponentType.tsx @@ -2,7 +2,15 @@ import type { InferActivityParams, RegisteredActivityName, } from "@stackflow/config"; -import { type ComponentType, lazy, type ReactNode } from "react"; +import type { ComponentType, ReactNode } from "react"; +import { preloadableLazyComponent } from "./utils/PreloadableLazyComponent"; +import { + inspect, + PromiseStatus, + reject, + resolve, + type SyncInspectablePromise, +} from "./utils/SyncInspectablePromise"; export const STRUCTURED_ACTIVITY_COMPONENT_TYPE: unique symbol = Symbol( "STRUCTURED_ACTIVITY_COMPONENT_TYPE", @@ -26,8 +34,26 @@ export function structuredActivityComponent< loading?: Loading>; errorHandler?: ErrorHandler>; }): StructuredActivityComponentType> { + const content = options.content; + let cachedContent: SyncInspectablePromise<{ + default: Content>; + }> | null = null; + return { ...options, + content: + typeof content !== "function" + ? content + : () => { + if ( + !cachedContent || + inspect(cachedContent).status === PromiseStatus.REJECTED + ) { + cachedContent = resolve(content()); + } + + return cachedContent; + }, [STRUCTURED_ACTIVITY_COMPONENT_TYPE]: true, }; } @@ -64,16 +90,27 @@ export function getContentComponent( return ContentComponentMap.get(structuredActivityComponent)!; } - const content = structuredActivityComponent.content; - const ContentComponent = - "component" in content - ? content.component - : lazy(async () => { - const { - default: { component: Component }, - } = await content(); - return { default: Component }; - }); + const { Component: ContentComponent } = preloadableLazyComponent(() => { + const content = structuredActivityComponent.content; + const contentPromise = resolve( + typeof content === "function" ? content() : { default: content }, + ); + const state = inspect(contentPromise); + + if (state.status === PromiseStatus.FULFILLED) { + return resolve({ + default: state.value.default.component, + }); + } else if (state.status === PromiseStatus.REJECTED) { + return reject(state.reason); + } + + return resolve( + contentPromise.then((value) => ({ + default: value.default.component, + })), + ); + }); ContentComponentMap.set(structuredActivityComponent, ContentComponent); diff --git a/integrations/react/src/__internal__/utils/PreloadableLazyComponent.tsx b/integrations/react/src/__internal__/utils/PreloadableLazyComponent.tsx new file mode 100644 index 000000000..d6dc25ebf --- /dev/null +++ b/integrations/react/src/__internal__/utils/PreloadableLazyComponent.tsx @@ -0,0 +1,35 @@ +import type { ComponentType } from "react"; +import { + inspect, + PromiseStatus, + type SyncInspectablePromise, +} from "./SyncInspectablePromise"; +import { useThenable } from "./useThenable"; + +export function preloadableLazyComponent

( + load: () => SyncInspectablePromise<{ default: ComponentType

}>, +): { Component: ComponentType

; preload: () => Promise } { + let cachedLoadingPromise: SyncInspectablePromise<{ + default: ComponentType

; + }> | null = null; + const cachedLoad = () => { + if ( + !cachedLoadingPromise || + inspect(cachedLoadingPromise).status === PromiseStatus.REJECTED + ) { + cachedLoadingPromise = load(); + } + + return cachedLoadingPromise; + }; + const Component = (props: P) => { + const { default: Component } = useThenable(cachedLoad()); + + return ; + }; + + return { + Component, + preload: async () => void (await cachedLoad()), + }; +} diff --git a/integrations/react/src/__internal__/utils/SyncInspectablePromise.ts b/integrations/react/src/__internal__/utils/SyncInspectablePromise.ts new file mode 100644 index 000000000..ab2a367b5 --- /dev/null +++ b/integrations/react/src/__internal__/utils/SyncInspectablePromise.ts @@ -0,0 +1,97 @@ +import { isPromiseLike } from "./isPromiseLike"; + +export interface SyncInspectablePromise extends Promise { + status: PromiseStatus; + value?: T; + reason?: unknown; +} + +export const PromiseStatus = { + PENDING: "pending", + FULFILLED: "fulfilled", + REJECTED: "rejected", +} as const; +export type PromiseStatus = (typeof PromiseStatus)[keyof typeof PromiseStatus]; + +export type PromiseState = + | { + status: typeof PromiseStatus.PENDING; + } + | { + status: typeof PromiseStatus.FULFILLED; + value: T; + } + | { + status: typeof PromiseStatus.REJECTED; + reason: unknown; + }; + +export function inspect( + promise: SyncInspectablePromise, +): PromiseState { + if (promise.status === PromiseStatus.PENDING) { + return { + status: PromiseStatus.PENDING, + }; + } else if (promise.status === PromiseStatus.FULFILLED && "value" in promise) { + return { + status: PromiseStatus.FULFILLED, + value: promise.value as T, + }; + } else if (promise.status === PromiseStatus.REJECTED && "reason" in promise) { + return { + status: PromiseStatus.REJECTED, + reason: promise.reason, + }; + } else { + throw new Error("Invalid promise state"); + } +} + +function makeSyncInspectable( + thenable: PromiseLike, +): SyncInspectablePromise { + const syncInspectablePromise: SyncInspectablePromise = Object.assign( + new Promise((resolve) => resolve(thenable)), + { status: PromiseStatus.PENDING }, + ); + + syncInspectablePromise.then( + (value) => { + syncInspectablePromise.status = PromiseStatus.FULFILLED; + syncInspectablePromise.value = value; + }, + (reason) => { + syncInspectablePromise.status = PromiseStatus.REJECTED; + syncInspectablePromise.reason = reason; + }, + ); + + return syncInspectablePromise; +} + +export function resolve(value: T): SyncInspectablePromise> { + if (isPromiseLike(value)) { + if ( + value instanceof Promise && + "status" in value && + Object.values(PromiseStatus).some((status) => status === value.status) + ) { + return value as SyncInspectablePromise>; + } + + return makeSyncInspectable(value) as SyncInspectablePromise>; + } + + return Object.assign(Promise.resolve(value), { + status: PromiseStatus.FULFILLED, + value, + }) as SyncInspectablePromise>; +} + +export function reject(error: unknown): SyncInspectablePromise { + return Object.assign(Promise.reject(error), { + status: PromiseStatus.REJECTED, + reason: error, + }) as SyncInspectablePromise; +} diff --git a/integrations/react/src/__internal__/utils/useThenable.ts b/integrations/react/src/__internal__/utils/useThenable.ts new file mode 100644 index 000000000..7f4e939f6 --- /dev/null +++ b/integrations/react/src/__internal__/utils/useThenable.ts @@ -0,0 +1,14 @@ +import { inspect, PromiseStatus, resolve } from "./SyncInspectablePromise"; + +export function useThenable(thenable: PromiseLike): Awaited { + const syncInspectable = resolve(thenable); + const state = inspect(syncInspectable); + + if (state.status === PromiseStatus.FULFILLED) { + return state.value; + } else if (state.status === PromiseStatus.REJECTED) { + throw state.reason; + } + + throw syncInspectable; // Trigger suspense by throwing the promise. +} diff --git a/integrations/react/src/future/lazy.tsx b/integrations/react/src/future/lazy.tsx index 36206fa5a..db9449997 100644 --- a/integrations/react/src/future/lazy.tsx +++ b/integrations/react/src/future/lazy.tsx @@ -1,28 +1,37 @@ -import React from "react"; import type { LazyActivityComponentType } from "../__internal__/LazyActivityComponentType"; import type { StaticActivityComponentType } from "../__internal__/StaticActivityComponentType"; +import { preloadableLazyComponent } from "../__internal__/utils/PreloadableLazyComponent"; +import { + inspect, + PromiseStatus, + reject, + resolve, +} from "../__internal__/utils/SyncInspectablePromise"; export function lazy( load: () => Promise<{ default: StaticActivityComponentType }>, ): LazyActivityComponentType { - let cachedValue: Promise<{ default: StaticActivityComponentType }> | null = - null; + const { Component, preload } = preloadableLazyComponent(() => + resolve(load()), + ); - const cachedLoad = () => { - if (!cachedValue) { - cachedValue = load(); - cachedValue.catch((error) => { - cachedValue = null; + const LazyActivityComponent: LazyActivityComponentType = Object.assign( + Component, + { + _load: () => { + const preloadTask = resolve(preload()); + const preloadTaskState = inspect(preloadTask); - throw error; - }); - } - return cachedValue; - }; + if (preloadTaskState.status === PromiseStatus.FULFILLED) { + return resolve({ default: Component }); + } else if (preloadTaskState.status === PromiseStatus.REJECTED) { + return reject(preloadTaskState.reason); + } - const LazyActivityComponent: LazyActivityComponentType = - React.lazy(cachedLoad); - LazyActivityComponent._load = cachedLoad; + return resolve(preloadTask.then(() => ({ default: Component }))); + }, + }, + ); return LazyActivityComponent; } diff --git a/integrations/react/src/future/loader/loaderPlugin.tsx b/integrations/react/src/future/loader/loaderPlugin.tsx index 348c5b89a..bf85771a8 100644 --- a/integrations/react/src/future/loader/loaderPlugin.tsx +++ b/integrations/react/src/future/loader/loaderPlugin.tsx @@ -6,6 +6,11 @@ import type { ActivityComponentType } from "../../__internal__/ActivityComponent import type { StackflowReactPlugin } from "../../__internal__/StackflowReactPlugin"; import { isStructuredActivityComponent } from "../../__internal__/StructuredActivityComponentType"; import { isPromiseLike } from "../../__internal__/utils/isPromiseLike"; +import { + inspect, + PromiseStatus, + resolve, +} from "../../__internal__/utils/SyncInspectablePromise"; import type { StackflowInput } from "../stackflow"; export function loaderPlugin< @@ -35,7 +40,7 @@ export function loaderPlugin< ...event, activityContext: { ...event.activityContext, - loaderData: initialContext.initialLoaderData, + loaderData: resolve(initialContext.initialLoaderData), }, }; } @@ -109,29 +114,28 @@ function createBeforeRouteHandler< } const loaderData = - matchActivity.loader && loadData(activityName, activityParams); - - const loaderDataPromise = isPromiseLike(loaderData) - ? loaderData - : undefined; - const lazyComponentPromise = + matchActivity.loader && resolve(loadData(activityName, activityParams)); + const lazyComponentPromise = resolve( isStructuredActivityComponent(matchActivityComponent) && - typeof matchActivityComponent.content === "function" + typeof matchActivityComponent.content === "function" ? matchActivityComponent.content() - : "_load" in matchActivityComponent - ? matchActivityComponent._load?.() - : undefined; + : "_load" in matchActivityComponent && + typeof matchActivityComponent._load === "function" + ? matchActivityComponent._load() + : undefined, + ); const shouldRenderImmediately = (activityContext as any) ?.lazyActivityComponentRenderContext?.shouldRenderImmediately; if ( - (loaderDataPromise || lazyComponentPromise) && + ((loaderData && inspect(loaderData).status === PromiseStatus.PENDING) || + inspect(lazyComponentPromise).status === PromiseStatus.PENDING) && (shouldRenderImmediately !== true || "loading" in matchActivityComponent === false) ) { pause(); - Promise.allSettled([loaderDataPromise, lazyComponentPromise]) + Promise.allSettled([loaderData, lazyComponentPromise]) .then(([loaderDataPromiseResult, lazyComponentPromiseResult]) => { printLoaderDataPromiseError({ promiseResult: loaderDataPromiseResult, diff --git a/integrations/react/src/future/loader/use.ts b/integrations/react/src/future/loader/use.ts deleted file mode 100644 index 808a27d7b..000000000 --- a/integrations/react/src/future/loader/use.ts +++ /dev/null @@ -1,31 +0,0 @@ -// @ts-nocheck - -export function use(promise: Promise | T): T { - if (!(promise instanceof Promise)) { - return promise; - } - - if (promise.status === "fulfilled") { - return promise.value; - // biome-ignore lint/style/noUselessElse: - } else if (promise.status === "rejected") { - throw promise.reason; - // biome-ignore lint/style/noUselessElse: - } else if (promise.status === "pending") { - throw promise; - // biome-ignore lint/style/noUselessElse: - } else { - promise.status = "pending"; - promise.then( - (result: any) => { - promise.status = "fulfilled"; - promise.value = result; - }, - (reason: any) => { - promise.status = "rejected"; - promise.reason = reason; - }, - ); - throw promise; - } -} diff --git a/integrations/react/src/future/loader/useLoaderData.ts b/integrations/react/src/future/loader/useLoaderData.ts index 17c987976..bbe85e64b 100644 --- a/integrations/react/src/future/loader/useLoaderData.ts +++ b/integrations/react/src/future/loader/useLoaderData.ts @@ -1,9 +1,10 @@ import type { ActivityLoaderArgs } from "@stackflow/config"; +import { resolve } from "../../__internal__/utils/SyncInspectablePromise"; +import { useThenable } from "../../__internal__/utils/useThenable"; import { useActivity } from "../../stable"; -import { use } from "./use"; export function useLoaderData< T extends (args: ActivityLoaderArgs) => any, >(): Awaited> { - return use((useActivity().context as any)?.loaderData); + return useThenable(resolve((useActivity().context as any)?.loaderData)); } diff --git a/integrations/react/src/future/stackflow.tsx b/integrations/react/src/future/stackflow.tsx index be92c9c4a..02bf8a5d1 100644 --- a/integrations/react/src/future/stackflow.tsx +++ b/integrations/react/src/future/stackflow.tsx @@ -12,7 +12,6 @@ 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";