Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .changeset/add-lifecycle-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@stackflow/react": minor
---

Add lifecyclePlugin and useFocusEffect hook for activity focus/blur lifecycle

- `useFocusEffect(callback)` hook to register per-activity focus/blur callbacks
- Detection and invocation in plugin `onChanged` (outside React render cycle)
- `callbackRef` pattern for always-latest callback without `useCallback`
- Error isolation via `runSafely()` for all user callbacks
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