Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/small-rice-listen.md
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
147 changes: 147 additions & 0 deletions integrations/react/src/stable/lazyActivityPlugin.ts
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");
Comment on lines +4 to +6
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Consider the risks of using React internals

Using React's internal symbols (react.lazy and react.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:

  1. Version checks or feature detection
  2. Fallback behavior if symbols are undefined
  3. Documentation about supported React versions
🤖 Prompt for AI Agents
In integrations/react/src/stable/lazyActivityPlugin.ts around lines 4 to 6, the
code uses React internal symbols react.lazy and react.memo which are not part of
the public API and may change unexpectedly. To fix this, add runtime checks to
verify these symbols exist before using them, implement fallback behavior if
they are undefined, and document the React versions supported by this plugin to
inform users of compatibility constraints.


// 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 });
},
});
}
23 changes: 19 additions & 4 deletions integrations/react/src/stable/stackflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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,
Expand All @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 majorReactVersion = Number.parseInt(version, 10);
/**
* 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) {
// Prepend so it can pause navigation early
plugins.unshift(lazyActivityPlugin(activityComponentMap));
}
🤖 Prompt for AI Agents
integrations/react/src/stable/stackflow.tsx around lines 152 to 161: tighten the
React version gate to only allow 18.x (e.g., check majorReactVersion === 18 or
majorReactVersion >= 18 && majorReactVersion < 19), pass radix 10 into
Number.parseInt(version, 10), and prepend the plugin to the plugins array
instead of pushing it (use unshift) so it runs before other plugins; optionally
add a dev-only warning (guarded by NODE_ENV !== 'production') when the React
version is outside the supported range to aid debugging.

const enoughPastTime = () =>
new Date().getTime() - options.transitionDuration * 2;

Expand Down