Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bd1776b
feat(react): add lifecyclePlugin and useFocusEffect hook
orionmiz Mar 30, 2026
808f841
chore: add changeset for lifecyclePlugin
orionmiz Mar 30, 2026
9ddcbae
Merge branch 'main' into edward_karrot/plugin-refocus
orionmiz Mar 30, 2026
3127294
Merge branch 'main' into edward_karrot/plugin-refocus
orionmiz Mar 31, 2026
a5da496
Merge branch 'main' into edward_karrot/plugin-refocus
orionmiz Apr 1, 2026
a416012
chore(react): deprecate useActiveEffect in favor of useFocusEffect
orionmiz Apr 1, 2026
c20f614
refactor: address review feedback from ENvironmentSet
orionmiz Apr 8, 2026
0ff0341
fix(react): pass --passWithNoTests to jest after lifecycle tests move…
orionmiz Apr 8, 2026
d35d30c
chore(react): remove unused test config and devDependencies
orionmiz Apr 8, 2026
e936a8a
feat(plugin-lifecycle): re-run effect on callback change (React Navig…
orionmiz Apr 8, 2026
bed456f
chore(plugin-lifecycle): add comment explaining render-phase ref write
orionmiz Apr 8, 2026
67f4137
fix(plugin-lifecycle): add reentrancy guard to onChanged
orionmiz Apr 8, 2026
138e32b
test(plugin-lifecycle): add reentrancy test
orionmiz Apr 8, 2026
4f32f6b
test(plugin-lifecycle): fix reentrancy test to use onChanged path
orionmiz Apr 8, 2026
05c219e
test(plugin-lifecycle): use callLog for reentrancy order verification
orionmiz Apr 8, 2026
a81e3eb
fix(plugin-lifecycle): move callbackRef update to useEffect
orionmiz Apr 8, 2026
88b14e1
fix(plugin-lifecycle): use array for pending transitions
orionmiz Apr 8, 2026
95c1da4
Merge branch 'main' into edward_karrot/plugin-refocus
orionmiz Apr 8, 2026
2cd9de5
chore(react): revert esbuild.config.js and tsconfig.json to main
orionmiz Apr 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 122 additions & 19 deletions .pnp.cjs

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions integrations/react/esbuild.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { readdirSync, statSync } = require("fs");
const { join } = require("path");
const { context } = require("esbuild");
const config = require("@stackflow/esbuild-config");
const {
Expand All @@ -12,10 +14,14 @@ const external = Object.keys({
...pkg.peerDependencies,
});

const entryPoints = readdirSync("./src", { recursive: true })
.map((f) => join("./src", f))
.filter((f) => !f.includes(".spec.") && statSync(f).isFile());

Promise.all([
context({
...config({
entryPoints: ["./src/**/*"],
entryPoints,
outdir: "dist",
}),
bundle: false,
Expand All @@ -27,7 +33,7 @@ Promise.all([
),
context({
...config({
entryPoints: ["./src/**/*"],
entryPoints,
outdir: "dist",
}),
bundle: true,
Expand Down
33 changes: 33 additions & 0 deletions integrations/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,52 @@
"build:js": "node ./esbuild.config.js",
"clean": "rimraf dist",
"dev": "yarn build:js --watch && yarn build:dts --watch",
"test": "yarn jest",
"typecheck": "tsc --noEmit"
},
"jest": {
"testEnvironment": "jsdom",
"roots": [
"<rootDir>/src"
],
"coveragePathIgnorePatterns": [
"index.ts"
],
"transform": {
"^.+\\.(t|j)sx?$": [
"@swc/jest",
{
"jsc": {
"transform": {
"react": {
"runtime": "automatic"
}
}
}
}
]
}
},
"dependencies": {
"react-fast-compare": "^3.2.2"
},
"devDependencies": {
"@stackflow/config": "^1.2.2",
"@stackflow/core": "^1.3.0",
"@stackflow/esbuild-config": "^1.0.3",
"@stackflow/plugin-renderer-basic": "^1.1.13",
"@swc/core": "^1.6.6",
"@swc/jest": "^0.2.36",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/jest": "^29.5.12",
"@types/react": "^18.3.3",
"esbuild": "^0.23.0",
"esbuild-plugin-file-path-extensions": "^2.1.2",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"rimraf": "^3.0.2",
"typescript": "^5.5.3"
},
Expand Down
1 change: 1 addition & 0 deletions integrations/react/src/future/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export * from "./useConfig";
export * from "./useFlow";
export * from "./usePrepare";
export * from "./useStepFlow";
export { useFocusEffect } from "./lifecycle";
2 changes: 2 additions & 0 deletions integrations/react/src/future/lifecycle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { lifecyclePlugin } from "./lifecyclePlugin";
export { useFocusEffect } from "./useFocusEffect";
289 changes: 289 additions & 0 deletions integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
import { defineConfig } from "@stackflow/config";
import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic";
import { act, render } from "@testing-library/react";
import React, { useState } from "react";
import type { StackflowReactPlugin } from "../../__internal__/StackflowReactPlugin";
import { stackflow } from "../stackflow";
import { useFocusEffect } from "./useFocusEffect";

declare module "@stackflow/config" {
interface Register {
ActivityA: {};
ActivityB: {};
}
}

function setupStack({
ActivityA,
ActivityB,
extraPlugins = [],
}: {
ActivityA: React.FC;
ActivityB: React.FC;
extraPlugins?: StackflowReactPlugin[];
}) {
const config = defineConfig({
activities: [{ name: "ActivityA" }, { name: "ActivityB" }],
transitionDuration: 0,
initialActivity: () => "ActivityA",
});

return stackflow({
config,
components: { ActivityA, ActivityB },
plugins: [basicRendererPlugin(), ...extraPlugins],
});
}

describe("lifecyclePlugin", () => {
describe("initial focus", () => {
it("calls the effect on initial mount when activity is active", async () => {
const effect = jest.fn();

function ActivityA() {
useFocusEffect(effect);
return <div>A</div>;
}
function ActivityB() {
return <div>B</div>;
}

const { Stack } = setupStack({ ActivityA, ActivityB });

await act(async () => {
render(<Stack />);
});

expect(effect).toHaveBeenCalledTimes(1);
});
});

describe("blur cleanup", () => {
it("runs cleanup when another activity is pushed", async () => {
const cleanup = jest.fn();
const effect = jest.fn(() => cleanup);

function ActivityA() {
useFocusEffect(effect);
return <div>A</div>;
}
function ActivityB() {
return <div>B</div>;
}

const { Stack, actions } = setupStack({ ActivityA, ActivityB });

await act(async () => {
render(<Stack />);
});

expect(effect).toHaveBeenCalledTimes(1);
expect(cleanup).not.toHaveBeenCalled();

await act(async () => {
actions.push("ActivityB", {});
});

expect(cleanup).toHaveBeenCalledTimes(1);
});
});

describe("refocus", () => {
it("re-runs the effect when activity returns to active after pop", async () => {
const cleanup = jest.fn();
const effect = jest.fn(() => cleanup);

function ActivityA() {
useFocusEffect(effect);
return <div>A</div>;
}
function ActivityB() {
return <div>B</div>;
}

const { Stack, actions } = setupStack({ ActivityA, ActivityB });

await act(async () => {
render(<Stack />);
});

expect(effect).toHaveBeenCalledTimes(1);

// Push B on top of A → A blurs
await act(async () => {
actions.push("ActivityB", {});
});

expect(cleanup).toHaveBeenCalledTimes(1);

// Pop B → A refocuses
await act(async () => {
actions.pop();
});

expect(effect).toHaveBeenCalledTimes(2);
});
});

describe("multiple hooks in one activity", () => {
it("calls all registered effects on focus", async () => {
const effect1 = jest.fn();
const effect2 = jest.fn();

function ActivityA() {
useFocusEffect(effect1);
useFocusEffect(effect2);
return <div>A</div>;
}
function ActivityB() {
return <div>B</div>;
}

const { Stack } = setupStack({ ActivityA, ActivityB });

await act(async () => {
render(<Stack />);
});

expect(effect1).toHaveBeenCalledTimes(1);
expect(effect2).toHaveBeenCalledTimes(1);
});

it("runs all cleanups on blur", async () => {
const cleanup1 = jest.fn();
const cleanup2 = jest.fn();

function ActivityA() {
useFocusEffect(() => cleanup1);
useFocusEffect(() => cleanup2);
return <div>A</div>;
}
function ActivityB() {
return <div>B</div>;
}

const { Stack, actions } = setupStack({ ActivityA, ActivityB });

await act(async () => {
render(<Stack />);
});

await act(async () => {
actions.push("ActivityB", {});
});

expect(cleanup1).toHaveBeenCalledTimes(1);
expect(cleanup2).toHaveBeenCalledTimes(1);
});
});

describe("unmount cleanup", () => {
it("runs cleanup when component unmounts", async () => {
const cleanup = jest.fn();

function ActivityA() {
useFocusEffect(() => cleanup);
return <div>A</div>;
}
function ActivityB() {
return <div>B</div>;
}

const { Stack } = setupStack({ ActivityA, ActivityB });

const { unmount } = await act(async () => {
return render(<Stack />);
});

expect(cleanup).not.toHaveBeenCalled();

await act(async () => {
unmount();
});

expect(cleanup).toHaveBeenCalledTimes(1);
});
});

describe("callbackRef pattern", () => {
it("uses the latest callback on refocus", async () => {
const firstEffect = jest.fn();
const secondEffect = jest.fn();
let setUseSecond!: (v: boolean) => void;

function ActivityA() {
const [useSecond, _setUseSecond] = useState(false);
setUseSecond = _setUseSecond;

useFocusEffect(useSecond ? secondEffect : firstEffect);
return <div>A</div>;
}
function ActivityB() {
return <div>B</div>;
}

const { Stack, actions } = setupStack({ ActivityA, ActivityB });

await act(async () => {
render(<Stack />);
});

expect(firstEffect).toHaveBeenCalledTimes(1);
expect(secondEffect).not.toHaveBeenCalled();

// Update callback while A is active
await act(async () => {
setUseSecond(true);
});

// Push B → A blurs
await act(async () => {
actions.push("ActivityB", {});
});

// Pop B → A refocuses → should use secondEffect
await act(async () => {
actions.pop();
});

expect(secondEffect).toHaveBeenCalledTimes(1);
});
});

describe("effect on ActivityB", () => {
it("runs effect on pushed activity and cleans up on pop", async () => {
const cleanupB = jest.fn();
const effectB = jest.fn(() => cleanupB);

function ActivityA() {
return <div>A</div>;
}
function ActivityB() {
useFocusEffect(effectB);
return <div>B</div>;
}

const { Stack, actions } = setupStack({ ActivityA, ActivityB });

await act(async () => {
render(<Stack />);
});

expect(effectB).not.toHaveBeenCalled();

// Push B
await act(async () => {
actions.push("ActivityB", {});
});

expect(effectB).toHaveBeenCalledTimes(1);

// Pop B
await act(async () => {
actions.pop();
});

expect(cleanupB).toHaveBeenCalledTimes(1);
});
});
});
Loading
Loading