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
146 changes: 146 additions & 0 deletions integrations/react/src/stable/lazyActivityPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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
Copy Markdown

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;
return isMemo;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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;
}
Comment thread
orionmiz marked this conversation as resolved.
Outdated

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 });
},
});
}
12 changes: 8 additions & 4 deletions integrations/react/src/stable/stackflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -129,10 +130,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 @@ -144,6 +141,13 @@ export function stackflow<T extends BaseActivities>(
},
);

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;

Expand Down