-
Notifications
You must be signed in to change notification settings - Fork 112
feat(react): support lazy activity with internal plugin #617
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f2592b0
bab05f7
341ed38
58d2ddc
7770a81
1c22f60
60127cc
6b8fa40
a488fe9
eebe0c6
8823102
fe404be
c0b23e9
7726c76
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@stackflow/react": minor | ||
| --- | ||
|
|
||
| feat(react): support lazy activity with internal plugin |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T> { | ||
| then( | ||
| onFulfill: (value: T) => unknown, | ||
| onReject: (error: unknown) => unknown, | ||
| ): undefined | Wakeable; | ||
| } | ||
| interface UntrackedThenable<T> extends ThenableImpl<T> { | ||
| status?: undefined; | ||
| } | ||
|
|
||
| interface PendingThenable<T> extends ThenableImpl<T> { | ||
| status: "pending"; | ||
| } | ||
|
|
||
| interface FulfilledThenable<T> extends ThenableImpl<T> { | ||
| status: "fulfilled"; | ||
| value: T; | ||
| } | ||
|
|
||
| interface RejectedThenable<T> extends ThenableImpl<T> { | ||
| status: "rejected"; | ||
| reason: unknown; | ||
| } | ||
|
|
||
| type Thenable<T> = | ||
| | UntrackedThenable<T> | ||
| | PendingThenable<T> | ||
| | FulfilledThenable<T> | ||
| | RejectedThenable<T>; | ||
|
|
||
| const Uninitialized = -1; | ||
| const Pending = 0; | ||
| const Resolved = 1; | ||
| const Rejected = 2; | ||
|
|
||
| type UninitializedPayload<T> = { | ||
| _status: -1; | ||
| _result: () => Thenable<{ default: T }>; | ||
| }; | ||
|
|
||
| type PendingPayload = { | ||
| _status: 0; | ||
| _result: Wakeable; | ||
| }; | ||
|
|
||
| type ResolvedPayload<T> = { | ||
| _status: 1; | ||
| _result: { default: T }; | ||
| }; | ||
|
|
||
| type RejectedPayload = { | ||
| _status: 2; | ||
| _result: unknown; | ||
| }; | ||
|
|
||
| type Payload<T> = | ||
| | UninitializedPayload<T> | ||
| | PendingPayload | ||
| | ResolvedPayload<T> | ||
| | RejectedPayload; | ||
|
|
||
| type LazyComponent = { | ||
| $$typeof: symbol | number; | ||
| _payload: Payload<unknown>; | ||
| }; | ||
|
|
||
| // 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 }); | ||
| }, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<T extends BaseActivities> = { | |||||||||||||||||||||||||||||||||||||||
| export function stackflow<T extends BaseActivities>( | ||||||||||||||||||||||||||||||||||||||||
| options: StackflowOptions<T>, | ||||||||||||||||||||||||||||||||||||||||
| ): StackflowOutput<T> { | ||||||||||||||||||||||||||||||||||||||||
| 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<T extends BaseActivities>( | |||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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)); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+153
to
+162
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tighten React version gate to 18.x and prepend plugin; add radix to parseInt The plugin relies on React internals; enabling it for 19.x is risky. Also, to ensure it pauses navigation before other plugins, it should run first. Pass radix 10 to parseInt for robustness. Apply this diff: - const majorReactVersion = Number.parseInt(version);
+ const majorReactVersion = Number.parseInt(version, 10);
@@
- if (majorReactVersion >= 18 && majorReactVersion <= 19) {
- plugins.push(lazyActivityPlugin(activityComponentMap));
+ if (majorReactVersion >= 18 && majorReactVersion < 19) {
+ // Prepend so it can pause navigation early
+ plugins.unshift(lazyActivityPlugin(activityComponentMap));
}If you prefer extra safety, also log a dev-only warning when outside the supported range to aid debugging. I can provide a snippet. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| const enoughPastTime = () => | ||||||||||||||||||||||||||||||||||||||||
| new Date().getTime() - options.transitionDuration * 2; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider the risks of using React internals
Using React's internal symbols (
react.lazyandreact.memo) makes this implementation fragile. These symbols are not part of React's public API and could change in future versions without notice, potentially breaking this plugin.Consider adding:
🤖 Prompt for AI Agents