From bd1776b745a5923d74b653067873e279952417e2 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Mon, 30 Mar 2026 16:52:14 +0900 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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();