From f2592b0b6586f9268ea360e82fdeee30583e5c19 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Fri, 1 Aug 2025 19:50:47 +0900 Subject: [PATCH 01/11] feat(react): add lazy activity plugin for stable version --- .../react/src/stable/lazyActivityPlugin.ts | 111 ++++++++++++++++++ integrations/react/src/stable/stackflow.tsx | 12 +- 2 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 integrations/react/src/stable/lazyActivityPlugin.ts diff --git a/integrations/react/src/stable/lazyActivityPlugin.ts b/integrations/react/src/stable/lazyActivityPlugin.ts new file mode 100644 index 000000000..850400299 --- /dev/null +++ b/integrations/react/src/stable/lazyActivityPlugin.ts @@ -0,0 +1,111 @@ +import type { ActivityComponentType } from "../__internal__/ActivityComponentType"; +import type { StackflowReactPlugin } from "../__internal__/StackflowReactPlugin"; + +// https://github.com/facebook/react/blob/v19.1.1/packages/react/src/ReactLazy.js + +const REACT_LAZY_TYPE: symbol = Symbol.for("react.lazy"); + +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; +}; + +function isLazyComponent(component: unknown): component is LazyComponent { + return ( + typeof component === "object" && + component !== null && + "$$typeof" in component && + component.$$typeof === REACT_LAZY_TYPE && + "_payload" in component + ); +} + +export function lazyActivityPlugin(activityComponentMap: { + [key: string]: ActivityComponentType; +}): StackflowReactPlugin { + return () => ({ + key: "plugin-lazy-activity", + onBeforePush({ actions, actionParams }) { + const Activity = activityComponentMap[actionParams.activityName]; + + if (isLazyComponent(Activity) && Activity._payload._status === -1) { + actions.pause(); + + (Activity._payload as any)._result().then( + () => { + actions.resume(); + }, + () => { + actions.resume(); + }, + ); + } + }, + onBeforeReplace() {}, + }); +} diff --git a/integrations/react/src/stable/stackflow.tsx b/integrations/react/src/stable/stackflow.tsx index 84ba6caaf..f4f3f16ba 100644 --- a/integrations/react/src/stable/stackflow.tsx +++ b/integrations/react/src/stable/stackflow.tsx @@ -20,6 +20,7 @@ import { CoreProvider } from "../__internal__/core"; import { PluginsProvider } from "../__internal__/plugins"; 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"; @@ -129,10 +130,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, @@ -144,6 +141,13 @@ export function stackflow( }, ); + const plugins: StackflowReactPlugin[] = [ + ...(options.plugins ?? []) + .flat(Number.POSITIVE_INFINITY as 0) + .map((p) => p as StackflowReactPlugin), + lazyActivityPlugin(activityComponentMap), + ]; + const enoughPastTime = () => new Date().getTime() - options.transitionDuration * 2; From bab05f77821fb681e158bff49b7f04d9f2198de1 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Fri, 1 Aug 2025 19:52:46 +0900 Subject: [PATCH 02/11] refactor: use enum --- integrations/react/src/stable/lazyActivityPlugin.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/integrations/react/src/stable/lazyActivityPlugin.ts b/integrations/react/src/stable/lazyActivityPlugin.ts index 850400299..d5701b969 100644 --- a/integrations/react/src/stable/lazyActivityPlugin.ts +++ b/integrations/react/src/stable/lazyActivityPlugin.ts @@ -93,7 +93,10 @@ export function lazyActivityPlugin(activityComponentMap: { onBeforePush({ actions, actionParams }) { const Activity = activityComponentMap[actionParams.activityName]; - if (isLazyComponent(Activity) && Activity._payload._status === -1) { + if ( + isLazyComponent(Activity) && + Activity._payload._status === Uninitialized + ) { actions.pause(); (Activity._payload as any)._result().then( From 341ed38614d03637c018d7d421eee9809a3a4106 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Fri, 1 Aug 2025 19:53:19 +0900 Subject: [PATCH 03/11] refactor: remove any type --- integrations/react/src/stable/lazyActivityPlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/react/src/stable/lazyActivityPlugin.ts b/integrations/react/src/stable/lazyActivityPlugin.ts index d5701b969..83768e59c 100644 --- a/integrations/react/src/stable/lazyActivityPlugin.ts +++ b/integrations/react/src/stable/lazyActivityPlugin.ts @@ -99,7 +99,7 @@ export function lazyActivityPlugin(activityComponentMap: { ) { actions.pause(); - (Activity._payload as any)._result().then( + Activity._payload._result().then( () => { actions.resume(); }, From 58d2ddcd3532c820ecc20b99420a982ebbdfdd44 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Mon, 4 Aug 2025 09:55:33 +0900 Subject: [PATCH 04/11] fix: work same for replace too --- .../react/src/stable/lazyActivityPlugin.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/integrations/react/src/stable/lazyActivityPlugin.ts b/integrations/react/src/stable/lazyActivityPlugin.ts index 83768e59c..666eef455 100644 --- a/integrations/react/src/stable/lazyActivityPlugin.ts +++ b/integrations/react/src/stable/lazyActivityPlugin.ts @@ -109,6 +109,24 @@ export function lazyActivityPlugin(activityComponentMap: { ); } }, - onBeforeReplace() {}, + onBeforeReplace({ actions, actionParams }) { + const Activity = activityComponentMap[actionParams.activityName]; + + if ( + isLazyComponent(Activity) && + Activity._payload._status === Uninitialized + ) { + actions.pause(); + + Activity._payload._result().then( + () => { + actions.resume(); + }, + () => { + actions.resume(); + }, + ); + } + }, }); } From 7770a81d736e549b11224e1fba615430b27d49b1 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Mon, 4 Aug 2025 12:20:50 +0900 Subject: [PATCH 05/11] fix: handle memo component --- .../react/src/stable/lazyActivityPlugin.ts | 89 +++++++++++-------- 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/integrations/react/src/stable/lazyActivityPlugin.ts b/integrations/react/src/stable/lazyActivityPlugin.ts index 666eef455..d6c395400 100644 --- a/integrations/react/src/stable/lazyActivityPlugin.ts +++ b/integrations/react/src/stable/lazyActivityPlugin.ts @@ -1,10 +1,12 @@ import type { ActivityComponentType } from "../__internal__/ActivityComponentType"; import type { StackflowReactPlugin } from "../__internal__/StackflowReactPlugin"; -// https://github.com/facebook/react/blob/v19.1.1/packages/react/src/ReactLazy.js +// 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; } @@ -75,58 +77,69 @@ type LazyComponent = { _payload: Payload; }; +type MemoComponent = { + $$typeof: symbol | number; + type: React.ElementType; +}; + function isLazyComponent(component: unknown): component is LazyComponent { - return ( + const isLazy = ( typeof component === "object" && component !== null && "$$typeof" in component && - component.$$typeof === REACT_LAZY_TYPE && - "_payload" 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 ); + 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]; + + if (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 }) { - const Activity = activityComponentMap[actionParams.activityName]; - - if ( - isLazyComponent(Activity) && - Activity._payload._status === Uninitialized - ) { - actions.pause(); - - Activity._payload._result().then( - () => { - actions.resume(); - }, - () => { - actions.resume(); - }, - ); - } + handleLazyActivity({ actions, actionParams }); }, onBeforeReplace({ actions, actionParams }) { - const Activity = activityComponentMap[actionParams.activityName]; - - if ( - isLazyComponent(Activity) && - Activity._payload._status === Uninitialized - ) { - actions.pause(); - - Activity._payload._result().then( - () => { - actions.resume(); - }, - () => { - actions.resume(); - }, - ); - } + handleLazyActivity({ actions, actionParams }); }, }); } From 1c22f60d6aa72d6aebfbbb4e23f45b63e8a5e30a Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Mon, 4 Aug 2025 12:23:18 +0900 Subject: [PATCH 06/11] chore: add source comment --- integrations/react/src/stable/lazyActivityPlugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/integrations/react/src/stable/lazyActivityPlugin.ts b/integrations/react/src/stable/lazyActivityPlugin.ts index d6c395400..7fd5e7e9c 100644 --- a/integrations/react/src/stable/lazyActivityPlugin.ts +++ b/integrations/react/src/stable/lazyActivityPlugin.ts @@ -77,6 +77,7 @@ type LazyComponent = { _payload: Payload; }; +// https://github.com/facebook/react/blob/v19.1.1/packages/react/src/ReactMemo.js type MemoComponent = { $$typeof: symbol | number; type: React.ElementType; From 60127cc297adc4d1d4f75358141e8687092907a6 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Mon, 4 Aug 2025 12:23:26 +0900 Subject: [PATCH 07/11] chore: add changeset --- .changeset/small-rice-listen.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/small-rice-listen.md 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 From 6b8fa40b42c5befae2be1b16a2a8bcb719f4ce77 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Mon, 4 Aug 2025 12:28:06 +0900 Subject: [PATCH 08/11] chore: run format --- .../react/src/stable/lazyActivityPlugin.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/integrations/react/src/stable/lazyActivityPlugin.ts b/integrations/react/src/stable/lazyActivityPlugin.ts index 7fd5e7e9c..057c2818e 100644 --- a/integrations/react/src/stable/lazyActivityPlugin.ts +++ b/integrations/react/src/stable/lazyActivityPlugin.ts @@ -1,10 +1,9 @@ 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'); +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 { @@ -84,30 +83,31 @@ type MemoComponent = { }; function isLazyComponent(component: unknown): component is LazyComponent { - const isLazy = ( + const isLazy = typeof component === "object" && component !== null && "$$typeof" in component && - component.$$typeof === REACT_LAZY_TYPE && - "_payload" in component - ); + component.$$typeof === REACT_LAZY_TYPE && + "_payload" in component; return isLazy; } function isMemoComponent(component: unknown): component is MemoComponent { - const isMemo = ( + const isMemo = typeof component === "object" && component !== null && "$$typeof" in component && - component.$$typeof === REACT_MEMO_TYPE - ); + component.$$typeof === REACT_MEMO_TYPE; return isMemo; } export function lazyActivityPlugin(activityComponentMap: { [key: string]: ActivityComponentType; }): StackflowReactPlugin { - function handleLazyActivity({ actions, actionParams }: { + function handleLazyActivity({ + actions, + actionParams, + }: { actions: { pause: () => void; resume: () => void }; actionParams: { activityName: string }; }) { From a488fe99eed1cae7617e0683017b18ffd742f544 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Mon, 4 Aug 2025 12:37:41 +0900 Subject: [PATCH 09/11] fix: accept suggestion --- integrations/react/src/stable/lazyActivityPlugin.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/integrations/react/src/stable/lazyActivityPlugin.ts b/integrations/react/src/stable/lazyActivityPlugin.ts index 057c2818e..1008b0fac 100644 --- a/integrations/react/src/stable/lazyActivityPlugin.ts +++ b/integrations/react/src/stable/lazyActivityPlugin.ts @@ -97,7 +97,8 @@ function isMemoComponent(component: unknown): component is MemoComponent { typeof component === "object" && component !== null && "$$typeof" in component && - component.$$typeof === REACT_MEMO_TYPE; + component.$$typeof === REACT_MEMO_TYPE && + "type" in component; return isMemo; } @@ -113,7 +114,7 @@ export function lazyActivityPlugin(activityComponentMap: { }) { let Activity = activityComponentMap[actionParams.activityName]; - if (isMemoComponent(Activity)) { + while (isMemoComponent(Activity)) { Activity = Activity.type as ActivityComponentType; } From eebe0c64d8a21311b2daaa8742b9d839322a3ecb Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Mon, 11 Aug 2025 10:53:57 +0900 Subject: [PATCH 10/11] feat: add version check --- integrations/react/src/stable/stackflow.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/integrations/react/src/stable/stackflow.tsx b/integrations/react/src/stable/stackflow.tsx index f4f3f16ba..3b46761f0 100644 --- a/integrations/react/src/stable/stackflow.tsx +++ b/integrations/react/src/stable/stackflow.tsx @@ -26,6 +26,8 @@ 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 }; @@ -145,9 +147,18 @@ export function stackflow( ...(options.plugins ?? []) .flat(Number.POSITIVE_INFINITY as 0) .map((p) => p as StackflowReactPlugin), - lazyActivityPlugin(activityComponentMap), ]; + 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; From fe404be26e2fa962b34c3f4b9699fe72023c0185 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Mon, 18 Aug 2025 10:21:54 +0900 Subject: [PATCH 11/11] trigger ci