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 diff --git a/.pnp.cjs b/.pnp.cjs index a90d2d9b3..026749e75 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -113,11 +113,11 @@ 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-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", "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": [\ @@ -6821,12 +6821,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"],\ @@ -7074,13 +7074,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"],\ @@ -7163,12 +7213,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"]\ @@ -7183,6 +7242,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": [\ @@ -7190,10 +7284,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"]\ @@ -7483,10 +7586,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"],\ @@ -13003,10 +13106,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"],\ @@ -13016,7 +13119,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",\ @@ -13379,10 +13482,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"],\ @@ -13409,7 +13512,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": [\ @@ -19430,10 +19533,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/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(); 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 76227a6ab..7a33ab743 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6044,10 +6044,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"