diff --git a/.changeset/small-rice-listen.md b/.changeset/small-rice-listen.md new file mode 100644 index 000000000..251ebee86 --- /dev/null +++ b/.changeset/small-rice-listen.md @@ -0,0 +1,5 @@ +--- +"@stackflow/react": minor +--- + +feat(react): support lazy activity with internal plugin diff --git a/integrations/react/src/stable/lazyActivityPlugin.ts b/integrations/react/src/stable/lazyActivityPlugin.ts new file mode 100644 index 000000000..1008b0fac --- /dev/null +++ b/integrations/react/src/stable/lazyActivityPlugin.ts @@ -0,0 +1,147 @@ +import type { ActivityComponentType } from "../__internal__/ActivityComponentType"; +import type { StackflowReactPlugin } from "../__internal__/StackflowReactPlugin"; + +// https://github.com/facebook/react/blob/v19.1.1/packages/shared/ReactSymbols.js#L32 +const REACT_LAZY_TYPE: symbol = Symbol.for("react.lazy"); +const REACT_MEMO_TYPE: symbol = Symbol.for("react.memo"); + +// https://github.com/facebook/react/blob/v19.1.1/packages/react/src/ReactLazy.js +interface Wakeable { + then(onFulfill: () => unknown, onReject: () => unknown): undefined | Wakeable; +} + +interface ThenableImpl { + then( + onFulfill: (value: T) => unknown, + onReject: (error: unknown) => unknown, + ): undefined | Wakeable; +} +interface UntrackedThenable extends ThenableImpl { + status?: undefined; +} + +interface PendingThenable extends ThenableImpl { + status: "pending"; +} + +interface FulfilledThenable extends ThenableImpl { + status: "fulfilled"; + value: T; +} + +interface RejectedThenable extends ThenableImpl { + status: "rejected"; + reason: unknown; +} + +type Thenable = + | UntrackedThenable + | PendingThenable + | FulfilledThenable + | RejectedThenable; + +const Uninitialized = -1; +const Pending = 0; +const Resolved = 1; +const Rejected = 2; + +type UninitializedPayload = { + _status: -1; + _result: () => Thenable<{ default: T }>; +}; + +type PendingPayload = { + _status: 0; + _result: Wakeable; +}; + +type ResolvedPayload = { + _status: 1; + _result: { default: T }; +}; + +type RejectedPayload = { + _status: 2; + _result: unknown; +}; + +type Payload = + | UninitializedPayload + | PendingPayload + | ResolvedPayload + | RejectedPayload; + +type LazyComponent = { + $$typeof: symbol | number; + _payload: Payload; +}; + +// https://github.com/facebook/react/blob/v19.1.1/packages/react/src/ReactMemo.js +type MemoComponent = { + $$typeof: symbol | number; + type: React.ElementType; +}; + +function isLazyComponent(component: unknown): component is LazyComponent { + const isLazy = + typeof component === "object" && + component !== null && + "$$typeof" in component && + component.$$typeof === REACT_LAZY_TYPE && + "_payload" in component; + return isLazy; +} + +function isMemoComponent(component: unknown): component is MemoComponent { + const isMemo = + typeof component === "object" && + component !== null && + "$$typeof" in component && + component.$$typeof === REACT_MEMO_TYPE && + "type" in component; + return isMemo; +} + +export function lazyActivityPlugin(activityComponentMap: { + [key: string]: ActivityComponentType; +}): StackflowReactPlugin { + function handleLazyActivity({ + actions, + actionParams, + }: { + actions: { pause: () => void; resume: () => void }; + actionParams: { activityName: string }; + }) { + let Activity = activityComponentMap[actionParams.activityName]; + + while (isMemoComponent(Activity)) { + Activity = Activity.type as ActivityComponentType; + } + + if ( + isLazyComponent(Activity) && + Activity._payload._status === Uninitialized + ) { + actions.pause(); + + Activity._payload._result().then( + () => { + actions.resume(); + }, + () => { + actions.resume(); + }, + ); + } + } + + return () => ({ + key: "plugin-lazy-activity", + onBeforePush({ actions, actionParams }) { + handleLazyActivity({ actions, actionParams }); + }, + onBeforeReplace({ actions, actionParams }) { + handleLazyActivity({ actions, actionParams }); + }, + }); +} diff --git a/integrations/react/src/stable/stackflow.tsx b/integrations/react/src/stable/stackflow.tsx index 90697e0fb..6563b1e0a 100644 --- a/integrations/react/src/stable/stackflow.tsx +++ b/integrations/react/src/stable/stackflow.tsx @@ -21,11 +21,14 @@ import { PluginsProvider } from "../__internal__/plugins"; import type { StackflowReactPlugin } from "../__internal__/StackflowReactPlugin"; import { isBrowser, makeRef } from "../__internal__/utils"; import type { BaseActivities } from "./BaseActivities"; +import { lazyActivityPlugin } from "./lazyActivityPlugin"; import type { UseActionsOutputType } from "./useActions"; import { useActions } from "./useActions"; import type { UseStepActionsOutputType } from "./useStepActions"; import { useStepActions } from "./useStepActions"; +import { version } from "react"; + function parseActionOptions(options?: { animate?: boolean }) { if (!options) { return { skipActiveState: false }; @@ -130,10 +133,6 @@ export type StackflowOutput = { export function stackflow( options: StackflowOptions, ): StackflowOutput { - const plugins = (options.plugins ?? []) - .flat(Number.POSITIVE_INFINITY as 0) - .map((p) => p as StackflowReactPlugin); - const activityComponentMap = Object.entries(options.activities).reduce( (acc, [key, Activity]) => ({ ...acc, @@ -145,6 +144,22 @@ export function stackflow( }, ); + const plugins: StackflowReactPlugin[] = [ + ...(options.plugins ?? []) + .flat(Number.POSITIVE_INFINITY as 0) + .map((p) => p as StackflowReactPlugin), + ]; + + const majorReactVersion = Number.parseInt(version); + + /** + * TODO: This plugin depends on internal APIs of React. + * A proper solution (e.g. Suspense integration) should be implemented in the next major version. + */ + if (majorReactVersion >= 18 && majorReactVersion <= 19) { + plugins.push(lazyActivityPlugin(activityComponentMap)); + } + const enoughPastTime = () => new Date().getTime() - options.transitionDuration * 2;