From bd1776b745a5923d74b653067873e279952417e2 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Mon, 30 Mar 2026 16:52:14 +0900 Subject: [PATCH 01/15] feat(react): add lifecyclePlugin and useFocusEffect hook Add an internal lifecycle plugin that provides focus/blur callbacks for activities. When an activity becomes active (initial or refocus), registered effects run. When it loses active status, cleanups execute. Detection and invocation happen in the plugin's onChanged hook (outside React render cycle), while the hook only handles registration/deregistration. This avoids useDeferredValue tearing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .pnp.cjs | 141 +++++++-- integrations/react/esbuild.config.js | 10 +- integrations/react/package.json | 33 ++ integrations/react/src/future/index.ts | 1 + .../react/src/future/lifecycle/index.ts | 2 + .../future/lifecycle/lifecyclePlugin.spec.tsx | 289 ++++++++++++++++++ .../src/future/lifecycle/lifecyclePlugin.tsx | 87 ++++++ .../react/src/future/lifecycle/runSafely.ts | 12 + .../src/future/lifecycle/useFocusEffect.ts | 42 +++ integrations/react/src/future/stackflow.tsx | 3 + integrations/react/tsconfig.json | 2 +- yarn.lock | 9 + 12 files changed, 609 insertions(+), 22 deletions(-) create mode 100644 integrations/react/src/future/lifecycle/index.ts create mode 100644 integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx create mode 100644 integrations/react/src/future/lifecycle/lifecyclePlugin.tsx create mode 100644 integrations/react/src/future/lifecycle/runSafely.ts create mode 100644 integrations/react/src/future/lifecycle/useFocusEffect.ts diff --git a/.pnp.cjs b/.pnp.cjs index b600d9738..5285cf83e 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -109,10 +109,10 @@ const RAW_RUNTIME_STATE = ["@stackflow/plugin-history-sync", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync", "workspace:extensions/plugin-history-sync"]],\ ["@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"]],\ + ["@stackflow/plugin-renderer-basic", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic", "virtual:953894a7c789b2607a0624cdd2fe101ab646f7a48665d6c9514d70b8fe13e6bcc6f5184c4f01b3e7f4d1a067ba4fe9edb93885b270a0c2a5328c3aaac43dadf9#workspace:extensions/plugin-renderer-basic", "virtual:c41768bfd5ee324a3f9a83b254e0b7124a4fcca91fe506bc2a80d72fecb3083480f4644c952d19848553d28a66cb338c740989739f68c017ca74ef4340e9fea2#workspace:extensions/plugin-renderer-basic", "workspace:extensions/plugin-renderer-basic"]],\ ["@stackflow/plugin-renderer-web", ["workspace:extensions/plugin-renderer-web"]],\ ["@stackflow/plugin-stack-depth-change", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-stack-depth-change", "workspace:extensions/plugin-stack-depth-change"]],\ - ["@stackflow/react", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react", "workspace:integrations/react"]],\ + ["@stackflow/react", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react", "virtual:529c8a661b5417ff774bc95bf03f7325b4d0d04ab9b64e53f221a654e56c8deae5796080e692602e22c37f65861ea95cc75439afcc3a5ba2190d904c35fc9d04#workspace:integrations/react", "workspace:integrations/react"]],\ ["@stackflow/react-ui-core", ["virtual:669046a185e83900af978519e5adddf8e8f1f8fed824849248ba56cf8fcd4e4208872f27e14c3c844d3b769f42be1ba6e0aa90f12df9fa6c38a55aedee211f53#workspace:extensions/react-ui-core", "workspace:extensions/react-ui-core"]]\ ],\ "fallbackPool": [\ @@ -6751,12 +6751,12 @@ const RAW_RUNTIME_STATE = ["@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"],\ + ["@testing-library/react", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#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"],\ + ["jest-environment-jsdom", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:29.7.0"],\ ["react", "npm:18.3.1"],\ ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ ["rimraf", "npm:6.1.3"],\ @@ -7004,13 +7004,63 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ + ["virtual:953894a7c789b2607a0624cdd2fe101ab646f7a48665d6c9514d70b8fe13e6bcc6f5184c4f01b3e7f4d1a067ba4fe9edb93885b270a0c2a5328c3aaac43dadf9#workspace:extensions/plugin-renderer-basic", {\ + "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-renderer-basic-virtual-bb2ef0b972/1/extensions/plugin-renderer-basic/",\ + "packageDependencies": [\ + ["@stackflow/plugin-renderer-basic", "virtual:953894a7c789b2607a0624cdd2fe101ab646f7a48665d6c9514d70b8fe13e6bcc6f5184c4f01b3e7f4d1a067ba4fe9edb93885b270a0c2a5328c3aaac43dadf9#workspace:extensions/plugin-renderer-basic"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/react", "workspace:integrations/react"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/stackflow__core", null],\ + ["@types/stackflow__react", null],\ + ["esbuild", "npm:0.23.0"],\ + ["react", "npm:18.3.1"],\ + ["rimraf", "npm:3.0.2"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ + ],\ + "packagePeers": [\ + "@stackflow/core",\ + "@stackflow/react",\ + "@types/react",\ + "@types/stackflow__core",\ + "@types/stackflow__react",\ + "react"\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:c41768bfd5ee324a3f9a83b254e0b7124a4fcca91fe506bc2a80d72fecb3083480f4644c952d19848553d28a66cb338c740989739f68c017ca74ef4340e9fea2#workspace:extensions/plugin-renderer-basic", {\ + "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-renderer-basic-virtual-c766e9fd84/1/extensions/plugin-renderer-basic/",\ + "packageDependencies": [\ + ["@stackflow/plugin-renderer-basic", "virtual:c41768bfd5ee324a3f9a83b254e0b7124a4fcca91fe506bc2a80d72fecb3083480f4644c952d19848553d28a66cb338c740989739f68c017ca74ef4340e9fea2#workspace:extensions/plugin-renderer-basic"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/react", "virtual:529c8a661b5417ff774bc95bf03f7325b4d0d04ab9b64e53f221a654e56c8deae5796080e692602e22c37f65861ea95cc75439afcc3a5ba2190d904c35fc9d04#workspace:integrations/react"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/stackflow__core", null],\ + ["@types/stackflow__react", null],\ + ["esbuild", "npm:0.23.0"],\ + ["react", "npm:18.3.1"],\ + ["rimraf", "npm:3.0.2"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ + ],\ + "packagePeers": [\ + "@stackflow/core",\ + "@stackflow/react",\ + "@types/react",\ + "@types/stackflow__core",\ + "@types/stackflow__react",\ + "react"\ + ],\ + "linkType": "SOFT"\ + }],\ ["workspace:extensions/plugin-renderer-basic", {\ "packageLocation": "./extensions/plugin-renderer-basic/",\ "packageDependencies": [\ ["@stackflow/plugin-renderer-basic", "workspace:extensions/plugin-renderer-basic"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ + ["@stackflow/react", "virtual:529c8a661b5417ff774bc95bf03f7325b4d0d04ab9b64e53f221a654e56c8deae5796080e692602e22c37f65861ea95cc75439afcc3a5ba2190d904c35fc9d04#workspace:integrations/react"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.23.0"],\ ["react", "npm:18.3.1"],\ @@ -7078,12 +7128,21 @@ const RAW_RUNTIME_STATE = ["@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"],\ + ["@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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2"],\ + ["@types/jest", "npm:29.5.12"],\ ["@types/react", "npm:18.3.3"],\ ["@types/stackflow__config", null],\ ["@types/stackflow__core", null],\ ["esbuild", "npm:0.23.0"],\ ["esbuild-plugin-file-path-extensions", "npm:2.1.3"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:29.7.0"],\ ["react", "npm:18.3.1"],\ + ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ ["react-fast-compare", "npm:3.2.2"],\ ["rimraf", "npm:3.0.2"],\ ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ @@ -7098,6 +7157,41 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ + ["virtual:529c8a661b5417ff774bc95bf03f7325b4d0d04ab9b64e53f221a654e56c8deae5796080e692602e22c37f65861ea95cc75439afcc3a5ba2190d904c35fc9d04#workspace:integrations/react", {\ + "packageLocation": "./.yarn/__virtual__/@stackflow-react-virtual-c41768bfd5/1/integrations/react/",\ + "packageDependencies": [\ + ["@stackflow/react", "virtual:529c8a661b5417ff774bc95bf03f7325b4d0d04ab9b64e53f221a654e56c8deae5796080e692602e22c37f65861ea95cc75439afcc3a5ba2190d904c35fc9d04#workspace:integrations/react"],\ + ["@stackflow/config", "workspace:config"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/plugin-renderer-basic", "virtual:c41768bfd5ee324a3f9a83b254e0b7124a4fcca91fe506bc2a80d72fecb3083480f4644c952d19848553d28a66cb338c740989739f68c017ca74ef4340e9fea2#workspace:extensions/plugin-renderer-basic"],\ + ["@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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2"],\ + ["@types/jest", "npm:29.5.12"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/stackflow__config", null],\ + ["@types/stackflow__core", null],\ + ["esbuild", "npm:0.23.0"],\ + ["esbuild-plugin-file-path-extensions", "npm:2.1.3"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:29.7.0"],\ + ["react", "npm:18.3.1"],\ + ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ + ["react-fast-compare", "npm:3.2.2"],\ + ["rimraf", "npm:3.0.2"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ + ],\ + "packagePeers": [\ + "@stackflow/core",\ + "@types/react",\ + "@types/stackflow__config",\ + "@types/stackflow__core",\ + "react"\ + ],\ + "linkType": "SOFT"\ + }],\ ["workspace:integrations/react", {\ "packageLocation": "./integrations/react/",\ "packageDependencies": [\ @@ -7105,10 +7199,19 @@ const RAW_RUNTIME_STATE = ["@stackflow/config", "workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/plugin-renderer-basic", "virtual:953894a7c789b2607a0624cdd2fe101ab646f7a48665d6c9514d70b8fe13e6bcc6f5184c4f01b3e7f4d1a067ba4fe9edb93885b270a0c2a5328c3aaac43dadf9#workspace:extensions/plugin-renderer-basic"],\ + ["@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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2"],\ + ["@types/jest", "npm:29.5.12"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.23.0"],\ ["esbuild-plugin-file-path-extensions", "npm:2.1.3"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:29.7.0"],\ ["react", "npm:18.3.1"],\ + ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ ["react-fast-compare", "npm:3.2.2"],\ ["rimraf", "npm:3.0.2"],\ ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ @@ -7398,10 +7501,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:16.3.2", {\ - "packageLocation": "./.yarn/__virtual__/@testing-library-react-virtual-f767e7b05a/0/cache/@testing-library-react-npm-16.3.2-67b0b894c8-0ca88c6f67.zip/node_modules/@testing-library/react/",\ + ["virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2", {\ + "packageLocation": "./.yarn/__virtual__/@testing-library-react-virtual-9ad9598c0b/0/cache/@testing-library-react-npm-16.3.2-67b0b894c8-0ca88c6f67.zip/node_modules/@testing-library/react/",\ "packageDependencies": [\ - ["@testing-library/react", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:16.3.2"],\ + ["@testing-library/react", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2"],\ ["@babel/runtime", "npm:7.25.0"],\ ["@testing-library/dom", "npm:10.4.1"],\ ["@types/react", "npm:18.3.3"],\ @@ -12918,10 +13021,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:29.7.0", {\ - "packageLocation": "./.yarn/__virtual__/jest-environment-jsdom-virtual-03ba513b4a/0/cache/jest-environment-jsdom-npm-29.7.0-0b72dd0e0b-23bbfc9bca.zip/node_modules/jest-environment-jsdom/",\ + ["virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:29.7.0", {\ + "packageLocation": "./.yarn/__virtual__/jest-environment-jsdom-virtual-6ddc26222e/0/cache/jest-environment-jsdom-npm-29.7.0-0b72dd0e0b-23bbfc9bca.zip/node_modules/jest-environment-jsdom/",\ "packageDependencies": [\ - ["jest-environment-jsdom", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:29.7.0"],\ ["@jest/environment", "npm:29.7.0"],\ ["@jest/fake-timers", "npm:29.7.0"],\ ["@jest/types", "npm:29.6.3"],\ @@ -12931,7 +13034,7 @@ const RAW_RUNTIME_STATE = ["canvas", null],\ ["jest-mock", "npm:29.7.0"],\ ["jest-util", "npm:29.7.0"],\ - ["jsdom", "virtual:03ba513b4a4f2f49a0ee779e0b1da3ef4f41cbf0cff4a27f151a6c11d5162aae67852dc5c3f387d71c020640c3547cdf783b461f72a6ebbd7907fd3300ce6913#npm:20.0.3"]\ + ["jsdom", "virtual:6ddc26222e8aaaf60dbe5079fb179f32c850d26f7d5eed4ee3b5f965c379e18837e42bec2d87cd2beb06ebc9c2dad24e31837cd2f985431c5ebd8bb747b0202c#npm:20.0.3"]\ ],\ "packagePeers": [\ "@types/canvas",\ @@ -13294,10 +13397,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:03ba513b4a4f2f49a0ee779e0b1da3ef4f41cbf0cff4a27f151a6c11d5162aae67852dc5c3f387d71c020640c3547cdf783b461f72a6ebbd7907fd3300ce6913#npm:20.0.3", {\ - "packageLocation": "./.yarn/__virtual__/jsdom-virtual-09fbede01d/0/cache/jsdom-npm-20.0.3-906a2f7005-a4cdcff5b0.zip/node_modules/jsdom/",\ + ["virtual:6ddc26222e8aaaf60dbe5079fb179f32c850d26f7d5eed4ee3b5f965c379e18837e42bec2d87cd2beb06ebc9c2dad24e31837cd2f985431c5ebd8bb747b0202c#npm:20.0.3", {\ + "packageLocation": "./.yarn/__virtual__/jsdom-virtual-5cf75f356c/0/cache/jsdom-npm-20.0.3-906a2f7005-a4cdcff5b0.zip/node_modules/jsdom/",\ "packageDependencies": [\ - ["jsdom", "virtual:03ba513b4a4f2f49a0ee779e0b1da3ef4f41cbf0cff4a27f151a6c11d5162aae67852dc5c3f387d71c020640c3547cdf783b461f72a6ebbd7907fd3300ce6913#npm:20.0.3"],\ + ["jsdom", "virtual:6ddc26222e8aaaf60dbe5079fb179f32c850d26f7d5eed4ee3b5f965c379e18837e42bec2d87cd2beb06ebc9c2dad24e31837cd2f985431c5ebd8bb747b0202c#npm:20.0.3"],\ ["@types/canvas", null],\ ["abab", "npm:2.0.6"],\ ["acorn", "npm:8.16.0"],\ @@ -13324,7 +13427,7 @@ const RAW_RUNTIME_STATE = ["whatwg-encoding", "npm:2.0.0"],\ ["whatwg-mimetype", "npm:3.0.0"],\ ["whatwg-url", "npm:11.0.0"],\ - ["ws", "virtual:09fbede01d752e610be1714c18909368fe4fa709b16e76a5ed8cde05b6dbb3342f037902ae401113b5bbbb44b9753fbd2ba83c3277f1f798491ade558971e25f#npm:8.19.0"],\ + ["ws", "virtual:5cf75f356cf180b5d5b0ad15d0e36a731a029955ec8d46ac7c30b389a1d2bf7c50182cc3af071d7a8b19953f6ccd0f184053bdbb7dc9085bb1a85e597b796d4d#npm:8.19.0"],\ ["xml-name-validator", "npm:4.0.0"]\ ],\ "packagePeers": [\ @@ -19345,10 +19448,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:09fbede01d752e610be1714c18909368fe4fa709b16e76a5ed8cde05b6dbb3342f037902ae401113b5bbbb44b9753fbd2ba83c3277f1f798491ade558971e25f#npm:8.19.0", {\ - "packageLocation": "./.yarn/__virtual__/ws-virtual-99b0ff26e3/0/cache/ws-npm-8.19.0-c967c046a5-26e4901e93.zip/node_modules/ws/",\ + ["virtual:5cf75f356cf180b5d5b0ad15d0e36a731a029955ec8d46ac7c30b389a1d2bf7c50182cc3af071d7a8b19953f6ccd0f184053bdbb7dc9085bb1a85e597b796d4d#npm:8.19.0", {\ + "packageLocation": "./.yarn/__virtual__/ws-virtual-28f7343cc1/0/cache/ws-npm-8.19.0-c967c046a5-26e4901e93.zip/node_modules/ws/",\ "packageDependencies": [\ - ["ws", "virtual:09fbede01d752e610be1714c18909368fe4fa709b16e76a5ed8cde05b6dbb3342f037902ae401113b5bbbb44b9753fbd2ba83c3277f1f798491ade558971e25f#npm:8.19.0"],\ + ["ws", "virtual:5cf75f356cf180b5d5b0ad15d0e36a731a029955ec8d46ac7c30b389a1d2bf7c50182cc3af071d7a8b19953f6ccd0f184053bdbb7dc9085bb1a85e597b796d4d#npm:8.19.0"],\ ["@types/bufferutil", null],\ ["@types/utf-8-validate", null],\ ["bufferutil", null],\ diff --git a/integrations/react/esbuild.config.js b/integrations/react/esbuild.config.js index 17749d586..1be9b37ed 100644 --- a/integrations/react/esbuild.config.js +++ b/integrations/react/esbuild.config.js @@ -1,3 +1,5 @@ +const { readdirSync, statSync } = require("fs"); +const { join } = require("path"); const { context } = require("esbuild"); const config = require("@stackflow/esbuild-config"); const { @@ -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, @@ -27,7 +33,7 @@ Promise.all([ ), context({ ...config({ - entryPoints: ["./src/**/*"], + entryPoints, outdir: "dist", }), bundle: true, diff --git a/integrations/react/package.json b/integrations/react/package.json index 90a6473bd..530b7520f 100644 --- a/integrations/react/package.json +++ b/integrations/react/package.json @@ -38,8 +38,32 @@ "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": [ + "/src" + ], + "coveragePathIgnorePatterns": [ + "index.ts" + ], + "transform": { + "^.+\\.(t|j)sx?$": [ + "@swc/jest", + { + "jsc": { + "transform": { + "react": { + "runtime": "automatic" + } + } + } + } + ] + } + }, "dependencies": { "react-fast-compare": "^3.2.2" }, @@ -47,10 +71,19 @@ "@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" }, diff --git a/integrations/react/src/future/index.ts b/integrations/react/src/future/index.ts index 91ce06a66..30ac119cc 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"; +export { useFocusEffect } from "./lifecycle"; diff --git a/integrations/react/src/future/lifecycle/index.ts b/integrations/react/src/future/lifecycle/index.ts new file mode 100644 index 000000000..2a4c10bb6 --- /dev/null +++ b/integrations/react/src/future/lifecycle/index.ts @@ -0,0 +1,2 @@ +export { lifecyclePlugin } from "./lifecyclePlugin"; +export { useFocusEffect } from "./useFocusEffect"; diff --git a/integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx b/integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx new file mode 100644 index 000000000..827f6d121 --- /dev/null +++ b/integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx @@ -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
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("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
A
; + } + function ActivityB() { + return
B
; + } + + const { Stack, actions } = setupStack({ ActivityA, ActivityB }); + + await act(async () => { + render(); + }); + + 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
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); + }); + }); +}); diff --git a/integrations/react/src/future/lifecycle/lifecyclePlugin.tsx b/integrations/react/src/future/lifecycle/lifecyclePlugin.tsx new file mode 100644 index 000000000..39ab62199 --- /dev/null +++ b/integrations/react/src/future/lifecycle/lifecyclePlugin.tsx @@ -0,0 +1,87 @@ +import type { StackflowReactPlugin } from "../../__internal__/StackflowReactPlugin"; +import { createContext, createElement, useContext } from "react"; +import { runSafely } from "./runSafely"; + +type FocusEffectEntry = { + id: symbol; + activityId: string; + callbackRef: { current: () => (() => void) | void }; +}; + +type LifecycleStore = { + entries: Map; + cleanups: Map void) | void>; + prevActiveActivityId: string | null; +}; + +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; +} + +export function lifecyclePlugin(): StackflowReactPlugin { + const store: LifecycleStore = { + entries: new Map(), + cleanups: new Map(), + prevActiveActivityId: null, + }; + + 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; + + // 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); + } + } + } + }, + }); +} diff --git a/integrations/react/src/future/lifecycle/runSafely.ts b/integrations/react/src/future/lifecycle/runSafely.ts new file mode 100644 index 000000000..60027c818 --- /dev/null +++ b/integrations/react/src/future/lifecycle/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/integrations/react/src/future/lifecycle/useFocusEffect.ts b/integrations/react/src/future/lifecycle/useFocusEffect.ts new file mode 100644 index 000000000..fb7071d00 --- /dev/null +++ b/integrations/react/src/future/lifecycle/useFocusEffect.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from "react"; + +import { useActivity } from "../../__internal__/activity/useActivity"; +import { runSafely } from "./runSafely"; +import { useLifecycleStore } from "./lifecyclePlugin"; + +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, + }); + + // Initial focus: if activity is already active, run effect immediately. + // activity.isActive is intentionally not in deps — onChanged handles subsequent transitions. + 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); + }; + }, [store, activity.id]); +} diff --git a/integrations/react/src/future/stackflow.tsx b/integrations/react/src/future/stackflow.tsx index 02bf8a5d1..c4f1e196a 100644 --- a/integrations/react/src/future/stackflow.tsx +++ b/integrations/react/src/future/stackflow.tsx @@ -22,6 +22,7 @@ import { isBrowser, makeRef } from "../__internal__/utils"; import type { StackflowReactPlugin } from "../stable"; import type { Actions } from "./Actions"; import { ConfigProvider } from "./ConfigProvider"; +import { lifecyclePlugin } from "./lifecycle"; import { DataLoaderProvider, loaderPlugin } from "./loader"; import { makeActions } from "./makeActions"; import { makeStepActions } from "./makeStepActions"; @@ -74,6 +75,8 @@ export function stackflow< return loaderData; }; const plugins = [ + lifecyclePlugin(), + ...(input.plugins ?? []) .flat(Number.POSITIVE_INFINITY as 0) .map((p) => p as StackflowReactPlugin), diff --git a/integrations/react/tsconfig.json b/integrations/react/tsconfig.json index 4ed7abc2b..1bb896f14 100644 --- a/integrations/react/tsconfig.json +++ b/integrations/react/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "./src", "outDir": "./dist" }, - "exclude": ["./dist"] + "exclude": ["./dist", "./src/**/*.spec.*"] } diff --git a/yarn.lock b/yarn.lock index 3df54152c..8951265b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5969,10 +5969,19 @@ __metadata: "@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" + "@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.23.0" esbuild-plugin-file-path-extensions: "npm:^2.1.2" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" react: "npm:^18.3.1" + react-dom: "npm:^18.3.1" react-fast-compare: "npm:^3.2.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.5.3" From 808f84102c61216f2253e68af0c422c4e370d1ce Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Mon, 30 Mar 2026 17:02:53 +0900 Subject: [PATCH 02/15] chore: add changeset for lifecyclePlugin Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/add-lifecycle-plugin.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/add-lifecycle-plugin.md diff --git a/.changeset/add-lifecycle-plugin.md b/.changeset/add-lifecycle-plugin.md new file mode 100644 index 000000000..1f649f591 --- /dev/null +++ b/.changeset/add-lifecycle-plugin.md @@ -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 From a416012ef2aab27dcd661510bc51f33e58ff5a0b Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 1 Apr 2026 19:11:15 +0900 Subject: [PATCH 03/15] chore(react): deprecate useActiveEffect in favor of useFocusEffect Co-Authored-By: Claude Opus 4.6 (1M context) --- integrations/react/src/stable/useActiveEffect.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/integrations/react/src/stable/useActiveEffect.ts b/integrations/react/src/stable/useActiveEffect.ts index 66c8bebba..4cd50faff 100644 --- a/integrations/react/src/stable/useActiveEffect.ts +++ b/integrations/react/src/stable/useActiveEffect.ts @@ -3,6 +3,11 @@ import { useEffect } from "react"; import { useActivity } from "../__internal__/activity/useActivity"; import { noop } from "../__internal__/utils"; +/** + * @deprecated Use `useFocusEffect` from `@stackflow/react/future` instead. + * `useFocusEffect` runs callbacks at the plugin level (outside React render cycle), + * avoiding `useDeferredValue` tearing issues. + */ export const useActiveEffect = (effect: React.EffectCallback) => { const { isActive } = useActivity(); From c20f6142b2c31e574018661cff2346c6f23dcdc1 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 10:47:07 +0900 Subject: [PATCH 04/15] refactor: address review feedback from ENvironmentSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove @deprecated from useActiveEffect — it serves a different use case (React-synced effects) than useFocusEffect (immediate external side-effects). Add JSDoc clarifying the distinction. 2. Move lifecyclePlugin to separate @stackflow/plugin-lifecycle package (opt-in, consistent with other plugins). 3. Add JSDoc to useFocusEffect warning against React setState in callbacks (runs outside React render cycle). 4. Add tests: cleanup exactly-once on pop, replace behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/add-lifecycle-plugin.md | 2 +- .pnp.cjs | 32 +++++++++ extensions/plugin-lifecycle/esbuild.config.js | 29 ++++++++ extensions/plugin-lifecycle/package.json | 71 +++++++++++++++++++ .../plugin-lifecycle/src}/index.ts | 0 .../src}/lifecyclePlugin.spec.tsx | 69 +++++++++++++++++- .../plugin-lifecycle/src}/lifecyclePlugin.tsx | 2 +- .../plugin-lifecycle/src}/runSafely.ts | 0 .../plugin-lifecycle/src}/useFocusEffect.ts | 18 ++++- extensions/plugin-lifecycle/tsconfig.json | 14 ++++ integrations/react/src/future/index.ts | 2 +- integrations/react/src/future/stackflow.tsx | 3 - .../react/src/stable/useActiveEffect.ts | 11 ++- yarn.lock | 29 ++++++++ 14 files changed, 268 insertions(+), 14 deletions(-) create mode 100644 extensions/plugin-lifecycle/esbuild.config.js create mode 100644 extensions/plugin-lifecycle/package.json rename {integrations/react/src/future/lifecycle => extensions/plugin-lifecycle/src}/index.ts (100%) rename {integrations/react/src/future/lifecycle => extensions/plugin-lifecycle/src}/lifecyclePlugin.spec.tsx (79%) rename {integrations/react/src/future/lifecycle => extensions/plugin-lifecycle/src}/lifecyclePlugin.tsx (96%) rename {integrations/react/src/future/lifecycle => extensions/plugin-lifecycle/src}/runSafely.ts (100%) rename {integrations/react/src/future/lifecycle => extensions/plugin-lifecycle/src}/useFocusEffect.ts (57%) create mode 100644 extensions/plugin-lifecycle/tsconfig.json diff --git a/.changeset/add-lifecycle-plugin.md b/.changeset/add-lifecycle-plugin.md index 1f649f591..f8eed0674 100644 --- a/.changeset/add-lifecycle-plugin.md +++ b/.changeset/add-lifecycle-plugin.md @@ -1,5 +1,5 @@ --- -"@stackflow/react": minor +"@stackflow/plugin-lifecycle": minor --- Add lifecyclePlugin and useFocusEffect hook for activity focus/blur lifecycle diff --git a/.pnp.cjs b/.pnp.cjs index 026749e75..9462ec4e5 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", "virtual:953894a7c789b2607a0624cdd2fe101ab646f7a48665d6c9514d70b8fe13e6bcc6f5184c4f01b3e7f4d1a067ba4fe9edb93885b270a0c2a5328c3aaac43dadf9#workspace:extensions/plugin-renderer-basic", "virtual:c41768bfd5ee324a3f9a83b254e0b7124a4fcca91fe506bc2a80d72fecb3083480f4644c952d19848553d28a66cb338c740989739f68c017ca74ef4340e9fea2#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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#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/integrations/react/src/future/lifecycle/index.ts b/extensions/plugin-lifecycle/src/index.ts similarity index 100% rename from integrations/react/src/future/lifecycle/index.ts rename to extensions/plugin-lifecycle/src/index.ts diff --git a/integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx similarity index 79% rename from integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx rename to extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx index 827f6d121..a2388d989 100644 --- a/integrations/react/src/future/lifecycle/lifecyclePlugin.spec.tsx +++ b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx @@ -1,9 +1,10 @@ import { defineConfig } from "@stackflow/config"; import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; +import type { StackflowReactPlugin } from "@stackflow/react"; +import { stackflow } from "@stackflow/react/future"; import { act, render } from "@testing-library/react"; import React, { useState } from "react"; -import type { StackflowReactPlugin } from "../../__internal__/StackflowReactPlugin"; -import { stackflow } from "../stackflow"; +import { lifecyclePlugin } from "./lifecyclePlugin"; import { useFocusEffect } from "./useFocusEffect"; declare module "@stackflow/config" { @@ -31,7 +32,7 @@ function setupStack({ return stackflow({ config, components: { ActivityA, ActivityB }, - plugins: [basicRendererPlugin(), ...extraPlugins], + plugins: [basicRendererPlugin(), lifecyclePlugin(), ...extraPlugins], }); } @@ -250,6 +251,68 @@ describe("lifecyclePlugin", () => { }); }); + 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(); diff --git a/integrations/react/src/future/lifecycle/lifecyclePlugin.tsx b/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx similarity index 96% rename from integrations/react/src/future/lifecycle/lifecyclePlugin.tsx rename to extensions/plugin-lifecycle/src/lifecyclePlugin.tsx index 39ab62199..ca62b6113 100644 --- a/integrations/react/src/future/lifecycle/lifecyclePlugin.tsx +++ b/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx @@ -1,4 +1,4 @@ -import type { StackflowReactPlugin } from "../../__internal__/StackflowReactPlugin"; +import type { StackflowReactPlugin } from "@stackflow/react"; import { createContext, createElement, useContext } from "react"; import { runSafely } from "./runSafely"; diff --git a/integrations/react/src/future/lifecycle/runSafely.ts b/extensions/plugin-lifecycle/src/runSafely.ts similarity index 100% rename from integrations/react/src/future/lifecycle/runSafely.ts rename to extensions/plugin-lifecycle/src/runSafely.ts diff --git a/integrations/react/src/future/lifecycle/useFocusEffect.ts b/extensions/plugin-lifecycle/src/useFocusEffect.ts similarity index 57% rename from integrations/react/src/future/lifecycle/useFocusEffect.ts rename to extensions/plugin-lifecycle/src/useFocusEffect.ts index fb7071d00..06be12863 100644 --- a/integrations/react/src/future/lifecycle/useFocusEffect.ts +++ b/extensions/plugin-lifecycle/src/useFocusEffect.ts @@ -1,9 +1,23 @@ +import { useActivity } from "@stackflow/react"; import { useEffect, useRef } from "react"; - -import { useActivity } from "../../__internal__/activity/useActivity"; 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) or unmount. + * + * 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 { 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 30ac119cc..45b7636f0 100644 --- a/integrations/react/src/future/index.ts +++ b/integrations/react/src/future/index.ts @@ -17,4 +17,4 @@ export * from "./useConfig"; export * from "./useFlow"; export * from "./usePrepare"; export * from "./useStepFlow"; -export { useFocusEffect } from "./lifecycle"; + diff --git a/integrations/react/src/future/stackflow.tsx b/integrations/react/src/future/stackflow.tsx index c4f1e196a..02bf8a5d1 100644 --- a/integrations/react/src/future/stackflow.tsx +++ b/integrations/react/src/future/stackflow.tsx @@ -22,7 +22,6 @@ import { isBrowser, makeRef } from "../__internal__/utils"; import type { StackflowReactPlugin } from "../stable"; import type { Actions } from "./Actions"; import { ConfigProvider } from "./ConfigProvider"; -import { lifecyclePlugin } from "./lifecycle"; import { DataLoaderProvider, loaderPlugin } from "./loader"; import { makeActions } from "./makeActions"; import { makeStepActions } from "./makeStepActions"; @@ -75,8 +74,6 @@ export function stackflow< return loaderData; }; const plugins = [ - lifecyclePlugin(), - ...(input.plugins ?? []) .flat(Number.POSITIVE_INFINITY as 0) .map((p) => p as StackflowReactPlugin), diff --git a/integrations/react/src/stable/useActiveEffect.ts b/integrations/react/src/stable/useActiveEffect.ts index 4cd50faff..a704d8117 100644 --- a/integrations/react/src/stable/useActiveEffect.ts +++ b/integrations/react/src/stable/useActiveEffect.ts @@ -4,9 +4,14 @@ import { useActivity } from "../__internal__/activity/useActivity"; import { noop } from "../__internal__/utils"; /** - * @deprecated Use `useFocusEffect` from `@stackflow/react/future` instead. - * `useFocusEffect` runs callbacks at the plugin level (outside React render cycle), - * avoiding `useDeferredValue` tearing issues. + * 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 7a33ab743..9d9bcb136 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" From 0ff034186abde09472e8ab6e737b591d093f7b14 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 12:11:13 +0900 Subject: [PATCH 05/15] fix(react): pass --passWithNoTests to jest after lifecycle tests moved out Co-Authored-By: Claude Opus 4.6 (1M context) --- integrations/react/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/react/package.json b/integrations/react/package.json index 530b7520f..31ea32a70 100644 --- a/integrations/react/package.json +++ b/integrations/react/package.json @@ -38,7 +38,7 @@ "build:js": "node ./esbuild.config.js", "clean": "rimraf dist", "dev": "yarn build:js --watch && yarn build:dts --watch", - "test": "yarn jest", + "test": "yarn jest --passWithNoTests", "typecheck": "tsc --noEmit" }, "jest": { From d35d30c636952b08717b4557374661f95fd1d21a Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 12:13:47 +0900 Subject: [PATCH 06/15] chore(react): remove unused test config and devDependencies Tests now live in @stackflow/plugin-lifecycle. Co-Authored-By: Claude Opus 4.6 (1M context) --- .pnp.cjs | 145 +++++--------------------------- integrations/react/package.json | 33 -------- yarn.lock | 9 -- 3 files changed, 21 insertions(+), 166 deletions(-) diff --git a/.pnp.cjs b/.pnp.cjs index 9462ec4e5..562efbae1 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -118,11 +118,11 @@ const RAW_RUNTIME_STATE = ["@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", "virtual:953894a7c789b2607a0624cdd2fe101ab646f7a48665d6c9514d70b8fe13e6bcc6f5184c4f01b3e7f4d1a067ba4fe9edb93885b270a0c2a5328c3aaac43dadf9#workspace:extensions/plugin-renderer-basic", "virtual:c41768bfd5ee324a3f9a83b254e0b7124a4fcca91fe506bc2a80d72fecb3083480f4644c952d19848553d28a66cb338c740989739f68c017ca74ef4340e9fea2#workspace:extensions/plugin-renderer-basic", "workspace:extensions/plugin-renderer-basic"]],\ + ["@stackflow/plugin-renderer-basic", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic", "workspace:extensions/plugin-renderer-basic"]],\ ["@stackflow/plugin-renderer-web", ["workspace:extensions/plugin-renderer-web"]],\ ["@stackflow/plugin-sentry", ["workspace:extensions/plugin-sentry"]],\ ["@stackflow/plugin-stack-depth-change", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-stack-depth-change", "workspace:extensions/plugin-stack-depth-change"]],\ - ["@stackflow/react", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react", "virtual:529c8a661b5417ff774bc95bf03f7325b4d0d04ab9b64e53f221a654e56c8deae5796080e692602e22c37f65861ea95cc75439afcc3a5ba2190d904c35fc9d04#workspace:integrations/react", "workspace:integrations/react"]],\ + ["@stackflow/react", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react", "workspace:integrations/react"]],\ ["@stackflow/react-ui-core", ["virtual:669046a185e83900af978519e5adddf8e8f1f8fed824849248ba56cf8fcd4e4208872f27e14c3c844d3b769f42be1ba6e0aa90f12df9fa6c38a55aedee211f53#workspace:extensions/react-ui-core", "workspace:extensions/react-ui-core"]]\ ],\ "fallbackPool": [\ @@ -6826,12 +6826,12 @@ const RAW_RUNTIME_STATE = ["@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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2"],\ + ["@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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#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"],\ @@ -6983,12 +6983,12 @@ const RAW_RUNTIME_STATE = ["@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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2"],\ + ["@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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#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"],\ @@ -7106,63 +7106,13 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:953894a7c789b2607a0624cdd2fe101ab646f7a48665d6c9514d70b8fe13e6bcc6f5184c4f01b3e7f4d1a067ba4fe9edb93885b270a0c2a5328c3aaac43dadf9#workspace:extensions/plugin-renderer-basic", {\ - "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-renderer-basic-virtual-bb2ef0b972/1/extensions/plugin-renderer-basic/",\ - "packageDependencies": [\ - ["@stackflow/plugin-renderer-basic", "virtual:953894a7c789b2607a0624cdd2fe101ab646f7a48665d6c9514d70b8fe13e6bcc6f5184c4f01b3e7f4d1a067ba4fe9edb93885b270a0c2a5328c3aaac43dadf9#workspace:extensions/plugin-renderer-basic"],\ - ["@stackflow/core", "workspace:core"],\ - ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/react", "workspace:integrations/react"],\ - ["@types/react", "npm:18.3.3"],\ - ["@types/stackflow__core", null],\ - ["@types/stackflow__react", null],\ - ["esbuild", "npm:0.23.0"],\ - ["react", "npm:18.3.1"],\ - ["rimraf", "npm:3.0.2"],\ - ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ - ],\ - "packagePeers": [\ - "@stackflow/core",\ - "@stackflow/react",\ - "@types/react",\ - "@types/stackflow__core",\ - "@types/stackflow__react",\ - "react"\ - ],\ - "linkType": "SOFT"\ - }],\ - ["virtual:c41768bfd5ee324a3f9a83b254e0b7124a4fcca91fe506bc2a80d72fecb3083480f4644c952d19848553d28a66cb338c740989739f68c017ca74ef4340e9fea2#workspace:extensions/plugin-renderer-basic", {\ - "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-renderer-basic-virtual-c766e9fd84/1/extensions/plugin-renderer-basic/",\ - "packageDependencies": [\ - ["@stackflow/plugin-renderer-basic", "virtual:c41768bfd5ee324a3f9a83b254e0b7124a4fcca91fe506bc2a80d72fecb3083480f4644c952d19848553d28a66cb338c740989739f68c017ca74ef4340e9fea2#workspace:extensions/plugin-renderer-basic"],\ - ["@stackflow/core", "workspace:core"],\ - ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/react", "virtual:529c8a661b5417ff774bc95bf03f7325b4d0d04ab9b64e53f221a654e56c8deae5796080e692602e22c37f65861ea95cc75439afcc3a5ba2190d904c35fc9d04#workspace:integrations/react"],\ - ["@types/react", "npm:18.3.3"],\ - ["@types/stackflow__core", null],\ - ["@types/stackflow__react", null],\ - ["esbuild", "npm:0.23.0"],\ - ["react", "npm:18.3.1"],\ - ["rimraf", "npm:3.0.2"],\ - ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ - ],\ - "packagePeers": [\ - "@stackflow/core",\ - "@stackflow/react",\ - "@types/react",\ - "@types/stackflow__core",\ - "@types/stackflow__react",\ - "react"\ - ],\ - "linkType": "SOFT"\ - }],\ ["workspace:extensions/plugin-renderer-basic", {\ "packageLocation": "./extensions/plugin-renderer-basic/",\ "packageDependencies": [\ ["@stackflow/plugin-renderer-basic", "workspace:extensions/plugin-renderer-basic"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/react", "virtual:529c8a661b5417ff774bc95bf03f7325b4d0d04ab9b64e53f221a654e56c8deae5796080e692602e22c37f65861ea95cc75439afcc3a5ba2190d904c35fc9d04#workspace:integrations/react"],\ + ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.23.0"],\ ["react", "npm:18.3.1"],\ @@ -7245,21 +7195,12 @@ const RAW_RUNTIME_STATE = ["@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"],\ - ["@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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2"],\ - ["@types/jest", "npm:29.5.12"],\ ["@types/react", "npm:18.3.3"],\ ["@types/stackflow__config", null],\ ["@types/stackflow__core", null],\ ["esbuild", "npm:0.23.0"],\ ["esbuild-plugin-file-path-extensions", "npm:2.1.3"],\ - ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ - ["jest-environment-jsdom", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:29.7.0"],\ ["react", "npm:18.3.1"],\ - ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ ["react-fast-compare", "npm:3.2.2"],\ ["rimraf", "npm:3.0.2"],\ ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ @@ -7274,41 +7215,6 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:529c8a661b5417ff774bc95bf03f7325b4d0d04ab9b64e53f221a654e56c8deae5796080e692602e22c37f65861ea95cc75439afcc3a5ba2190d904c35fc9d04#workspace:integrations/react", {\ - "packageLocation": "./.yarn/__virtual__/@stackflow-react-virtual-c41768bfd5/1/integrations/react/",\ - "packageDependencies": [\ - ["@stackflow/react", "virtual:529c8a661b5417ff774bc95bf03f7325b4d0d04ab9b64e53f221a654e56c8deae5796080e692602e22c37f65861ea95cc75439afcc3a5ba2190d904c35fc9d04#workspace:integrations/react"],\ - ["@stackflow/config", "workspace:config"],\ - ["@stackflow/core", "workspace:core"],\ - ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/plugin-renderer-basic", "virtual:c41768bfd5ee324a3f9a83b254e0b7124a4fcca91fe506bc2a80d72fecb3083480f4644c952d19848553d28a66cb338c740989739f68c017ca74ef4340e9fea2#workspace:extensions/plugin-renderer-basic"],\ - ["@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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2"],\ - ["@types/jest", "npm:29.5.12"],\ - ["@types/react", "npm:18.3.3"],\ - ["@types/stackflow__config", null],\ - ["@types/stackflow__core", null],\ - ["esbuild", "npm:0.23.0"],\ - ["esbuild-plugin-file-path-extensions", "npm:2.1.3"],\ - ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ - ["jest-environment-jsdom", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:29.7.0"],\ - ["react", "npm:18.3.1"],\ - ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ - ["react-fast-compare", "npm:3.2.2"],\ - ["rimraf", "npm:3.0.2"],\ - ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ - ],\ - "packagePeers": [\ - "@stackflow/core",\ - "@types/react",\ - "@types/stackflow__config",\ - "@types/stackflow__core",\ - "react"\ - ],\ - "linkType": "SOFT"\ - }],\ ["workspace:integrations/react", {\ "packageLocation": "./integrations/react/",\ "packageDependencies": [\ @@ -7316,19 +7222,10 @@ const RAW_RUNTIME_STATE = ["@stackflow/config", "workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/plugin-renderer-basic", "virtual:953894a7c789b2607a0624cdd2fe101ab646f7a48665d6c9514d70b8fe13e6bcc6f5184c4f01b3e7f4d1a067ba4fe9edb93885b270a0c2a5328c3aaac43dadf9#workspace:extensions/plugin-renderer-basic"],\ - ["@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:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2"],\ - ["@types/jest", "npm:29.5.12"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.23.0"],\ ["esbuild-plugin-file-path-extensions", "npm:2.1.3"],\ - ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ - ["jest-environment-jsdom", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:29.7.0"],\ ["react", "npm:18.3.1"],\ - ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ ["react-fast-compare", "npm:3.2.2"],\ ["rimraf", "npm:3.0.2"],\ ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ @@ -7618,10 +7515,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2", {\ - "packageLocation": "./.yarn/__virtual__/@testing-library-react-virtual-9ad9598c0b/0/cache/@testing-library-react-npm-16.3.2-67b0b894c8-0ca88c6f67.zip/node_modules/@testing-library/react/",\ + ["virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:16.3.2", {\ + "packageLocation": "./.yarn/__virtual__/@testing-library-react-virtual-f767e7b05a/0/cache/@testing-library-react-npm-16.3.2-67b0b894c8-0ca88c6f67.zip/node_modules/@testing-library/react/",\ "packageDependencies": [\ - ["@testing-library/react", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:16.3.2"],\ + ["@testing-library/react", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:16.3.2"],\ ["@babel/runtime", "npm:7.25.0"],\ ["@testing-library/dom", "npm:10.4.1"],\ ["@types/react", "npm:18.3.3"],\ @@ -13138,10 +13035,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:29.7.0", {\ - "packageLocation": "./.yarn/__virtual__/jest-environment-jsdom-virtual-6ddc26222e/0/cache/jest-environment-jsdom-npm-29.7.0-0b72dd0e0b-23bbfc9bca.zip/node_modules/jest-environment-jsdom/",\ + ["virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:29.7.0", {\ + "packageLocation": "./.yarn/__virtual__/jest-environment-jsdom-virtual-03ba513b4a/0/cache/jest-environment-jsdom-npm-29.7.0-0b72dd0e0b-23bbfc9bca.zip/node_modules/jest-environment-jsdom/",\ "packageDependencies": [\ - ["jest-environment-jsdom", "virtual:eeae00ab9cdb4d807ead707d69a05cf69e4e15f4e94e6427b60f23763785847dffc9899221c63493fb7012f64c6d1ead2deba588072b0b1635942f995a9b7033#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:29.7.0"],\ ["@jest/environment", "npm:29.7.0"],\ ["@jest/fake-timers", "npm:29.7.0"],\ ["@jest/types", "npm:29.6.3"],\ @@ -13151,7 +13048,7 @@ const RAW_RUNTIME_STATE = ["canvas", null],\ ["jest-mock", "npm:29.7.0"],\ ["jest-util", "npm:29.7.0"],\ - ["jsdom", "virtual:6ddc26222e8aaaf60dbe5079fb179f32c850d26f7d5eed4ee3b5f965c379e18837e42bec2d87cd2beb06ebc9c2dad24e31837cd2f985431c5ebd8bb747b0202c#npm:20.0.3"]\ + ["jsdom", "virtual:03ba513b4a4f2f49a0ee779e0b1da3ef4f41cbf0cff4a27f151a6c11d5162aae67852dc5c3f387d71c020640c3547cdf783b461f72a6ebbd7907fd3300ce6913#npm:20.0.3"]\ ],\ "packagePeers": [\ "@types/canvas",\ @@ -13514,10 +13411,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:6ddc26222e8aaaf60dbe5079fb179f32c850d26f7d5eed4ee3b5f965c379e18837e42bec2d87cd2beb06ebc9c2dad24e31837cd2f985431c5ebd8bb747b0202c#npm:20.0.3", {\ - "packageLocation": "./.yarn/__virtual__/jsdom-virtual-5cf75f356c/0/cache/jsdom-npm-20.0.3-906a2f7005-a4cdcff5b0.zip/node_modules/jsdom/",\ + ["virtual:03ba513b4a4f2f49a0ee779e0b1da3ef4f41cbf0cff4a27f151a6c11d5162aae67852dc5c3f387d71c020640c3547cdf783b461f72a6ebbd7907fd3300ce6913#npm:20.0.3", {\ + "packageLocation": "./.yarn/__virtual__/jsdom-virtual-09fbede01d/0/cache/jsdom-npm-20.0.3-906a2f7005-a4cdcff5b0.zip/node_modules/jsdom/",\ "packageDependencies": [\ - ["jsdom", "virtual:6ddc26222e8aaaf60dbe5079fb179f32c850d26f7d5eed4ee3b5f965c379e18837e42bec2d87cd2beb06ebc9c2dad24e31837cd2f985431c5ebd8bb747b0202c#npm:20.0.3"],\ + ["jsdom", "virtual:03ba513b4a4f2f49a0ee779e0b1da3ef4f41cbf0cff4a27f151a6c11d5162aae67852dc5c3f387d71c020640c3547cdf783b461f72a6ebbd7907fd3300ce6913#npm:20.0.3"],\ ["@types/canvas", null],\ ["abab", "npm:2.0.6"],\ ["acorn", "npm:8.16.0"],\ @@ -13544,7 +13441,7 @@ const RAW_RUNTIME_STATE = ["whatwg-encoding", "npm:2.0.0"],\ ["whatwg-mimetype", "npm:3.0.0"],\ ["whatwg-url", "npm:11.0.0"],\ - ["ws", "virtual:5cf75f356cf180b5d5b0ad15d0e36a731a029955ec8d46ac7c30b389a1d2bf7c50182cc3af071d7a8b19953f6ccd0f184053bdbb7dc9085bb1a85e597b796d4d#npm:8.19.0"],\ + ["ws", "virtual:09fbede01d752e610be1714c18909368fe4fa709b16e76a5ed8cde05b6dbb3342f037902ae401113b5bbbb44b9753fbd2ba83c3277f1f798491ade558971e25f#npm:8.19.0"],\ ["xml-name-validator", "npm:4.0.0"]\ ],\ "packagePeers": [\ @@ -19565,10 +19462,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:5cf75f356cf180b5d5b0ad15d0e36a731a029955ec8d46ac7c30b389a1d2bf7c50182cc3af071d7a8b19953f6ccd0f184053bdbb7dc9085bb1a85e597b796d4d#npm:8.19.0", {\ - "packageLocation": "./.yarn/__virtual__/ws-virtual-28f7343cc1/0/cache/ws-npm-8.19.0-c967c046a5-26e4901e93.zip/node_modules/ws/",\ + ["virtual:09fbede01d752e610be1714c18909368fe4fa709b16e76a5ed8cde05b6dbb3342f037902ae401113b5bbbb44b9753fbd2ba83c3277f1f798491ade558971e25f#npm:8.19.0", {\ + "packageLocation": "./.yarn/__virtual__/ws-virtual-99b0ff26e3/0/cache/ws-npm-8.19.0-c967c046a5-26e4901e93.zip/node_modules/ws/",\ "packageDependencies": [\ - ["ws", "virtual:5cf75f356cf180b5d5b0ad15d0e36a731a029955ec8d46ac7c30b389a1d2bf7c50182cc3af071d7a8b19953f6ccd0f184053bdbb7dc9085bb1a85e597b796d4d#npm:8.19.0"],\ + ["ws", "virtual:09fbede01d752e610be1714c18909368fe4fa709b16e76a5ed8cde05b6dbb3342f037902ae401113b5bbbb44b9753fbd2ba83c3277f1f798491ade558971e25f#npm:8.19.0"],\ ["@types/bufferutil", null],\ ["@types/utf-8-validate", null],\ ["bufferutil", null],\ diff --git a/integrations/react/package.json b/integrations/react/package.json index 31ea32a70..90a6473bd 100644 --- a/integrations/react/package.json +++ b/integrations/react/package.json @@ -38,32 +38,8 @@ "build:js": "node ./esbuild.config.js", "clean": "rimraf dist", "dev": "yarn build:js --watch && yarn build:dts --watch", - "test": "yarn jest --passWithNoTests", "typecheck": "tsc --noEmit" }, - "jest": { - "testEnvironment": "jsdom", - "roots": [ - "/src" - ], - "coveragePathIgnorePatterns": [ - "index.ts" - ], - "transform": { - "^.+\\.(t|j)sx?$": [ - "@swc/jest", - { - "jsc": { - "transform": { - "react": { - "runtime": "automatic" - } - } - } - } - ] - } - }, "dependencies": { "react-fast-compare": "^3.2.2" }, @@ -71,19 +47,10 @@ "@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" }, diff --git a/yarn.lock b/yarn.lock index 9d9bcb136..7ed3caca3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6073,19 +6073,10 @@ __metadata: "@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" - "@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.23.0" esbuild-plugin-file-path-extensions: "npm:^2.1.2" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" react: "npm:^18.3.1" - react-dom: "npm:^18.3.1" react-fast-compare: "npm:^3.2.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.5.3" From e936a8a646454b81e737319c15af389eb166b721 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 14:36:38 +0900 Subject: [PATCH 07/15] feat(plugin-lifecycle): re-run effect on callback change (React Navigation pattern) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the callback reference changes while focused, cleanup the old effect and immediately run the new one. Users wrap callbacks in useCallback to control when this happens. This matches React Navigation's useFocusEffect behavior where deps changes trigger cleanup→re-run even while the screen is focused. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lifecyclePlugin.spec.tsx | 60 ++++++++++++++++--- .../plugin-lifecycle/src/useFocusEffect.ts | 28 ++++++--- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx index a2388d989..47671a5a8 100644 --- a/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx +++ b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx @@ -3,7 +3,7 @@ import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; import type { StackflowReactPlugin } from "@stackflow/react"; import { stackflow } from "@stackflow/react/future"; import { act, render } from "@testing-library/react"; -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import { lifecyclePlugin } from "./lifecyclePlugin"; import { useFocusEffect } from "./useFocusEffect"; @@ -206,7 +206,47 @@ describe("lifecyclePlugin", () => { }); }); - describe("callbackRef pattern", () => { + 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(); @@ -216,7 +256,11 @@ describe("lifecyclePlugin", () => { const [useSecond, _setUseSecond] = useState(false); setUseSecond = _setUseSecond; - useFocusEffect(useSecond ? secondEffect : firstEffect); + useFocusEffect( + useCallback(() => { + return useSecond ? secondEffect() : firstEffect(); + }, [useSecond]), + ); return
A
; } function ActivityB() { @@ -232,22 +276,24 @@ describe("lifecyclePlugin", () => { expect(firstEffect).toHaveBeenCalledTimes(1); expect(secondEffect).not.toHaveBeenCalled(); - // Update callback while A is active + // Update dep while A is active → cleanup→re-run await act(async () => { setUseSecond(true); }); - // Push B → A blurs + expect(secondEffect).toHaveBeenCalledTimes(1); + + // Push B → blur cleanup await act(async () => { actions.push("ActivityB", {}); }); - // Pop B → A refocuses → should use secondEffect + // Pop B → refocus → secondEffect again await act(async () => { actions.pop(); }); - expect(secondEffect).toHaveBeenCalledTimes(1); + expect(secondEffect).toHaveBeenCalledTimes(2); }); }); diff --git a/extensions/plugin-lifecycle/src/useFocusEffect.ts b/extensions/plugin-lifecycle/src/useFocusEffect.ts index 06be12863..b6d7337c3 100644 --- a/extensions/plugin-lifecycle/src/useFocusEffect.ts +++ b/extensions/plugin-lifecycle/src/useFocusEffect.ts @@ -5,7 +5,19 @@ 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) or unmount. + * 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 @@ -25,10 +37,7 @@ export function useFocusEffect( const activity = useActivity(); const idRef = useRef(Symbol()); const callbackRef = useRef(callback); - - useEffect(() => { - callbackRef.current = callback; - }); + callbackRef.current = callback; useEffect(() => { const id = idRef.current; @@ -39,8 +48,8 @@ export function useFocusEffect( callbackRef, }); - // Initial focus: if activity is already active, run effect immediately. - // activity.isActive is intentionally not in deps — onChanged handles subsequent transitions. + // 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); @@ -52,5 +61,8 @@ export function useFocusEffect( store.cleanups.delete(id); store.entries.delete(id); }; - }, [store, activity.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]); } From bed456fee961cc0e22fa5467ed963de971abd7ad Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 14:43:15 +0900 Subject: [PATCH 08/15] chore(plugin-lifecycle): add comment explaining render-phase ref write Co-Authored-By: Claude Opus 4.6 (1M context) --- extensions/plugin-lifecycle/src/useFocusEffect.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extensions/plugin-lifecycle/src/useFocusEffect.ts b/extensions/plugin-lifecycle/src/useFocusEffect.ts index b6d7337c3..40893eeb0 100644 --- a/extensions/plugin-lifecycle/src/useFocusEffect.ts +++ b/extensions/plugin-lifecycle/src/useFocusEffect.ts @@ -37,6 +37,9 @@ export function useFocusEffect( const activity = useActivity(); const idRef = useRef(Symbol()); const callbackRef = useRef(callback); + // Render-phase ref write: ensures onChanged always reads the latest callback, + // even if it fires synchronously before useEffect commits. This is an + // idempotent assignment (safe under Strict Mode double-render). callbackRef.current = callback; useEffect(() => { From 67f4137cd1d03fc8ea7e36454d1d5b76b17acce8 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 15:05:25 +0900 Subject: [PATCH 09/15] fix(plugin-lifecycle): add reentrancy guard to onChanged If a focus/blur callback triggers navigation (push/pop/replace), onChanged fires synchronously again. Without a guard this corrupts the blur/focus iteration loop. Add a processing flag + pendingTransition queue so reentrant calls are deferred and drained after the current transition completes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plugin-lifecycle/src/lifecyclePlugin.tsx | 72 ++++++++++++++----- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx b/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx index ca62b6113..88696c1d4 100644 --- a/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx +++ b/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx @@ -8,10 +8,17 @@ type FocusEffectEntry = { 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; + pendingTransition: PendingTransition | null; }; const LifecycleStoreContext = createContext(null); @@ -26,11 +33,40 @@ export function useLifecycleStore(): LifecycleStore { 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, + pendingTransition: null, }; return () => ({ @@ -62,25 +98,29 @@ export function lifecyclePlugin(): StackflowReactPlugin { const prevActiveId = store.prevActiveActivityId; store.prevActiveActivityId = currentActiveId; - // 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); - } - } + // Reentrancy guard: if a callback triggers navigation (push/pop/replace), + // onChanged fires synchronously again. Defer to avoid corrupted iteration. + if (store.processing) { + store.pendingTransition = { prevActiveId, currentActiveId }; + return; } - // 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); - } + store.processing = true; + try { + processTransition(store, prevActiveId, currentActiveId); + + // Drain queued transitions from reentrant onChanged calls + while (store.pendingTransition !== null) { + const pending = store.pendingTransition; + store.pendingTransition = null; + processTransition( + store, + pending.prevActiveId, + pending.currentActiveId, + ); } + } finally { + store.processing = false; } }, }); From 138e32b7cabcd81b211e6ed070954ac81a9e85b7 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 15:06:52 +0900 Subject: [PATCH 10/15] test(plugin-lifecycle): add reentrancy test Verify that navigation inside a focus callback (e.g. redirect on focus) doesn't corrupt blur/focus state. The reentrancy guard defers the inner transition and drains it after the outer completes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lifecyclePlugin.spec.tsx | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx index 47671a5a8..f378d5392 100644 --- a/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx +++ b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx @@ -1,7 +1,7 @@ import { defineConfig } from "@stackflow/config"; import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; import type { StackflowReactPlugin } from "@stackflow/react"; -import { stackflow } from "@stackflow/react/future"; +import { stackflow, useFlow } from "@stackflow/react/future"; import { act, render } from "@testing-library/react"; import React, { useCallback, useState } from "react"; import { lifecyclePlugin } from "./lifecyclePlugin"; @@ -11,6 +11,7 @@ declare module "@stackflow/config" { interface Register { ActivityA: {}; ActivityB: {}; + ActivityC: {}; } } @@ -395,4 +396,68 @@ describe("lifecyclePlugin", () => { expect(cleanupB).toHaveBeenCalledTimes(1); }); }); + + describe("reentrancy", () => { + it("handles navigation inside focus callback without corrupting state", async () => { + const cleanupA = jest.fn(); + const effectC = jest.fn(); + + function ActivityA() { + useFocusEffect(() => cleanupA); + return
A
; + } + function ActivityB() { + const { replace } = useFlow(); + + // On focus, immediately redirect to C + useFocusEffect( + useCallback(() => { + replace("ActivityC", {}); + }, []), + ); + return
B
; + } + function ActivityC() { + useFocusEffect(effectC); + 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(); + }); + + // Push B → B focuses → B's callback triggers replace to C + await act(async () => { + actions.push("ActivityB" as any, {}); + }); + + // A's cleanup should have run (A blurred when B was pushed) + expect(cleanupA).toHaveBeenCalled(); + + // C should eventually get focused + await act(async () => {}); + + expect(effectC).toHaveBeenCalled(); + }); + }); }); From 4f32f6b6f72a399bd92a7372f4c14c7bd4115564 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 15:12:37 +0900 Subject: [PATCH 11/15] test(plugin-lifecycle): fix reentrancy test to use onChanged path The previous test triggered navigation from initial focus (useEffect path), which doesn't cause reentrancy. Changed to trigger navigation from refocus callback (onChanged path), which is synchronous and actually causes reentrant onChanged calls. Verified: test fails without the reentrancy guard, passes with it. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lifecyclePlugin.spec.tsx | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx index f378d5392..94a1b01fd 100644 --- a/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx +++ b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx @@ -398,27 +398,39 @@ describe("lifecyclePlugin", () => { }); describe("reentrancy", () => { - it("handles navigation inside focus callback without corrupting state", async () => { + it("handles navigation inside refocus callback (onChanged path) without crash", async () => { + // Scenario: A refocuses via pop B → A's onChanged callback pushes C + // This triggers dispatchEvent → onChanged reentrantly from within onChanged. const cleanupA = jest.fn(); - const effectC = jest.fn(); + let pushC: (() => void) | null = null; function ActivityA() { - useFocusEffect(() => cleanupA); - return
A
; - } - function ActivityB() { - const { replace } = useFlow(); + const { push } = useFlow(); - // On focus, immediately redirect to C useFocusEffect( useCallback(() => { - replace("ActivityC", {}); + // On refocus, push C (triggers reentrant onChanged) + if (pushC) { + const fn = pushC; + pushC = null; + fn(); + } + return cleanupA; }, []), ); + + // Expose push for arming + pushC = null; + React.useEffect(() => { + // We'll arm pushC externally before pop + }, []); + + return
A
; + } + function ActivityB() { return
B
; } function ActivityC() { - useFocusEffect(effectC); return
C
; } @@ -446,18 +458,25 @@ describe("lifecyclePlugin", () => { render(); }); - // Push B → B focuses → B's callback triggers replace to C + // Push B → A blurs await act(async () => { actions.push("ActivityB" as any, {}); }); - // A's cleanup should have run (A blurred when B was pushed) - expect(cleanupA).toHaveBeenCalled(); + expect(cleanupA).toHaveBeenCalledTimes(1); + + // Arm: when A refocuses via onChanged, push C + pushC = () => actions.push("ActivityC" as any, {}); - // C should eventually get focused - await act(async () => {}); + // Pop B → A refocuses (onChanged) → callback pushes C (reentrant onChanged) + // Should not crash or corrupt state + await act(async () => { + actions.pop(); + }); - expect(effectC).toHaveBeenCalled(); + // A's refocus callback ran and triggered push C. + // A should have been blurred again (cleanup 2nd time) due to C being pushed. + expect(cleanupA).toHaveBeenCalledTimes(2); }); }); }); From 05c219ed15192bcec6ca5aed4241064f54e4d953 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 15:17:04 +0900 Subject: [PATCH 12/15] test(plugin-lifecycle): use callLog for reentrancy order verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace count-based assertion with callLog pattern (following blockerPlugin's test style). Verifies that: - A:focus completes fully (start→end) before A:cleanup runs - Without guard: A:cleanup is missing (cleanup not in store when reentrant blur runs) - With guard: pending transition drains after focus, cleanup runs Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lifecyclePlugin.spec.tsx | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx index 94a1b01fd..2d24f5801 100644 --- a/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx +++ b/extensions/plugin-lifecycle/src/lifecyclePlugin.spec.tsx @@ -398,33 +398,29 @@ describe("lifecyclePlugin", () => { }); describe("reentrancy", () => { - it("handles navigation inside refocus callback (onChanged path) without crash", async () => { - // Scenario: A refocuses via pop B → A's onChanged callback pushes C - // This triggers dispatchEvent → onChanged reentrantly from within onChanged. - const cleanupA = jest.fn(); + 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() { - const { push } = useFlow(); - useFocusEffect( useCallback(() => { - // On refocus, push C (triggers reentrant onChanged) + callLog.push("A:focus:start"); if (pushC) { const fn = pushC; pushC = null; fn(); + // If reentrant blur ran synchronously, "A:cleanup" would appear here } - return cleanupA; + callLog.push("A:focus:end"); + return () => { + callLog.push("A:cleanup"); + }; }, []), ); - - // Expose push for arming - pushC = null; - React.useEffect(() => { - // We'll arm pushC externally before pop - }, []); - return
A
; } function ActivityB() { @@ -458,25 +454,33 @@ describe("lifecyclePlugin", () => { 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(cleanupA).toHaveBeenCalledTimes(1); + expect(callLog).toEqual(["A:cleanup"]); + callLog.length = 0; - // Arm: when A refocuses via onChanged, push C + // Arm: when A refocuses, push C pushC = () => actions.push("ActivityC" as any, {}); - // Pop B → A refocuses (onChanged) → callback pushes C (reentrant onChanged) - // Should not crash or corrupt state + // Pop B → A refocuses → callback pushes C (reentrant) → deferred blur await act(async () => { actions.pop(); }); - // A's refocus callback ran and triggered push C. - // A should have been blurred again (cleanup 2nd time) due to C being pushed. - expect(cleanupA).toHaveBeenCalledTimes(2); + // 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", + ]); }); }); }); From a81e3eb773928d4093ec428c2cd7526224c86b5f Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 15:50:42 +0900 Subject: [PATCH 13/15] fix(plugin-lifecycle): move callbackRef update to useEffect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render-phase ref writes are unsafe under Concurrent Mode — if a render is interrupted/discarded, the uncommitted callback stays in the ref. onChanged (running outside React) could then execute a callback from a discarded render, causing tearing. Moving to useEffect means onChanged may briefly read a stale (but committed) callback between render and effect flush. This is preferable to reading a callback from a discarded render. Co-Authored-By: Claude Opus 4.6 (1M context) --- extensions/plugin-lifecycle/src/useFocusEffect.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/plugin-lifecycle/src/useFocusEffect.ts b/extensions/plugin-lifecycle/src/useFocusEffect.ts index 40893eeb0..d922341e3 100644 --- a/extensions/plugin-lifecycle/src/useFocusEffect.ts +++ b/extensions/plugin-lifecycle/src/useFocusEffect.ts @@ -37,10 +37,10 @@ export function useFocusEffect( const activity = useActivity(); const idRef = useRef(Symbol()); const callbackRef = useRef(callback); - // Render-phase ref write: ensures onChanged always reads the latest callback, - // even if it fires synchronously before useEffect commits. This is an - // idempotent assignment (safe under Strict Mode double-render). - callbackRef.current = callback; + + useEffect(() => { + callbackRef.current = callback; + }); useEffect(() => { const id = idRef.current; From 88b14e1f544ac083b446f250ecd847b34197f919 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 17:09:21 +0900 Subject: [PATCH 14/15] fix(plugin-lifecycle): use array for pending transitions Single pendingTransition slot gets overwritten when multiple entries trigger navigation during the same processTransition. Switch to an array so all reentrant transitions are queued and drained in order. Co-Authored-By: Claude Opus 4.6 (1M context) --- extensions/plugin-lifecycle/src/lifecyclePlugin.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx b/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx index 88696c1d4..3a255c93b 100644 --- a/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx +++ b/extensions/plugin-lifecycle/src/lifecyclePlugin.tsx @@ -18,7 +18,7 @@ type LifecycleStore = { cleanups: Map void) | void>; prevActiveActivityId: string | null; processing: boolean; - pendingTransition: PendingTransition | null; + pendingTransitions: PendingTransition[]; }; const LifecycleStoreContext = createContext(null); @@ -66,7 +66,7 @@ export function lifecyclePlugin(): StackflowReactPlugin { cleanups: new Map(), prevActiveActivityId: null, processing: false, - pendingTransition: null, + pendingTransitions: [], }; return () => ({ @@ -101,7 +101,7 @@ export function lifecyclePlugin(): StackflowReactPlugin { // Reentrancy guard: if a callback triggers navigation (push/pop/replace), // onChanged fires synchronously again. Defer to avoid corrupted iteration. if (store.processing) { - store.pendingTransition = { prevActiveId, currentActiveId }; + store.pendingTransitions.push({ prevActiveId, currentActiveId }); return; } @@ -110,9 +110,8 @@ export function lifecyclePlugin(): StackflowReactPlugin { processTransition(store, prevActiveId, currentActiveId); // Drain queued transitions from reentrant onChanged calls - while (store.pendingTransition !== null) { - const pending = store.pendingTransition; - store.pendingTransition = null; + while (store.pendingTransitions.length > 0) { + const pending = store.pendingTransitions.shift()!; processTransition( store, pending.prevActiveId, From 2cd9de567204d29a02e2534f76dd73413b242a6d Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Wed, 8 Apr 2026 17:32:46 +0900 Subject: [PATCH 15/15] chore(react): revert esbuild.config.js and tsconfig.json to main These changes were only needed when lifecycle tests lived in @stackflow/react. Now that the plugin is a separate package, the original config is sufficient. Co-Authored-By: Claude Opus 4.6 (1M context) --- integrations/react/esbuild.config.js | 10 ++-------- integrations/react/tsconfig.json | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/integrations/react/esbuild.config.js b/integrations/react/esbuild.config.js index 1be9b37ed..17749d586 100644 --- a/integrations/react/esbuild.config.js +++ b/integrations/react/esbuild.config.js @@ -1,5 +1,3 @@ -const { readdirSync, statSync } = require("fs"); -const { join } = require("path"); const { context } = require("esbuild"); const config = require("@stackflow/esbuild-config"); const { @@ -14,14 +12,10 @@ 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, + entryPoints: ["./src/**/*"], outdir: "dist", }), bundle: false, @@ -33,7 +27,7 @@ Promise.all([ ), context({ ...config({ - entryPoints, + entryPoints: ["./src/**/*"], outdir: "dist", }), bundle: true, diff --git a/integrations/react/tsconfig.json b/integrations/react/tsconfig.json index 1bb896f14..4ed7abc2b 100644 --- a/integrations/react/tsconfig.json +++ b/integrations/react/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "./src", "outDir": "./dist" }, - "exclude": ["./dist", "./src/**/*.spec.*"] + "exclude": ["./dist"] }