diff --git a/.changeset/add-lifecycle-plugin.md b/.changeset/add-lifecycle-plugin.md new file mode 100644 index 000000000..f8eed0674 --- /dev/null +++ b/.changeset/add-lifecycle-plugin.md @@ -0,0 +1,10 @@ +--- +"@stackflow/plugin-lifecycle": 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 diff --git a/.pnp.cjs b/.pnp.cjs index a90d2d9b3..562efbae1 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -58,6 +58,10 @@ const RAW_RUNTIME_STATE = "name": "@stackflow/plugin-history-sync",\ "reference": "workspace:extensions/plugin-history-sync"\ },\ + {\ + "name": "@stackflow/plugin-lifecycle",\ + "reference": "workspace:extensions/plugin-lifecycle"\ + },\ {\ "name": "@stackflow/plugin-map-initial-activity",\ "reference": "workspace:extensions/plugin-map-initial-activity"\ @@ -111,6 +115,7 @@ const RAW_RUNTIME_STATE = ["@stackflow/plugin-devtools", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-devtools", "workspace:extensions/plugin-devtools"]],\ ["@stackflow/plugin-google-analytics-4", ["workspace:extensions/plugin-google-analytics-4"]],\ ["@stackflow/plugin-history-sync", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync", "workspace:extensions/plugin-history-sync"]],\ + ["@stackflow/plugin-lifecycle", ["workspace:extensions/plugin-lifecycle"]],\ ["@stackflow/plugin-map-initial-activity", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-map-initial-activity", "workspace:extensions/plugin-map-initial-activity"]],\ ["@stackflow/plugin-preload", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-preload", "workspace:extensions/plugin-preload"]],\ ["@stackflow/plugin-renderer-basic", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic", "workspace:extensions/plugin-renderer-basic"]],\ @@ -6965,6 +6970,33 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@stackflow/plugin-lifecycle", [\ + ["workspace:extensions/plugin-lifecycle", {\ + "packageLocation": "./extensions/plugin-lifecycle/",\ + "packageDependencies": [\ + ["@stackflow/plugin-lifecycle", "workspace:extensions/plugin-lifecycle"],\ + ["@stackflow/config", "workspace:config"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/plugin-renderer-basic", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic"],\ + ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ + ["@swc/core", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:1.6.6"],\ + ["@swc/jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:0.2.36"],\ + ["@testing-library/dom", "npm:10.4.1"],\ + ["@testing-library/react", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:16.3.2"],\ + ["@types/jest", "npm:29.5.12"],\ + ["@types/react", "npm:18.3.3"],\ + ["esbuild", "npm:0.27.3"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:29.7.0"],\ + ["react", "npm:18.3.1"],\ + ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ + ["rimraf", "npm:6.1.3"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@stackflow/plugin-map-initial-activity", [\ ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-map-initial-activity", {\ "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-map-initial-activity-virtual-3f909b4f3d/1/extensions/plugin-map-initial-activity/",\ diff --git a/extensions/plugin-lifecycle/esbuild.config.js b/extensions/plugin-lifecycle/esbuild.config.js new file mode 100644 index 000000000..b84dfb4db --- /dev/null +++ b/extensions/plugin-lifecycle/esbuild.config.js @@ -0,0 +1,29 @@ +const { context } = require("esbuild"); +const config = require("@stackflow/esbuild-config"); +const pkg = require("./package.json"); + +const watch = process.argv.includes("--watch"); +const external = Object.keys({ + ...pkg.dependencies, + ...pkg.peerDependencies, +}); + +Promise.all([ + context({ + ...config({}), + format: "cjs", + external, + }).then((ctx) => + watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()), + ), + context({ + ...config({}), + format: "esm", + outExtension: { + ".js": ".mjs", + }, + external, + }).then((ctx) => + watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()), + ), +]).catch(() => process.exit(1)); diff --git a/extensions/plugin-lifecycle/package.json b/extensions/plugin-lifecycle/package.json new file mode 100644 index 000000000..3f23af8ce --- /dev/null +++ b/extensions/plugin-lifecycle/package.json @@ -0,0 +1,71 @@ +{ + "name": "@stackflow/plugin-lifecycle", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://github.com/daangn/stackflow.git", + "directory": "extensions/plugin-lifecycle" + }, + "license": "MIT", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "import": "./dist/index.mjs" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "src", + "README.md" + ], + "scripts": { + "build": "yarn build:js && yarn build:dts", + "build:dts": "tsc --emitDeclarationOnly", + "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", + "coveragePathIgnorePatterns": [ + "index.ts" + ], + "transform": { + "^.+\\.(t|j)sx?$": "@swc/jest" + } + }, + "devDependencies": { + "@stackflow/config": "^1.2.2", + "@stackflow/core": "^1.3.0", + "@stackflow/esbuild-config": "^1.0.3", + "@stackflow/plugin-renderer-basic": "^1.1.13", + "@stackflow/react": "^1.12.0", + "@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.27.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rimraf": "^6.1.3", + "typescript": "^5.5.3" + }, + "peerDependencies": { + "@stackflow/core": "^1.1.0-canary.0", + "@stackflow/react": "^1.3.2-canary.0", + "react": ">=16.8.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/extensions/plugin-lifecycle/src/index.ts b/extensions/plugin-lifecycle/src/index.ts new file mode 100644 index 000000000..2a4c10bb6 --- /dev/null +++ b/extensions/plugin-lifecycle/src/index.ts @@ -0,0 +1,2 @@ +export { lifecyclePlugin } from "./lifecyclePlugin"; +export { useFocusEffect } from "./useFocusEffect"; diff --git a/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx new file mode 100644 index 000000000..2d24f5801 --- /dev/null +++ b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx @@ -0,0 +1,486 @@ +import { defineConfig } from "@stackflow/config"; +import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; +import type { StackflowReactPlugin } from "@stackflow/react"; +import { stackflow, useFlow } from "@stackflow/react/future"; +import { act, render } from "@testing-library/react"; +import React, { useCallback, useState } from "react"; +import { lifecyclePlugin } from "./lifecyclePlugin"; +import { useFocusEffect } from "./useFocusEffect"; + +declare module "@stackflow/config" { + interface Register { + ActivityA: {}; + ActivityB: {}; + ActivityC: {}; + } +} + +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(), lifecyclePlugin(), ...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
A
; + } + function ActivityB() { + return
B
; + } + + const { Stack } = setupStack({ ActivityA, ActivityB }); + + await act(async () => { + render(); + }); + + 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
A
; + } + function ActivityB() { + return
B
; + } + + const { Stack, actions } = setupStack({ ActivityA, ActivityB }); + + await act(async () => { + render(); + }); + + 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
A
; + } + function ActivityB() { + return
B
; + } + + const { Stack, actions } = setupStack({ ActivityA, ActivityB }); + + await act(async () => { + render(); + }); + + 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
A
; + } + function ActivityB() { + return
B
; + } + + const { Stack } = setupStack({ ActivityA, ActivityB }); + + await act(async () => { + render(); + }); + + 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
A
; + } + function ActivityB() { + return
B
; + } + + const { Stack, actions } = setupStack({ ActivityA, ActivityB }); + + await act(async () => { + render(); + }); + + 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
A
; + } + function ActivityB() { + return
B
; + } + + const { Stack } = setupStack({ ActivityA, ActivityB }); + + const { unmount } = await act(async () => { + return render(); + }); + + expect(cleanup).not.toHaveBeenCalled(); + + await act(async () => { + unmount(); + }); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); + }); + + describe("callback change while focused", () => { + it("cleanup→re-runs when callback reference changes (useCallback deps)", async () => { + const cleanup1 = jest.fn(); + const effect1 = jest.fn(() => cleanup1); + const cleanup2 = jest.fn(); + const effect2 = jest.fn(() => cleanup2); + let setArticleId!: (v: string) => void; + + function ActivityA() { + const [articleId, _setArticleId] = useState("1"); + setArticleId = _setArticleId; + + useFocusEffect( + useCallback(() => { + return articleId === "1" ? effect1() : effect2(); + }, [articleId]), + ); + return
A
; + } + function ActivityB() { + return
B
; + } + + const { Stack } = setupStack({ ActivityA, ActivityB }); + + await act(async () => { + render(); + }); + + expect(effect1).toHaveBeenCalledTimes(1); + expect(effect2).not.toHaveBeenCalled(); + + // Change articleId while focused → cleanup old, run new + await act(async () => { + setArticleId("2"); + }); + + expect(cleanup1).toHaveBeenCalledTimes(1); + expect(effect2).toHaveBeenCalledTimes(1); + }); + + 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( + useCallback(() => { + return useSecond ? secondEffect() : firstEffect(); + }, [useSecond]), + ); + return
A
; + } + function ActivityB() { + return
B
; + } + + const { Stack, actions } = setupStack({ ActivityA, ActivityB }); + + await act(async () => { + render(); + }); + + expect(firstEffect).toHaveBeenCalledTimes(1); + expect(secondEffect).not.toHaveBeenCalled(); + + // Update dep while A is active → cleanup→re-run + await act(async () => { + setUseSecond(true); + }); + + expect(secondEffect).toHaveBeenCalledTimes(1); + + // Push B → blur cleanup + await act(async () => { + actions.push("ActivityB", {}); + }); + + // Pop B → refocus → secondEffect again + await act(async () => { + actions.pop(); + }); + + expect(secondEffect).toHaveBeenCalledTimes(2); + }); + }); + + describe("cleanup called exactly once on pop", () => { + it("does not double-invoke cleanup from both onChanged blur and useEffect unmount", async () => { + const cleanup = jest.fn(); + + function ActivityA() { + return
A
; + } + function ActivityB() { + useFocusEffect(() => cleanup); + return
B
; + } + + const { Stack, actions } = setupStack({ ActivityA, ActivityB }); + + await act(async () => { + render(); + }); + + // Push B + await act(async () => { + actions.push("ActivityB", {}); + }); + + // Pop B — triggers onChanged blur + useEffect unmount cleanup + await act(async () => { + actions.pop(); + }); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); + }); + + describe("replace", () => { + it("runs cleanup on the replaced activity", async () => { + const cleanupA = jest.fn(); + const effectA = jest.fn(() => cleanupA); + + function ActivityA() { + useFocusEffect(effectA); + return
A
; + } + function ActivityB() { + return
B
; + } + + const { Stack, actions } = setupStack({ ActivityA, ActivityB }); + + await act(async () => { + render(); + }); + + expect(effectA).toHaveBeenCalledTimes(1); + + // Replace A with B — A blurs, B focuses + await act(async () => { + actions.replace("ActivityB", {}); + }); + + expect(cleanupA).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
A
; + } + function ActivityB() { + useFocusEffect(effectB); + return
B
; + } + + const { Stack, actions } = setupStack({ ActivityA, ActivityB }); + + await act(async () => { + render(); + }); + + 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); + }); + }); + + describe("reentrancy", () => { + it("refocus 콜백에서 시작된 navigation의 blur는 현재 콜백이 반환된 후 처리된다", async () => { + // Scenario: pop B → A refocuses (onChanged) → A's callback pushes C + // The push triggers reentrant onChanged. With the reentrancy guard, + // the inner transition is deferred until the outer focus completes. + const callLog: string[] = []; + let pushC: (() => void) | null = null; + + function ActivityA() { + useFocusEffect( + useCallback(() => { + callLog.push("A:focus:start"); + if (pushC) { + const fn = pushC; + pushC = null; + fn(); + // If reentrant blur ran synchronously, "A:cleanup" would appear here + } + callLog.push("A:focus:end"); + return () => { + callLog.push("A:cleanup"); + }; + }, []), + ); + return
A
; + } + function ActivityB() { + return
B
; + } + function ActivityC() { + return
C
; + } + + const config = defineConfig({ + activities: [ + { name: "ActivityA" as const }, + { name: "ActivityB" as const }, + { name: "ActivityC" as const }, + ], + transitionDuration: 0, + initialActivity: () => "ActivityA" as const, + }); + + const { Stack, actions } = stackflow({ + config, + components: { + ActivityA, + ActivityB, + ActivityC, + }, + plugins: [basicRendererPlugin(), lifecyclePlugin()], + }); + + await act(async () => { + render(); + }); + + // Initial: A focuses + expect(callLog).toEqual(["A:focus:start", "A:focus:end"]); + callLog.length = 0; + + // Push B → A blurs + await act(async () => { + actions.push("ActivityB" as any, {}); + }); + + expect(callLog).toEqual(["A:cleanup"]); + callLog.length = 0; + + // Arm: when A refocuses, push C + pushC = () => actions.push("ActivityC" as any, {}); + + // Pop B → A refocuses → callback pushes C (reentrant) → deferred blur + await act(async () => { + actions.pop(); + }); + + // Key assertion: A:focus completes fully before A:cleanup runs again. + // Without the reentrancy guard, A:cleanup would be missing (cleanup not yet in store). + expect(callLog).toEqual([ + "A:focus:start", + "A:focus:end", + "A:cleanup", + ]); + }); + }); +}); diff --git a/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx b/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx new file mode 100644 index 000000000..3a255c93b --- /dev/null +++ b/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx @@ -0,0 +1,126 @@ +import type { StackflowReactPlugin } from "@stackflow/react"; +import { createContext, createElement, useContext } from "react"; +import { runSafely } from "./runSafely"; + +type FocusEffectEntry = { + id: symbol; + activityId: string; + callbackRef: { current: () => (() => void) | void }; +}; + +type PendingTransition = { + prevActiveId: string | null; + currentActiveId: string | null; +}; + +type LifecycleStore = { + entries: Map; + cleanups: Map void) | void>; + prevActiveActivityId: string | null; + processing: boolean; + pendingTransitions: PendingTransition[]; +}; + +const LifecycleStoreContext = createContext(null); + +export function useLifecycleStore(): LifecycleStore { + const store = useContext(LifecycleStoreContext); + if (!store) { + throw new Error( + "lifecyclePlugin() must be registered before using useFocusEffect()", + ); + } + return store; +} + +function processTransition( + store: LifecycleStore, + prevActiveId: string | null, + currentActiveId: string | null, +): void { + // 1. Blur: cleanup previous active activity's entries + if (prevActiveId !== null) { + for (const [entryId, entry] of store.entries) { + if (entry.activityId === prevActiveId) { + const cleanup = store.cleanups.get(entryId); + runSafely(cleanup); + store.cleanups.delete(entryId); + } + } + } + + // 2. Focus: run effects for new active activity's entries + if (currentActiveId !== null) { + for (const [entryId, entry] of store.entries) { + if (entry.activityId === currentActiveId) { + const cleanup = runSafely(entry.callbackRef.current); + store.cleanups.set(entryId, cleanup); + } + } + } +} + +export function lifecyclePlugin(): StackflowReactPlugin { + const store: LifecycleStore = { + entries: new Map(), + cleanups: new Map(), + prevActiveActivityId: null, + processing: false, + pendingTransitions: [], + }; + + return () => ({ + key: "@stackflow/plugin-lifecycle", + + onInit({ actions }) { + const stack = actions.getStack(); + const activeActivity = stack.activities.find((a) => a.isActive); + store.prevActiveActivityId = activeActivity?.id ?? null; + }, + + wrapStack({ stack }) { + return createElement( + LifecycleStoreContext.Provider, + { value: store }, + stack.render(), + ); + }, + + onChanged({ actions }) { + const currentStack = actions.getStack(); + const activeActivity = currentStack.activities.find((a) => a.isActive); + const currentActiveId = activeActivity?.id ?? null; + + if (currentActiveId === store.prevActiveActivityId) { + return; + } + + const prevActiveId = store.prevActiveActivityId; + store.prevActiveActivityId = currentActiveId; + + // Reentrancy guard: if a callback triggers navigation (push/pop/replace), + // onChanged fires synchronously again. Defer to avoid corrupted iteration. + if (store.processing) { + store.pendingTransitions.push({ prevActiveId, currentActiveId }); + return; + } + + store.processing = true; + try { + processTransition(store, prevActiveId, currentActiveId); + + // Drain queued transitions from reentrant onChanged calls + while (store.pendingTransitions.length > 0) { + const pending = store.pendingTransitions.shift()!; + processTransition( + store, + pending.prevActiveId, + pending.currentActiveId, + ); + } + } finally { + store.processing = false; + } + }, + }); +} diff --git a/extensions/plugin-lifecycle/src/runSafely.ts b/extensions/plugin-lifecycle/src/runSafely.ts new file mode 100644 index 000000000..60027c818 --- /dev/null +++ b/extensions/plugin-lifecycle/src/runSafely.ts @@ -0,0 +1,12 @@ +export function runSafely( + fn: (() => (() => void) | void) | void | undefined, +): (() => void) | void { + if (typeof fn !== "function") { + return; + } + try { + return fn(); + } catch (e) { + console.error(e); + } +} diff --git a/extensions/plugin-lifecycle/src/useFocusEffect.ts b/extensions/plugin-lifecycle/src/useFocusEffect.ts new file mode 100644 index 000000000..d922341e3 --- /dev/null +++ b/extensions/plugin-lifecycle/src/useFocusEffect.ts @@ -0,0 +1,71 @@ +import { useActivity } from "@stackflow/react"; +import { useEffect, useRef } from "react"; +import { runSafely } from "./runSafely"; +import { useLifecycleStore } from "./lifecyclePlugin"; + +/** + * Registers a callback that runs when the activity gains focus (becomes active) + * and an optional cleanup that runs on blur (loses active status), unmount, + * or when the callback reference changes. + * + * Wrap the callback in `React.useCallback` to control when cleanup→re-run occurs: + * + * ```tsx + * useFocusEffect( + * useCallback(() => { + * const sub = subscribe(articleId); + * return () => sub.unsubscribe(); + * }, [articleId]) + * ); + * ``` + * + * The callback is invoked from the plugin's `onChanged` handler — outside the + * React render cycle — so it executes immediately on activity transition without + * waiting for React's deferred rendering. + * + * Best for external side-effects: query invalidation, analytics, cache warming. + * Avoid calling React setState inside the callback — the React tree may still + * reflect the previous stack state at invocation time. + * + * For effects that depend on a settled React tree (DOM manipulation, scroll + * restoration), use `useActiveEffect` from `@stackflow/react` instead. + */ +export function useFocusEffect( + callback: () => (() => void) | void, +): void { + const store = useLifecycleStore(); + const activity = useActivity(); + const idRef = useRef(Symbol()); + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }); + + useEffect(() => { + const id = idRef.current; + + store.entries.set(id, { + id, + activityId: activity.id, + callbackRef, + }); + + // If activity is currently active, run effect immediately. + // This handles both initial focus and callback changes while focused. + if (activity.isActive) { + const cleanup = runSafely(callbackRef.current); + store.cleanups.set(id, cleanup); + } + + return () => { + const cleanup = store.cleanups.get(id); + runSafely(cleanup); + store.cleanups.delete(id); + store.entries.delete(id); + }; + // callback in deps: changes trigger cleanup→re-run (React Navigation pattern) + // activity.isActive intentionally excluded — onChanged handles subsequent transitions + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [store, activity.id, callback]); +} diff --git a/extensions/plugin-lifecycle/tsconfig.json b/extensions/plugin-lifecycle/tsconfig.json new file mode 100644 index 000000000..9b86f62a0 --- /dev/null +++ b/extensions/plugin-lifecycle/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "jsx": "preserve", + "module": "preserve", + "declaration": true, + "declarationMap": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist" + }, + "include": ["./src/**/*"], + "exclude": ["./src/**/*.spec.*"] +} diff --git a/integrations/react/src/future/index.ts b/integrations/react/src/future/index.ts index 91ce06a66..45b7636f0 100644 --- a/integrations/react/src/future/index.ts +++ b/integrations/react/src/future/index.ts @@ -17,3 +17,4 @@ export * from "./useConfig"; export * from "./useFlow"; export * from "./usePrepare"; export * from "./useStepFlow"; + diff --git a/integrations/react/src/stable/useActiveEffect.ts b/integrations/react/src/stable/useActiveEffect.ts index 66c8bebba..a704d8117 100644 --- a/integrations/react/src/stable/useActiveEffect.ts +++ b/integrations/react/src/stable/useActiveEffect.ts @@ -3,6 +3,16 @@ import { useEffect } from "react"; import { useActivity } from "../__internal__/activity/useActivity"; import { noop } from "../__internal__/utils"; +/** + * Runs an effect when the activity becomes active (`isActive === true`). + * Executes after React commit, so the callback sees a fully settled React tree. + * + * Best for effects that depend on React state/context (DOM manipulation, scroll restoration). + * + * For external side-effects (query invalidation, analytics) that should run immediately + * on activity transition without waiting for React's deferred rendering, + * use `useFocusEffect` from `@stackflow/react/future` instead. + */ export const useActiveEffect = (effect: React.EffectCallback) => { const { isActive } = useActivity(); diff --git a/yarn.lock b/yarn.lock index 76227a6ab..7ed3caca3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5907,6 +5907,35 @@ __metadata: languageName: unknown linkType: soft +"@stackflow/plugin-lifecycle@workspace:extensions/plugin-lifecycle": + version: 0.0.0-use.local + resolution: "@stackflow/plugin-lifecycle@workspace:extensions/plugin-lifecycle" + dependencies: + "@stackflow/config": "npm:^1.2.2" + "@stackflow/core": "npm:^1.3.0" + "@stackflow/esbuild-config": "npm:^1.0.3" + "@stackflow/plugin-renderer-basic": "npm:^1.1.13" + "@stackflow/react": "npm:^1.12.0" + "@swc/core": "npm:^1.6.6" + "@swc/jest": "npm:^0.2.36" + "@testing-library/dom": "npm:^10.4.0" + "@testing-library/react": "npm:^16.3.2" + "@types/jest": "npm:^29.5.12" + "@types/react": "npm:^18.3.3" + esbuild: "npm:^0.27.3" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" + react: "npm:^18.3.1" + react-dom: "npm:^18.3.1" + rimraf: "npm:^6.1.3" + typescript: "npm:^5.5.3" + peerDependencies: + "@stackflow/core": ^1.1.0-canary.0 + "@stackflow/react": ^1.3.2-canary.0 + react: ">=16.8.0" + languageName: unknown + linkType: soft + "@stackflow/plugin-map-initial-activity@npm:^1.0.11, @stackflow/plugin-map-initial-activity@workspace:extensions/plugin-map-initial-activity": version: 0.0.0-use.local resolution: "@stackflow/plugin-map-initial-activity@workspace:extensions/plugin-map-initial-activity"