diff --git a/.pnp.cjs b/.pnp.cjs index 6a194ab38..ea7f744ad 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -30,6 +30,10 @@ const RAW_RUNTIME_STATE = "name": "@stackflow/docs",\ "reference": "workspace:docs"\ },\ + {\ + "name": "@stackflow/e2e-history-sync-blocker",\ + "reference": "workspace:e2e"\ + },\ {\ "name": "@stackflow/compat-await-push",\ "reference": "workspace:extensions/compat-await-push"\ @@ -99,11 +103,12 @@ const RAW_RUNTIME_STATE = ["@stackflow/core", ["workspace:core"]],\ ["@stackflow/demo", ["workspace:demo"]],\ ["@stackflow/docs", ["workspace:docs"]],\ + ["@stackflow/e2e-history-sync-blocker", ["workspace:e2e"]],\ ["@stackflow/esbuild-config", ["workspace:packages/esbuild-config"]],\ ["@stackflow/link", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/link", "workspace:extensions/link"]],\ ["@stackflow/monorepo", ["workspace:."]],\ ["@stackflow/plugin-basic-ui", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-basic-ui", "workspace:extensions/plugin-basic-ui"]],\ - ["@stackflow/plugin-blocker", ["workspace:extensions/plugin-blocker"]],\ + ["@stackflow/plugin-blocker", ["virtual:137001fbbe4c5a64f486aea865a749a9274730020c67c12e787ea96ab5c851a2d27143516452e8364746285d6be9344ffa47021731ca9521366c4a691a3d8765#workspace:extensions/plugin-blocker", "workspace:extensions/plugin-blocker"]],\ ["@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"]],\ @@ -5219,6 +5224,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@playwright/test", [\ + ["npm:1.61.1", {\ + "packageLocation": "./.yarn/cache/@playwright-test-npm-1.61.1-8d56fe0fbb-0891535703.zip/node_modules/@playwright/test/",\ + "packageDependencies": [\ + ["@playwright/test", "npm:1.61.1"],\ + ["playwright", "npm:1.61.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@popperjs/core", [\ ["npm:2.11.8", {\ "packageLocation": "./.yarn/cache/@popperjs-core-npm-2.11.8-f1692e11a0-ddd16090cd.zip/node_modules/@popperjs/core/",\ @@ -6635,6 +6650,39 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@stackflow/e2e-history-sync-blocker", [\ + ["workspace:e2e", {\ + "packageLocation": "./e2e/",\ + "packageDependencies": [\ + ["@stackflow/e2e-history-sync-blocker", "workspace:e2e"],\ + ["@playwright/test", "npm:1.61.1"],\ + ["@stackflow/config", "workspace:config"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/plugin-blocker", "virtual:137001fbbe4c5a64f486aea865a749a9274730020c67c12e787ea96ab5c851a2d27143516452e8364746285d6be9344ffa47021731ca9521366c4a691a3d8765#workspace:extensions/plugin-blocker"],\ + ["@stackflow/plugin-history-sync", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync"],\ + ["@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:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:16.3.2"],\ + ["@types/jest", "npm:29.5.12"],\ + ["@types/node", "npm:20.14.9"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/react-dom", "npm:18.3.0"],\ + ["@vitejs/plugin-react", "virtual:137001fbbe4c5a64f486aea865a749a9274730020c67c12e787ea96ab5c851a2d27143516452e8364746285d6be9344ffa47021731ca9521366c4a691a3d8765#npm:4.3.1"],\ + ["history", "npm:5.3.0"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:29.7.0"],\ + ["react", "npm:18.3.1"],\ + ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ + ["rimraf", "npm:3.0.2"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"],\ + ["vite", "virtual:137001fbbe4c5a64f486aea865a749a9274730020c67c12e787ea96ab5c851a2d27143516452e8364746285d6be9344ffa47021731ca9521366c4a691a3d8765#npm:5.3.2"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@stackflow/esbuild-config", [\ ["workspace:packages/esbuild-config", {\ "packageLocation": "./packages/esbuild-config/",\ @@ -6767,6 +6815,41 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@stackflow/plugin-blocker", [\ + ["virtual:137001fbbe4c5a64f486aea865a749a9274730020c67c12e787ea96ab5c851a2d27143516452e8364746285d6be9344ffa47021731ca9521366c4a691a3d8765#workspace:extensions/plugin-blocker", {\ + "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-blocker-virtual-4894bb3700/1/extensions/plugin-blocker/",\ + "packageDependencies": [\ + ["@stackflow/plugin-blocker", "virtual:137001fbbe4c5a64f486aea865a749a9274730020c67c12e787ea96ab5c851a2d27143516452e8364746285d6be9344ffa47021731ca9521366c4a691a3d8765#workspace:extensions/plugin-blocker"],\ + ["@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:4894bb3700d40298c0b780832469c36744bf157b3f4f7f0c0b6cdbc25cc1e89aadad803ae2cfa1e7a9fae9283a7054b91fcd68497fbc45b02fd592e718c8f1dc#npm:16.3.2"],\ + ["@types/jest", "npm:29.5.12"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/stackflow__core", null],\ + ["@types/stackflow__react", null],\ + ["esbuild", "npm:0.27.3"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#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"]\ + ],\ + "packagePeers": [\ + "@stackflow/core",\ + "@stackflow/react",\ + "@types/react",\ + "@types/stackflow__core",\ + "@types/stackflow__react",\ + "react"\ + ],\ + "linkType": "SOFT"\ + }],\ ["workspace:extensions/plugin-blocker", {\ "packageLocation": "./extensions/plugin-blocker/",\ "packageDependencies": [\ @@ -6779,7 +6862,7 @@ 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:4894bb3700d40298c0b780832469c36744bf157b3f4f7f0c0b6cdbc25cc1e89aadad803ae2cfa1e7a9fae9283a7054b91fcd68497fbc45b02fd592e718c8f1dc#npm:16.3.2"],\ ["@types/jest", "npm:29.5.12"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.27.3"],\ @@ -6948,7 +7031,7 @@ 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:4894bb3700d40298c0b780832469c36744bf157b3f4f7f0c0b6cdbc25cc1e89aadad803ae2cfa1e7a9fae9283a7054b91fcd68497fbc45b02fd592e718c8f1dc#npm:16.3.2"],\ ["@types/jest", "npm:29.5.12"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.27.3"],\ @@ -7397,10 +7480,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:4894bb3700d40298c0b780832469c36744bf157b3f4f7f0c0b6cdbc25cc1e89aadad803ae2cfa1e7a9fae9283a7054b91fcd68497fbc45b02fd592e718c8f1dc#npm:16.3.2", {\ + "packageLocation": "./.yarn/__virtual__/@testing-library-react-virtual-51a481856b/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:4894bb3700d40298c0b780832469c36744bf157b3f4f7f0c0b6cdbc25cc1e89aadad803ae2cfa1e7a9fae9283a7054b91fcd68497fbc45b02fd592e718c8f1dc#npm:16.3.2"],\ ["@babel/runtime", "npm:7.25.0"],\ ["@testing-library/dom", "npm:10.4.1"],\ ["@types/react", "npm:18.3.3"],\ @@ -8100,6 +8183,24 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ + ["virtual:137001fbbe4c5a64f486aea865a749a9274730020c67c12e787ea96ab5c851a2d27143516452e8364746285d6be9344ffa47021731ca9521366c4a691a3d8765#npm:4.3.1", {\ + "packageLocation": "./.yarn/__virtual__/@vitejs-plugin-react-virtual-5e0929d49d/0/cache/@vitejs-plugin-react-npm-4.3.1-cbe92983ea-a9d1eb30c9.zip/node_modules/@vitejs/plugin-react/",\ + "packageDependencies": [\ + ["@vitejs/plugin-react", "virtual:137001fbbe4c5a64f486aea865a749a9274730020c67c12e787ea96ab5c851a2d27143516452e8364746285d6be9344ffa47021731ca9521366c4a691a3d8765#npm:4.3.1"],\ + ["@babel/core", "npm:7.24.7"],\ + ["@babel/plugin-transform-react-jsx-self", "virtual:e48b893e746914ed8ceadf4eea719f218eec495961721247fc6dc0b57da00d32117e13f10722411e4404cbae92f340007ba41922deea365154f9f0140cc70578#npm:7.24.7"],\ + ["@babel/plugin-transform-react-jsx-source", "virtual:e48b893e746914ed8ceadf4eea719f218eec495961721247fc6dc0b57da00d32117e13f10722411e4404cbae92f340007ba41922deea365154f9f0140cc70578#npm:7.24.7"],\ + ["@types/babel__core", "npm:7.20.5"],\ + ["@types/vite", null],\ + ["react-refresh", "npm:0.14.2"],\ + ["vite", "virtual:137001fbbe4c5a64f486aea865a749a9274730020c67c12e787ea96ab5c851a2d27143516452e8364746285d6be9344ffa47021731ca9521366c4a691a3d8765#npm:5.3.2"]\ + ],\ + "packagePeers": [\ + "@types/vite",\ + "vite"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:4.3.1", {\ "packageLocation": "./.yarn/__virtual__/@vitejs-plugin-react-virtual-e48b893e74/0/cache/@vitejs-plugin-react-npm-4.3.1-cbe92983ea-a9d1eb30c9.zip/node_modules/@vitejs/plugin-react/",\ "packageDependencies": [\ @@ -11442,6 +11543,14 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["fsevents", [\ + ["patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1", {\ + "packageLocation": "./.yarn/unplugged/fsevents-patch-19706e7e35/node_modules/fsevents/",\ + "packageDependencies": [\ + ["fsevents", "patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1"],\ + ["node-gyp", "npm:9.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ ["patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1", {\ "packageLocation": "./.yarn/unplugged/fsevents-patch-6b67494872/node_modules/fsevents/",\ "packageDependencies": [\ @@ -16328,6 +16437,26 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["playwright", [\ + ["npm:1.61.1", {\ + "packageLocation": "./.yarn/cache/playwright-npm-1.61.1-55c1fb1408-0cd1a8a7e1.zip/node_modules/playwright/",\ + "packageDependencies": [\ + ["playwright", "npm:1.61.1"],\ + ["fsevents", "patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1"],\ + ["playwright-core", "npm:1.61.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["playwright-core", [\ + ["npm:1.61.1", {\ + "packageLocation": "./.yarn/cache/playwright-core-npm-1.61.1-0f9bd8b431-b0e7b1d4de.zip/node_modules/playwright-core/",\ + "packageDependencies": [\ + ["playwright-core", "npm:1.61.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["postcss", [\ ["npm:8.4.31", {\ "packageLocation": "./.yarn/cache/postcss-npm-8.4.31-385051a82b-1a6653e721.zip/node_modules/postcss/",\ @@ -19062,6 +19191,45 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ + ["virtual:137001fbbe4c5a64f486aea865a749a9274730020c67c12e787ea96ab5c851a2d27143516452e8364746285d6be9344ffa47021731ca9521366c4a691a3d8765#npm:5.3.2", {\ + "packageLocation": "./.yarn/__virtual__/vite-virtual-5c92dfd79f/0/cache/vite-npm-5.3.2-0bfa725124-77b2849389.zip/node_modules/vite/",\ + "packageDependencies": [\ + ["vite", "virtual:137001fbbe4c5a64f486aea865a749a9274730020c67c12e787ea96ab5c851a2d27143516452e8364746285d6be9344ffa47021731ca9521366c4a691a3d8765#npm:5.3.2"],\ + ["@types/less", null],\ + ["@types/lightningcss", null],\ + ["@types/node", "npm:20.14.9"],\ + ["@types/sass", null],\ + ["@types/stylus", null],\ + ["@types/sugarss", null],\ + ["@types/terser", null],\ + ["esbuild", "npm:0.21.5"],\ + ["fsevents", "patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1"],\ + ["less", null],\ + ["lightningcss", null],\ + ["postcss", "npm:8.4.39"],\ + ["rollup", "npm:4.18.0"],\ + ["sass", null],\ + ["stylus", null],\ + ["sugarss", null],\ + ["terser", null]\ + ],\ + "packagePeers": [\ + "@types/less",\ + "@types/lightningcss",\ + "@types/node",\ + "@types/sass",\ + "@types/stylus",\ + "@types/sugarss",\ + "@types/terser",\ + "less",\ + "lightningcss",\ + "sass",\ + "stylus",\ + "sugarss",\ + "terser"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:2c30557d8ca5e7c67e7558d45bc44bef6c7a622b34a97fa8102f3235f92769c87777d34ed37059c12d8f3a21841ea06cf4badc5dc796697b0f765c17db6e24e5#npm:5.3.2", {\ "packageLocation": "./.yarn/__virtual__/vite-virtual-efe5987618/0/cache/vite-npm-5.3.2-0bfa725124-77b2849389.zip/node_modules/vite/",\ "packageDependencies": [\ diff --git a/.yarn/cache/@playwright-test-npm-1.61.1-8d56fe0fbb-0891535703.zip b/.yarn/cache/@playwright-test-npm-1.61.1-8d56fe0fbb-0891535703.zip new file mode 100644 index 000000000..9483bee20 Binary files /dev/null and b/.yarn/cache/@playwright-test-npm-1.61.1-8d56fe0fbb-0891535703.zip differ diff --git a/.yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-6b5b6f5692.zip b/.yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-6b5b6f5692.zip new file mode 100644 index 000000000..816292417 Binary files /dev/null and b/.yarn/cache/fsevents-npm-2.3.2-a881d6ac9f-6b5b6f5692.zip differ diff --git a/.yarn/cache/fsevents-patch-19706e7e35-10.zip b/.yarn/cache/fsevents-patch-19706e7e35-10.zip new file mode 100644 index 000000000..aff1ab12c Binary files /dev/null and b/.yarn/cache/fsevents-patch-19706e7e35-10.zip differ diff --git a/.yarn/cache/playwright-core-npm-1.61.1-0f9bd8b431-b0e7b1d4de.zip b/.yarn/cache/playwright-core-npm-1.61.1-0f9bd8b431-b0e7b1d4de.zip new file mode 100644 index 000000000..8b153fdac Binary files /dev/null and b/.yarn/cache/playwright-core-npm-1.61.1-0f9bd8b431-b0e7b1d4de.zip differ diff --git a/.yarn/cache/playwright-npm-1.61.1-55c1fb1408-0cd1a8a7e1.zip b/.yarn/cache/playwright-npm-1.61.1-55c1fb1408-0cd1a8a7e1.zip new file mode 100644 index 000000000..bb6f21161 Binary files /dev/null and b/.yarn/cache/playwright-npm-1.61.1-55c1fb1408-0cd1a8a7e1.zip differ diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 000000000..661927ba4 --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +*.log +test-results diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 000000000..bc779c96e --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,106 @@ +# `@stackflow/plugin-history-sync` × `preventDefault` verification harness + +A safety net that proves `@stackflow/plugin-history-sync` coexists correctly +with `preventDefault`-consuming plugins (`@stackflow/plugin-blocker`). It drives +a dedicated app with **both plugins applied** and asserts the one guarantee that +matters at every quiet point: **browser == stack** — the visible screen, the URL +and the public `getStack()` snapshot all agree. + +The four desyncs this guards against: + +1. A blocked **browser back** must keep the user in place (today the back is + dispatched directly and cannot be vetoed). +2. A blocked **programmatic** pop/stepPop/replace must not move the browser + (today a queued `history.back()` runs anyway). +3. A blocked **browser forward** must restore, and a following push must still + sync exactly (today a leaked counter skips the sync). +4. The above must hold **regardless of plugin registration order**. + +## Tiers + +| Tier | Runner | Environment | Scope | +|---|---|---|---| +| **T1** | jest (`node`) driving real Chromium via the `playwright` library | production build served by an in-process vite preview | all real-history behaviors: the four problems, the coexistence contract, concurrency, and both plugins' navigation-observable cases | +| **T2i** | jest (`jsdom`) | both plugins applied in-process | timing-independent blocker-internal contracts (error isolation, notification order) | + +Both tiers run the **current source** of the plugins (the workspace packages are +aliased to their `src`), so the harness reproduces today's behavior and will pick +up a product fix immediately. + +## Running + +```bash +# one-time: download the Chromium build used by T1 +yarn workspace @stackflow/e2e-history-sync-blocker browser:install +# (or set HARNESS_BROWSER_CHANNEL=chrome to use a system Chrome) + +# both tiers +yarn workspace @stackflow/e2e-history-sync-blocker test + +# one tier +yarn workspace @stackflow/e2e-history-sync-blocker test:t1 +yarn workspace @stackflow/e2e-history-sync-blocker test:t2i + +# explore the app by hand (same app the drivers use) +yarn workspace @stackflow/e2e-history-sync-blocker app:dev +``` + +T1 builds the app and serves it automatically (jest `globalSetup`); no separate +server step is needed. + +## Expected red on the unfixed product + +This harness encodes the **target** behavior. Against the current, unfixed +`plugin-history-sync` the cases that exercise a vetoed navigation that touches +history are **red** — that is correct and expected: + +- the four-problem cases; +- the coexistence-contract cases that block a pop and the blocker cases that + block a pop/stepPop (the URL desyncs from the committed stack); +- the concurrency cases whose consistency depends on the fix. + +The baseline navigation suite (history-sync behaviors with the blocker present +but disarmed) is **green** — it proves the harness models the system correctly, +so the reds are genuine product desyncs rather than harness faults. The cases +that don't touch a vetoed backward navigation (allowed navigations, blocked +pushes/replaces with no history side effect, the blocker-internal contracts) are +also green. When the product upholds browser == stack across `preventDefault`, +the whole gate turns green. + +## What is and isn't asserted + +Tests assert only externally observable behavior: + +- **SCREEN** — the visible activity/step (DOM markers). +- **URL** — `window.location`. +- **STACK** — the public `getStack()` snapshot (top activity, params, steps). +- **NAVIGABILITY** — where `browserBack`/`browserForward` reach at rest. +- **Harness-owned signals** — the blocker's own `shouldBlock`/`onBlocked` + notifications and `onError` sink, and the probe co-plugin's own hook calls. + +Internal coordinates (history `state` ordinals, suppression tokens, the sync +queue, history-sync's own before/after hooks) are never read. Settle is observed, +never slept for: a step is done only once the public transition state is idle and +a double-stable check (two snapshots separated by ≥1 animation frame + 1 +macrotask) agrees. + +## Layout + +``` +src/ + shared/contract.ts the observation contract: test ids, query knobs, bridge shape + app/ the harness app (both plugins, controls, blocker UI, probe, bridge) + dal/ Driver Abstraction Layer over a Chromium page + per-file fixture + t1/ real-browser specs (problems, compat, concurrency, history-sync, blocker) + t2i/ jsdom integration spec (blocker-internal contracts) +``` + +The app is configured entirely by URL query knobs (`order`, `hash`, `lazyDelay`, +`block`, `blockers`, `blockAsync`, `probe`, …), so each scenario is a pure +function of how the driver opened it. See `src/shared/contract.ts`. + +> Note: `yarn typecheck` covers the harness's own code (`src/app`, `src/dal`, +> `src/shared`). Because the harness compiles the plugins' current **source**, +> `tsc` additionally surfaces a few strict-mode diagnostics inside that aliased +> product source; those are not harness-code issues. The gate is the test suites +> and the production build. diff --git a/e2e/index.html b/e2e/index.html new file mode 100644 index 000000000..864a10510 --- /dev/null +++ b/e2e/index.html @@ -0,0 +1,11 @@ + + + + @stackflow history-sync × blocker harness + + + +
+ + + diff --git a/e2e/jest.config.cjs b/e2e/jest.config.cjs new file mode 100644 index 000000000..7386b06fe --- /dev/null +++ b/e2e/jest.config.cjs @@ -0,0 +1,55 @@ +/** + * Both gate tiers run under jest (which resolves cleanly under this repo's Yarn + * PnP setup): + * + * - t1 : real Chromium. A `node` environment drives the harness app through + * the `playwright` library. A built app is served by an in-process + * vite preview started in globalSetup. + * - t2i : jsdom integration. Both plugins applied; timing-independent + * blocker-internal contracts (error isolation, notification order). + * + * Both map the @stackflow/* packages to their `src` so the suites exercise the + * current source, not a built dist. + */ + +const swcTransform = [ + "@swc/jest", + { + jsc: { + transform: { react: { runtime: "automatic" } }, + }, + }, +]; + +const stackflowSrc = { + "^@stackflow/core$": "/../core/src/index.ts", + "^@stackflow/react$": "/../integrations/react/src/index.ts", + "^@stackflow/config$": "/../config/src/index.ts", + "^@stackflow/plugin-history-sync$": + "/../extensions/plugin-history-sync/src/index.ts", + "^@stackflow/plugin-blocker$": + "/../extensions/plugin-blocker/src/index.ts", + "^@stackflow/plugin-renderer-basic$": + "/../extensions/plugin-renderer-basic/src/index.ts", +}; + +module.exports = { + projects: [ + { + displayName: "t1", + testEnvironment: "node", + testMatch: ["/src/t1/**/*.spec.ts"], + transform: { "^.+\\.(t|j)sx?$": swcTransform }, + setupFilesAfterEnv: ["/src/t1/setup.cjs"], + globalSetup: "/src/t1/globalSetup.cjs", + globalTeardown: "/src/t1/globalTeardown.cjs", + }, + { + displayName: "t2i", + testEnvironment: "jsdom", + testMatch: ["/src/t2i/**/*.spec.tsx"], + transform: { "^.+\\.(t|j)sx?$": swcTransform }, + moduleNameMapper: stackflowSrc, + }, + ], +}; diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 000000000..c10df0de3 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,45 @@ +{ + "name": "@stackflow/e2e-history-sync-blocker", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "description": "Real-browser + jsdom verification harness proving @stackflow/plugin-history-sync coexists safely with preventDefault consumers (@stackflow/plugin-blocker).", + "scripts": { + "app:dev": "vite", + "app:build": "vite build", + "app:preview": "vite preview", + "browser:install": "playwright install chromium", + "test": "jest", + "test:t1": "jest --selectProjects t1", + "test:t2i": "jest --selectProjects t2i", + "typecheck": "tsc --noEmit", + "clean": "rimraf dist node_modules/.vite" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "@stackflow/config": "^2.0.0", + "@stackflow/core": "^2.0.0", + "@stackflow/plugin-blocker": "^0.1.1", + "@stackflow/plugin-history-sync": "^1.11.0", + "@stackflow/plugin-renderer-basic": "^1.1.14", + "@stackflow/react": "^2.0.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/node": "^20.14.9", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "history": "^5.3.0", + "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", + "vite": "^5.3.2" + } +} diff --git a/e2e/src/app/App.tsx b/e2e/src/app/App.tsx new file mode 100644 index 000000000..f977c2415 --- /dev/null +++ b/e2e/src/app/App.tsx @@ -0,0 +1,43 @@ +/** + * Harness app root. No StrictMode (and served as a production build) so that + * development-mode double-invocation never perturbs the fallback count or the + * settle observation. + */ + +import { useEffect, useMemo } from "react"; +import { markReady } from "./bridge"; +import { buildStack } from "./buildStack"; +import { LifecyclePanel } from "./components/LifecyclePanel"; +import { HarnessConfigContext } from "./HarnessConfigContext"; +import { getCoreActions } from "./plugins/spyPlugin"; +import { parseHarnessConfig, readHarnessSearch } from "./query"; + +export function App() { + const harnessConfig = useMemo( + () => parseHarnessConfig(readHarnessSearch()), + [], + ); + const { Stack } = useMemo(() => buildStack(harnessConfig), [harnessConfig]); + + useEffect(() => { + // Signal readiness once the initial route has reached idle. + let raf = 0; + const check = () => { + const actions = getCoreActions(); + if (actions && actions.getStack().globalTransitionState === "idle") { + markReady(); + return; + } + raf = requestAnimationFrame(check); + }; + check(); + return () => cancelAnimationFrame(raf); + }, []); + + return ( + + + + + ); +} diff --git a/e2e/src/app/HarnessConfigContext.ts b/e2e/src/app/HarnessConfigContext.ts new file mode 100644 index 000000000..c1a105dd4 --- /dev/null +++ b/e2e/src/app/HarnessConfigContext.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from "react"; +import type { HarnessConfig } from "./query"; + +/** Injects the per-instance harness configuration to activities. */ +export const HarnessConfigContext = createContext(null); + +export function useHarnessConfig(): HarnessConfig { + const config = useContext(HarnessConfigContext); + if (!config) { + throw new Error("HarnessConfigContext is not provided"); + } + return config; +} diff --git a/e2e/src/app/activities/activities.tsx b/e2e/src/app/activities/activities.tsx new file mode 100644 index 000000000..f0bf13154 --- /dev/null +++ b/e2e/src/app/activities/activities.tsx @@ -0,0 +1,39 @@ +/** + * The harness activities. All are plain screens; their navigation/step + * behavior comes entirely from history-sync + the controls. Lazy's loading + * window is produced by an async loader wired in the config, not by the + * component itself. + */ + +import type { ActivityComponentType } from "@stackflow/react"; +import { Screen } from "../components/Screen"; + +declare module "@stackflow/config" { + interface Register { + Home: Record; + Article: { articleId: string; title?: string }; + Third: { thirdId: string }; + Fourth: { fourthId: string }; + Lazy: Record; + } +} + +export const Home: ActivityComponentType<"Home"> = () => ( + +); + +export const Article: ActivityComponentType<"Article"> = () => ( + +); + +export const Third: ActivityComponentType<"Third"> = () => ( + +); + +export const Fourth: ActivityComponentType<"Fourth"> = () => ( + +); + +export const Lazy: ActivityComponentType<"Lazy"> = () => ( + +); diff --git a/e2e/src/app/bridge.ts b/e2e/src/app/bridge.ts new file mode 100644 index 000000000..56da88eff --- /dev/null +++ b/e2e/src/app/bridge.ts @@ -0,0 +1,66 @@ +/** + * The `window.__harness__` instrumentation bridge. It exposes only the public + * stack snapshot, the browser location, and harness-owned observations + * (blocker notifications, probe hook calls, the error sink, the fallback + * count). It never reveals history-sync internals. + */ + +import type { + HarnessBridge, + LocationView, + StackActivityView, + StackView, +} from "../shared/contract"; +import { harnessStore } from "./harnessStore"; +import { getCoreActions } from "./plugins/spyPlugin"; + +function serializeStack(): StackView { + const actions = getCoreActions(); + if (!actions) { + return { globalTransitionState: "loading", activities: [], active: null }; + } + const stack = actions.getStack(); + const activities: StackActivityView[] = stack.activities + .filter((a) => a.transitionState !== "exit-done") + .map((a) => { + const steps = a.steps ?? []; + const topStep = steps[steps.length - 1]; + return { + name: a.name, + params: a.params, + transitionState: a.transitionState, + isActive: a.isActive, + stepCount: steps.length, + stepParams: topStep ? topStep.params : a.params, + }; + }); + const active = activities.find((a) => a.isActive) ?? null; + return { + globalTransitionState: stack.globalTransitionState, + activities, + active, + }; +} + +function readLocation(): LocationView { + const { href, pathname, search, hash } = window.location; + return { href, pathname, search, hash }; +} + +const bridge: HarnessBridge = { + ready: false, + getStack: serializeStack, + getLocation: readLocation, + getFallbackCallCount: () => harnessStore.fallbackCount, + getBlockerLog: () => harnessStore.blockerLog.slice(), + getProbeLog: () => harnessStore.probeLog.slice(), + getErrors: () => harnessStore.errors.slice(), +}; + +export function installBridge() { + window.__harness__ = bridge; +} + +export function markReady() { + bridge.ready = true; +} diff --git a/e2e/src/app/buildStack.ts b/e2e/src/app/buildStack.ts new file mode 100644 index 000000000..3782100af --- /dev/null +++ b/e2e/src/app/buildStack.ts @@ -0,0 +1,71 @@ +/** + * Builds the stackflow instance for one harness configuration. history-sync and + * blocker are always both applied; their relative registration order and the + * optional probe co-plugin's placement are driven by the query so the same app + * exercises order-independence and the replay-interaction contract. + */ + +import { defineConfig } from "@stackflow/config"; +import { blockerPlugin } from "@stackflow/plugin-blocker"; +import { historySyncPlugin } from "@stackflow/plugin-history-sync"; +import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; +import { type StackflowReactPlugin, stackflow } from "@stackflow/react"; +import { Article, Fourth, Home, Lazy, Third } from "./activities/activities"; +import { harnessStore } from "./harnessStore"; +import { makeProbePlugin } from "./plugins/probePlugin"; +import { spyPlugin } from "./plugins/spyPlugin"; +import type { HarnessConfig } from "./query"; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function buildStack(hc: HarnessConfig) { + const config = defineConfig({ + activities: [ + { name: "Home", route: "/" }, + { name: "Article", route: "/articles/:articleId" }, + { name: "Third", route: "/third/:thirdId" }, + { name: "Fourth", route: "/fourth/:fourthId" }, + // The async loader is what makes Lazy hold a "paused" window of width + // lazyDelay (plugin-loader pauses the stack while a loader is pending). + { name: "Lazy", route: "/lazy", loader: () => delay(hc.lazyDelayMs) }, + ], + transitionDuration: hc.transitionDuration, + initialActivity: () => "Home", + }); + + const history = historySyncPlugin({ + config, + fallbackActivity: () => { + harnessStore.incFallbackCount(); + return "Home"; + }, + useHash: hc.useHash, + }); + + const blocker = blockerPlugin({ + onError: (error) => harnessStore.logError(error), + }); + + const orderedNav: StackflowReactPlugin[] = + hc.order === "blocker-first" ? [blocker, history] : [history, blocker]; + + if (hc.probe) { + const probe = makeProbePlugin(hc.probe.mode); + const blockerIndex = orderedNav.indexOf(blocker); + orderedNav.splice( + hc.probe.placement === "before" ? blockerIndex : blockerIndex + 1, + 0, + probe, + ); + } + + const { Stack, actions, stepActions } = stackflow({ + config, + components: { Home, Article, Third, Fourth, Lazy }, + plugins: [basicRendererPlugin(), spyPlugin, ...orderedNav], + }); + + return { Stack, actions, stepActions, config }; +} diff --git a/e2e/src/app/components/Blockers.tsx b/e2e/src/app/components/Blockers.tsx new file mode 100644 index 000000000..a61bd63d5 --- /dev/null +++ b/e2e/src/app/components/Blockers.tsx @@ -0,0 +1,123 @@ +/** + * Blocker arming and the app-level dialog host. + * + * `BlockerMounts` registers one `useBlocker` per armed blocker id on the + * activity that owns it, mirroring plugin-blocker's own usage. Registration is + * kept faithful to the original: the blocker is registered while the activity + * is mounted and plugin-blocker scopes it to the active activity — except when + * a lifecycle case explicitly unmounts a blocker via the store's mount toggle. + * + * A blocker's `onBlocked` records the notification and captures `proceed` into + * the module-level store, so the dialog (rendered at app root) and the captured + * proceed both outlive the owning component. `block-confirm` invokes proceed; + * `block-cancel` dismisses the dialog while leaving the navigation blocked. + */ + +import { useBlocker } from "@stackflow/plugin-blocker"; +import { useFlow } from "@stackflow/react"; +import { useRef } from "react"; +import { + type ActivityName, + type BlockableAction, + type BlockerId, + testid, +} from "../../shared/contract"; +import { harnessStore } from "../harnessStore"; +import type { HarnessConfig } from "../query"; +import { armedActionsFor, blockerIdsFor } from "../query"; +import { useHarnessVersion } from "../useHarnessVersion"; + +function ArmedBlocker({ + blockerId, + actions, + async, + onBlockedNav, +}: { + blockerId: BlockerId; + actions: Set; + async: boolean; + onBlockedNav: "replace" | null; +}) { + // Re-render (and re-register useBlocker with a fresh shouldBlock) when the + // arm toggle flips, so the last committed render's decision is the one used. + useHarnessVersion(); + const armed = harnessStore.isArmed(blockerId); + const { replace } = useFlow(); + const nestedFired = useRef(false); + + useBlocker({ + shouldBlock: (action) => { + harnessStore.logShouldBlock(blockerId, action.name as BlockableAction); + return armed && actions.has(action.name as BlockableAction); + }, + onBlocked: ({ action }, { proceed }) => { + const name = action.name as BlockableAction; + harnessStore.logBlocked(blockerId, name); + harnessStore.addPending({ blockerId, action: name, proceed, async }); + // Reentrancy: start a nested navigation from inside onBlocked. + if (onBlockedNav === "replace" && !nestedFired.current) { + nestedFired.current = true; + replace("Third", { thirdId: "nested" }); + } + }, + }); + return null; +} + +export function BlockerMounts({ + activityName, + config, +}: { + activityName: ActivityName; + config: HarnessConfig; +}) { + // Subscribe so lifecycle mount toggles re-render this subtree. + useHarnessVersion(); + const ids = blockerIdsFor(config, activityName); + const actions = armedActionsFor(config, activityName); + + return ( + <> + {ids + .filter((id) => harnessStore.isMounted(id)) + .map((id) => ( + + ))} + + ); +} + +export function BlockerDialogHost() { + useHarnessVersion(); + const pending = harnessStore.pending; + + return ( +
+ {pending.map((p) => ( +
+ blocking + + +
+ ))} +
+ ); +} diff --git a/e2e/src/app/components/Controls.tsx b/e2e/src/app/components/Controls.tsx new file mode 100644 index 000000000..4dc6625bb --- /dev/null +++ b/e2e/src/app/components/Controls.tsx @@ -0,0 +1,136 @@ +/** + * Navigation controls for the active screen. Buttons carry the data-testids the + * drivers click; parameters are read from uncontrolled inputs at click time so + * there is no React state race between a driver's fill and its click. + * + * Step controls act on the current activity, applying the value under that + * activity's id key (Article→articleId, Third→thirdId, Fourth→fourthId) while + * preserving the other params (e.g. an Article's title). + */ + +import { useActivity, useFlow, useStepFlow } from "@stackflow/react"; +import { useRef } from "react"; +import { type ActivityName, testid } from "../../shared/contract"; + +const STEP_ID_KEY: Record = { + Home: null, + Article: "articleId", + Third: "thirdId", + Fourth: "fourthId", + Lazy: null, +}; + +export function Controls({ activityName }: { activityName: ActivityName }) { + const { push, replace, pop } = useFlow(); + const { pushStep, popStep, replaceStep } = useStepFlow(activityName); + const activity = useActivity(); + + const idRef = useRef(null); + const titleRef = useRef(null); + + const readId = () => idRef.current?.value ?? ""; + const readTitle = () => { + const v = titleRef.current?.value ?? ""; + return v === "" ? undefined : v; + }; + + // The harness builds step params dynamically; the concrete activity's param + // shape is recovered at the call site via a cast. + const stepParams = () => { + const key = STEP_ID_KEY[activityName]; + const id = readId(); + const base = { ...activity.params } as Record; + if (key) { + base[key] = id; + } + return base as never; + }; + + return ( +
+ + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/e2e/src/app/components/LifecyclePanel.tsx b/e2e/src/app/components/LifecyclePanel.tsx new file mode 100644 index 000000000..ed6fcc138 --- /dev/null +++ b/e2e/src/app/components/LifecyclePanel.tsx @@ -0,0 +1,47 @@ +/** + * App-root controls that must remain available regardless of which activity is + * active: per-blocker mount toggles (lifecycle cases) and the blocker dialog + * host. Rendering them at the root is what lets a captured proceed be invoked + * after the blocker-owning component has unmounted. + */ + +import { testid } from "../../shared/contract"; +import { useHarnessConfig } from "../HarnessConfigContext"; +import { harnessStore } from "../harnessStore"; +import { useHarnessVersion } from "../useHarnessVersion"; +import { BlockerDialogHost } from "./Blockers"; + +export function LifecyclePanel() { + useHarnessVersion(); + const config = useHarnessConfig(); + + // Every blocker id that any armed activity can mount, deduplicated. + const ids = Array.from( + { length: config.blockerCount }, + (_, i) => `b${i + 1}`, + ).filter(() => config.armed.length > 0); + + return ( +
+ {ids.map((id) => ( + + + + + ))} + +
+ ); +} diff --git a/e2e/src/app/components/Screen.tsx b/e2e/src/app/components/Screen.tsx new file mode 100644 index 000000000..a73409681 --- /dev/null +++ b/e2e/src/app/components/Screen.tsx @@ -0,0 +1,32 @@ +/** + * One screen. Exposes the DOM observation markers and, for the active activity, + * the navigation controls. Blocker arming is rendered whenever the activity is + * mounted (plugin-blocker scopes it to the active activity), while controls are + * rendered only for the active activity so each control test id is unique. + */ + +import { useActivity } from "@stackflow/react"; +import { type ActivityName, testid } from "../../shared/contract"; +import { useHarnessConfig } from "../HarnessConfigContext"; +import { BlockerMounts } from "./Blockers"; +import { Controls } from "./Controls"; + +export function Screen({ activityName }: { activityName: ActivityName }) { + const activity = useActivity(); + const config = useHarnessConfig(); + const isActive = activity.isActive; + const stepIndex = Math.max(0, activity.steps.length - 1); + + return ( +
+ + {isActive ? : null} +
+ ); +} diff --git a/e2e/src/app/harnessStore.ts b/e2e/src/app/harnessStore.ts new file mode 100644 index 000000000..b25aa23ea --- /dev/null +++ b/e2e/src/app/harnessStore.ts @@ -0,0 +1,160 @@ +/** + * Module-level observation store for the harness app. + * + * It lives outside React on purpose: a blocker's captured `proceed` must remain + * invokable after the component that owns the blocker has unmounted, and the + * instrumentation bridge must read a consistent snapshot synchronously. React + * views subscribe to a monotonically increasing version to re-render. + */ + +import type { + BlockableAction, + BlockerId, + BlockerLogEntry, + ProbeLogEntry, +} from "../shared/contract"; + +interface PendingBlock { + blockerId: BlockerId; + action: BlockableAction; + proceed: () => void; + /** When true, confirm() invokes proceed across a real async gap. */ + async: boolean; +} + +/** Delay used to model "user confirms later, asynchronously" (blockAsync). */ +const ASYNC_CONFIRM_GAP_MS = 20; + +class HarnessStore { + private version = 0; + private listeners = new Set<() => void>(); + + blockerLog: BlockerLogEntry[] = []; + probeLog: ProbeLogEntry[] = []; + errors: string[] = []; + fallbackCount = 0; + + /** One entry per blocker that has vetoed a not-yet-resolved navigation. */ + pending: PendingBlock[] = []; + /** blockerId → mounted; absence means mounted (default true). */ + private mounted = new Map(); + /** Blocker ids whose arming has been toggled off (default armed). */ + private disarmed = new Set(); + /** hook name → number of times the probe co-plugin has run it. */ + private probeCounts = new Map(); + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getVersion = (): number => this.version; + + private bump() { + this.version += 1; + for (const l of this.listeners) { + l(); + } + } + + // --- blocker notifications (public contract of plugin-blocker) --- + + logShouldBlock(blockerId: BlockerId, action: BlockableAction) { + this.blockerLog.push({ blockerId, action, phase: "shouldBlock" }); + this.bump(); + } + + logBlocked(blockerId: BlockerId, action: BlockableAction, threw?: boolean) { + this.blockerLog.push({ blockerId, action, phase: "blocked", threw }); + this.bump(); + } + + logProceed(blockerId: BlockerId, action: BlockableAction) { + this.blockerLog.push({ blockerId, action, phase: "proceed" }); + this.bump(); + } + + logError(error: unknown) { + this.errors.push(error instanceof Error ? error.message : String(error)); + this.bump(); + } + + // --- initial-routing observation --- + + incFallbackCount() { + this.fallbackCount += 1; + this.bump(); + } + + // --- pending blocks / captured proceed --- + + addPending(block: PendingBlock) { + // A fresh veto by the same blocker supersedes any stale one, keeping at + // most one dialog per blocker id. + this.pending = this.pending.filter((p) => p.blockerId !== block.blockerId); + this.pending.push(block); + this.bump(); + } + + /** Invoke the captured proceed; the dialog persists so it can be called + * again (proceed idempotency is the plugin's guarantee). */ + confirm(blockerId: BlockerId) { + const block = this.pending.find((p) => p.blockerId === blockerId); + if (!block) { + return; + } + this.logProceed(block.blockerId, block.action); + if (block.async) { + setTimeout(() => block.proceed(), ASYNC_CONFIRM_GAP_MS); + } else { + block.proceed(); + } + } + + /** Dismiss the dialog without proceeding; the navigation stays blocked. */ + cancel(blockerId: BlockerId) { + this.pending = this.pending.filter((p) => p.blockerId !== blockerId); + this.bump(); + } + + isBlocking(blockerId: BlockerId): boolean { + return this.pending.some((p) => p.blockerId === blockerId); + } + + // --- lifecycle mount toggling --- + + isMounted(blockerId: BlockerId): boolean { + return this.mounted.get(blockerId) ?? true; + } + + toggleMount(blockerId: BlockerId) { + this.mounted.set(blockerId, !this.isMounted(blockerId)); + this.bump(); + } + + isArmed(blockerId: BlockerId): boolean { + return !this.disarmed.has(blockerId); + } + + toggleArm(blockerId: BlockerId) { + if (this.disarmed.has(blockerId)) { + this.disarmed.delete(blockerId); + } else { + this.disarmed.add(blockerId); + } + this.bump(); + } + + // --- probe co-plugin --- + + probeCall(hook: string): number { + const count = (this.probeCounts.get(hook) ?? 0) + 1; + this.probeCounts.set(hook, count); + const entry: ProbeLogEntry = { hook, count }; + this.probeLog.push(entry); + this.bump(); + return count; + } +} + +export const harnessStore = new HarnessStore(); diff --git a/e2e/src/app/main.tsx b/e2e/src/app/main.tsx new file mode 100644 index 000000000..96725eb22 --- /dev/null +++ b/e2e/src/app/main.tsx @@ -0,0 +1,12 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import { installBridge } from "./bridge"; + +// Install the bridge before the first render so drivers can poll `ready`. +installBridge(); + +const container = document.getElementById("root"); +if (!container) { + throw new Error("missing #root"); +} +createRoot(container).render(); diff --git a/e2e/src/app/plugins/probePlugin.ts b/e2e/src/app/plugins/probePlugin.ts new file mode 100644 index 000000000..c30a51c60 --- /dev/null +++ b/e2e/src/app/plugins/probePlugin.ts @@ -0,0 +1,31 @@ +/** + * A small synthetic co-plugin used to exercise plugin-blocker's replay + * interaction contract (the original suite's section 8). It is deliberately + * NOT history-sync: it observes only its own onBeforePush calls, and on the + * replay invocation (the 2nd call) it can run a nested navigation or cancel the + * replay via preventDefault. The blocker's own log and the public + * SCREEN/URL/STACK are the witnesses — history-sync's internal hooks are never + * asserted. + */ + +import type { StackflowReactPlugin } from "@stackflow/react"; +import type { ProbeMode } from "../../shared/contract"; +import { harnessStore } from "../harnessStore"; + +export function makeProbePlugin(mode: ProbeMode): StackflowReactPlugin { + return () => ({ + key: "harness-probe", + onBeforePush({ actions }) { + const count = harnessStore.probeCall("onBeforePush"); + // The first call is the original attempt; the second is the replay that + // plugin-blocker performs after every blocker has proceeded. + if (count === 2) { + if (mode === "nested") { + actions.pop(); + } else if (mode === "prevent") { + actions.preventDefault(); + } + } + }, + }); +} diff --git a/e2e/src/app/plugins/spyPlugin.ts b/e2e/src/app/plugins/spyPlugin.ts new file mode 100644 index 000000000..2c4b79a77 --- /dev/null +++ b/e2e/src/app/plugins/spyPlugin.ts @@ -0,0 +1,21 @@ +/** + * Captures the core actions so the instrumentation bridge can read the public + * stack snapshot. The React `stackflow()` output only surfaces push/replace/pop; + * `getStack()` lives on the core actions handed to plugins via onInit. + */ + +import type { StackflowActions } from "@stackflow/core"; +import type { StackflowReactPlugin } from "@stackflow/react"; + +let captured: StackflowActions | null = null; + +export const spyPlugin: StackflowReactPlugin = () => ({ + key: "harness-spy", + onInit({ actions }) { + captured = actions; + }, +}); + +export function getCoreActions(): StackflowActions | null { + return captured; +} diff --git a/e2e/src/app/query.ts b/e2e/src/app/query.ts new file mode 100644 index 000000000..15609b14c --- /dev/null +++ b/e2e/src/app/query.ts @@ -0,0 +1,131 @@ +/** + * Parses the harness app's URL query into a typed configuration. One harness + * app instance is fully described by its query string, which keeps every + * scenario a pure function of the URL the driver opened. + */ + +import { + type ActivityName, + type BlockableAction, + type ProbeMode, + type ProbePlacement, + queryKey, + type RegistrationOrder, +} from "../shared/contract"; + +export interface ArmedActivity { + activity: ActivityName; + actions: Set; +} + +export interface HarnessConfig { + order: RegistrationOrder; + useHash: boolean; + lazyDelayMs: number; + transitionDuration: number; + /** Number of blocker instances (b1..bN) mounted on each armed activity. */ + blockerCount: number; + blockAsync: boolean; + armed: ArmedActivity[]; + probe: { placement: ProbePlacement; mode: ProbeMode } | null; + /** When set, an armed blocker starts this nested navigation inside onBlocked. */ + onBlockedNav: "replace" | null; +} + +const ALL_ACTIONS: BlockableAction[] = [ + "Pushed", + "Popped", + "Replaced", + "StepPushed", + "StepPopped", + "StepReplaced", +]; + +function parseArmed(raw: string | null): ArmedActivity[] { + if (!raw) { + return []; + } + const armed: ArmedActivity[] = []; + for (const group of raw.split(";")) { + const [activity, actionList] = group.split(":"); + if (!activity) { + continue; + } + const actions = new Set( + (actionList ?? "") + .split("+") + .map((s) => s.trim()) + .filter((s): s is BlockableAction => + ALL_ACTIONS.includes(s as BlockableAction), + ), + ); + armed.push({ activity: activity as ActivityName, actions }); + } + return armed; +} + +/** + * The effective knob source: a driver-injected global (kept out of the route + * URL) when present, otherwise the URL query for manual/exploratory use. + */ +export function readHarnessSearch(): string { + if (typeof window !== "undefined" && window.__HARNESS_KNOBS__) { + return new URLSearchParams(window.__HARNESS_KNOBS__).toString(); + } + return typeof window !== "undefined" ? window.location.search : ""; +} + +export function parseHarnessConfig(search: string): HarnessConfig { + const q = new URLSearchParams(search); + + const order: RegistrationOrder = + q.get(queryKey.order) === "blocker-first" + ? "blocker-first" + : "blocker-last"; + + const probePlacementRaw = q.get(queryKey.probe); + const probeModeRaw = q.get(queryKey.probeMode); + const placement: ProbePlacement | null = + probePlacementRaw === "before" + ? "before" + : probePlacementRaw === "after" + ? "after" + : null; + const probe = placement + ? { placement, mode: (probeModeRaw as ProbeMode) ?? "count" } + : null; + + return { + order, + useHash: q.get(queryKey.hash) === "1", + lazyDelayMs: Number(q.get(queryKey.lazyDelay) ?? "0") || 0, + // Small but non-zero by default so the loading→idle settle is observable + // without slowing long sequences; widen per-test via the query. + transitionDuration: Number(q.get(queryKey.transitionDuration) ?? "30") || 0, + blockerCount: q.get(queryKey.blockers) === "2" ? 2 : 1, + blockAsync: q.get(queryKey.blockAsync) === "1", + armed: parseArmed(q.get(queryKey.block)), + probe, + onBlockedNav: q.get(queryKey.onBlockedNav) === "replace" ? "replace" : null, + }; +} + +/** The blocker ids mounted on a given activity, in order. */ +export function blockerIdsFor( + config: HarnessConfig, + activity: ActivityName, +): string[] { + if (!config.armed.some((a) => a.activity === activity)) { + return []; + } + return Array.from({ length: config.blockerCount }, (_, i) => `b${i + 1}`); +} + +/** The set of actions a given armed activity blocks. */ +export function armedActionsFor( + config: HarnessConfig, + activity: ActivityName, +): Set { + const entry = config.armed.find((a) => a.activity === activity); + return entry ? entry.actions : new Set(); +} diff --git a/e2e/src/app/useHarnessVersion.ts b/e2e/src/app/useHarnessVersion.ts new file mode 100644 index 000000000..890ee23c1 --- /dev/null +++ b/e2e/src/app/useHarnessVersion.ts @@ -0,0 +1,7 @@ +import { useSyncExternalStore } from "react"; +import { harnessStore } from "./harnessStore"; + +/** Re-render the caller whenever the harness store mutates. */ +export function useHarnessVersion(): number { + return useSyncExternalStore(harnessStore.subscribe, harnessStore.getVersion); +} diff --git a/e2e/src/dal/fixture.ts b/e2e/src/dal/fixture.ts new file mode 100644 index 000000000..04d1d9370 --- /dev/null +++ b/e2e/src/dal/fixture.ts @@ -0,0 +1,37 @@ +/** + * Per-file browser lifecycle for the t1 suites. Call `setupHarness()` inside a + * describe (or at file top) to get an `open(knobs)` factory; the browser is + * launched once per file and every opened harness is closed after each test. + */ + +import { type Browser, chromium } from "@playwright/test"; +import { Harness, type QueryKnobs } from "./harness"; + +export function setupHarness(): ( + knobs?: QueryKnobs, + initialPath?: string, +) => Promise { + let browser: Browser; + let opened: Harness[] = []; + + beforeAll(async () => { + browser = await chromium.launch({ + channel: process.env.HARNESS_BROWSER_CHANNEL || undefined, + }); + }); + + afterEach(async () => { + await Promise.all(opened.map((h) => h.close())); + opened = []; + }); + + afterAll(async () => { + await browser?.close(); + }); + + return async (knobs: QueryKnobs = {}, initialPath = "/") => { + const harness = await Harness.open(browser, knobs, initialPath); + opened.push(harness); + return harness; + }; +} diff --git a/e2e/src/dal/harness.ts b/e2e/src/dal/harness.ts new file mode 100644 index 000000000..888ab23f8 --- /dev/null +++ b/e2e/src/dal/harness.ts @@ -0,0 +1,463 @@ +/** + * Driver Abstraction Layer. + * + * Scenarios are written against this small set of primitives — open / click / + * fill / browserBack / browserForward / waitFor / read* / settle — so they are + * independent of the driver. Here the primitives are realized on a real + * Chromium page via the playwright library, but any real-browser automation + * that implements the same set would run the same scenarios. + * + * Settle is observed, never slept for: a step is "done" only once the public + * transition state is idle and a double-stable check (two snapshots separated + * by at least one animation frame and one macrotask) agrees. + */ + +import type { Browser, BrowserContext, Page } from "@playwright/test"; +import { + type BlockerLogEntry, + type LocationView, + type ProbeLogEntry, + type StackView, + testid, +} from "../shared/contract"; + +const BASE_URL = process.env.HARNESS_BASE_URL ?? "http://127.0.0.1:4173"; + +export type QueryKnobs = Record; + +function knobsRecord(knobs: QueryKnobs): Record { + const record: Record = {}; + for (const [k, v] of Object.entries(knobs)) { + if (v !== undefined && v !== false) { + record[k] = v === true ? "1" : String(v); + } + } + return record; +} + +const sel = (tid: string) => `[data-testid="${tid}"]`; + +/** The URL this app's route templates produce for a given stack top. */ +function expectedPath(active: StackView["active"]): string { + if (!active) { + return "/"; + } + const p = active.stepParams; + const title = p.title ? `?title=${p.title}` : ""; + switch (active.name) { + case "Home": + return "/"; + case "Article": + return `/articles/${p.articleId}/${title}`; + case "Third": + return `/third/${p.thirdId}/`; + case "Fourth": + return `/fourth/${p.fourthId}/`; + case "Lazy": + return "/lazy/"; + default: + return "/"; + } +} + +export class Harness { + private constructor( + readonly page: Page, + private readonly context: BrowserContext, + ) {} + + /** + * Open the harness with the given configuration knobs at the given initial + * path. Knobs are injected before the app loads (not via the route URL) so + * navigation assertions observe clean paths. + */ + static async open( + browser: Browser, + knobs: QueryKnobs = {}, + initialPath = "/", + ): Promise { + const context = await browser.newContext(); + const page = await context.newPage(); + // Cap waits so an unsatisfied positive condition (an expected red on the + // unfixed product) fails in seconds rather than the 30s default. + page.setDefaultTimeout(6000); + const record = knobsRecord(knobs); + await page.addInitScript((injected) => { + window.__HARNESS_KNOBS__ = injected; + }, record); + await page.goto(`${BASE_URL}${initialPath}`); + const harness = new Harness(page, context); + await harness.page.waitForFunction( + () => window.__harness__?.ready === true, + ); + await harness.settle(); + return harness; + } + + async close(): Promise { + await this.context.close(); + } + + // --- write primitives --- + + async click(tid: string): Promise { + await this.page.click(sel(tid)); + } + + async fill(tid: string, value: string): Promise { + await this.page.fill(sel(tid), value); + } + + async browserBack(): Promise { + await this.page.goBack(); + } + + async browserForward(): Promise { + await this.page.goForward(); + } + + // --- blocker interactions --- + + async confirm(blockerId: string): Promise { + await this.click(testid.blockConfirm(blockerId)); + } + + async cancelBlock(blockerId: string): Promise { + await this.click(testid.blockCancel(blockerId)); + } + + async toggleMount(blockerId: string): Promise { + await this.click(testid.blockerMountToggle(blockerId)); + } + + async toggleArm(blockerId: string): Promise { + await this.click(testid.blockerArmToggle(blockerId)); + } + + hasDialog(blockerId: string): Promise { + return this.isVisible(testid.blockDialog(blockerId)); + } + + /** Names of the actions for which some blocker's onBlocked fired, in order. */ + async blockedActions(): Promise { + const log = await this.readBlockerLog(); + return log.filter((e) => e.phase === "blocked").map((e) => e.action); + } + + /** ":" for every onBlocked notification, in order. */ + async blockedNotifications(): Promise { + const log = await this.readBlockerLog(); + return log + .filter((e) => e.phase === "blocked") + .map((e) => `${e.blockerId}:${e.action}`); + } + + /** Distinct actions any blocker's shouldBlock was consulted for. */ + async shouldBlockActions(): Promise { + const log = await this.readBlockerLog(); + return [ + ...new Set( + log.filter((e) => e.phase === "shouldBlock").map((e) => e.action), + ), + ]; + } + + // --- read primitives --- + + readStack(): Promise { + return this.page.evaluate(() => { + const h = window.__harness__; + if (!h) { + throw new Error( + "harness bridge missing: the app navigated away (history desync)", + ); + } + return h.getStack(); + }); + } + + readLocation(): Promise { + return this.page.evaluate(() => { + const h = window.__harness__; + if (!h) { + throw new Error( + "harness bridge missing: the app navigated away (history desync)", + ); + } + return h.getLocation(); + }); + } + + /** pathname + search + hash, mirroring the original suite's path() helper. */ + async readPath(): Promise { + const loc = await this.readLocation(); + return loc.pathname + loc.search + loc.hash; + } + + readBlockerLog(): Promise { + return this.page.evaluate(() => window.__harness__!.getBlockerLog()); + } + + readProbeLog(): Promise { + return this.page.evaluate(() => window.__harness__!.getProbeLog()); + } + + readErrors(): Promise { + return this.page.evaluate(() => window.__harness__!.getErrors()); + } + + readFallbackCount(): Promise { + return this.page.evaluate(() => window.__harness__!.getFallbackCallCount()); + } + + /** The activity name of the screen the user currently sees, from the DOM. */ + async readActiveScreen(): Promise { + return this.page.evaluate(() => { + const el = document.querySelector( + '[data-active="true"][data-testid^="screen-"]', + ); + return el?.getAttribute("data-testid")?.replace(/^screen-/, "") ?? null; + }); + } + + // --- visibility / presence --- + + isVisible(tid: string): Promise { + return this.page.isVisible(sel(tid)); + } + + /** Whether the page is still on the harness app (vs left it via back). */ + isOnHarness(): Promise { + return this.page.evaluate(() => Boolean(window.__harness__)); + } + + /** + * Navigability witness that the current entry is the bottom app entry: a + * `browserBack()` leaves the app entirely (there is no earlier app entry). + * Catches a broken implementation that created an extra browser entry where + * the public stack stayed shallow. + */ + async expectNoEarlierAppEntry(): Promise { + await this.browserBack(); + expect(await this.isOnHarness()).toBe(false); + } + + // --- composite navigation (commit + settle); use raw click for + // blocked/race scenarios where the navigation must not settle --- + + async pushArticle(articleId: string, title?: string): Promise { + await this.fill(testid.paramId, articleId); + await this.fill(testid.paramTitle, title ?? ""); + await this.click(testid.pushArticle); + await this.settle(); + } + + async pushThird(thirdId: string): Promise { + await this.fill(testid.paramId, thirdId); + await this.click(testid.pushThird); + await this.settle(); + } + + async pushFourth(fourthId: string): Promise { + await this.fill(testid.paramId, fourthId); + await this.click(testid.pushFourth); + await this.settle(); + } + + async replaceArticle(articleId: string, title?: string): Promise { + await this.fill(testid.paramId, articleId); + await this.fill(testid.paramTitle, title ?? ""); + await this.click(testid.replaceArticle); + await this.settle(); + } + + async replaceThird(thirdId: string): Promise { + await this.fill(testid.paramId, thirdId); + await this.click(testid.replaceThird); + await this.settle(); + } + + async replaceFourth(fourthId: string): Promise { + await this.fill(testid.paramId, fourthId); + await this.click(testid.replaceFourth); + await this.settle(); + } + + async pop(): Promise { + await this.click(testid.pop); + await this.settle(); + } + + async stepPushId(id: string): Promise { + await this.fill(testid.paramId, id); + await this.click(testid.stepPush); + await this.settle(); + } + + async stepPop(): Promise { + await this.click(testid.stepPop); + await this.settle(); + } + + async stepReplaceId(id: string): Promise { + await this.fill(testid.paramId, id); + await this.click(testid.stepReplace); + await this.settle(); + } + + // --- attempts (click without settling), for navigations that may be + // blocked or that must be observed mid-flight --- + + async attemptPushArticle(articleId: string, title?: string): Promise { + await this.fill(testid.paramId, articleId); + await this.fill(testid.paramTitle, title ?? ""); + await this.click(testid.pushArticle); + } + + async attemptReplaceThird(thirdId: string): Promise { + await this.fill(testid.paramId, thirdId); + await this.click(testid.replaceThird); + } + + async attemptPop(): Promise { + await this.click(testid.pop); + } + + async attemptStepPush(id: string): Promise { + await this.fill(testid.paramId, id); + await this.click(testid.stepPush); + } + + async attemptStepPop(): Promise { + await this.click(testid.stepPop); + } + + async attemptStepReplace(id: string): Promise { + await this.fill(testid.paramId, id); + await this.click(testid.stepReplace); + } + + /** Fire several browser-back presses in one turn (a rapid user burst). */ + async rapidBack(times: number): Promise { + await this.page.evaluate((n) => { + for (let i = 0; i < n; i += 1) { + window.history.back(); + } + }, times); + } + + /** + * browser == stack without pinning a specific resting activity: the URL must + * be exactly the one this app's public route templates produce for the + * current stack top (its visible step). Catches a URL that has drifted from + * the committed stack. Derived from the harness's own route contract, not + * from history-sync internals. + */ + async expectBrowserStack(): Promise { + const stack = await this.readStack(); + const screen = await this.readActiveScreen(); + expect(screen).toBe(stack.active?.name ?? null); + expect(await this.readPath()).toBe(expectedPath(stack.active)); + } + + /** Real browser Back/Forward then settle. */ + async goBack(): Promise { + await this.browserBack(); + await this.settle(); + } + + async goForward(): Promise { + await this.browserForward(); + await this.settle(); + } + + // --- waits (positive-condition polling; timeout = failure) --- + + async waitForActive(name: string): Promise { + await this.page.waitForFunction( + (n) => window.__harness__?.getStack().active?.name === n, + name, + ); + } + + async waitForPath(path: string): Promise { + await this.page.waitForFunction((p) => { + const l = window.__harness__!.getLocation(); + return l.pathname + l.search + l.hash === p; + }, path); + } + + async waitForDialog(blockerId: string): Promise { + await this.page.waitForSelector(sel(testid.blockDialog(blockerId))); + } + + /** Wait until the stack leaves idle — used to confirm a race window opened. */ + async waitForNonIdle(): Promise { + await this.page.waitForFunction( + () => window.__harness__!.getStack().globalTransitionState !== "idle", + ); + } + + async waitFor( + predicate: (arg: unknown) => boolean, + arg?: unknown, + ): Promise { + await this.page.waitForFunction(predicate, arg); + } + + /** + * Settle to a quiet point: first idle, then double-stable. The two snapshots + * are separated by ≥1 animation frame + 1 macrotask so a same-frame transient + * isn't mistaken for stability. + */ + async settle(timeoutMs = 8000): Promise { + await this.page.evaluate(async (timeout) => { + const h = window.__harness__; + if (!h) { + throw new Error( + "harness bridge missing: the app navigated away (history desync)", + ); + } + const snapshot = () => { + const stack = h.getStack(); + const loc = h.getLocation(); + return JSON.stringify({ + global: stack.globalTransitionState, + name: stack.active?.name ?? null, + params: stack.active?.params ?? null, + stepCount: stack.active?.stepCount ?? 0, + transition: stack.active?.transitionState ?? null, + href: loc.href, + }); + }; + const tick = () => + new Promise((resolve) => + requestAnimationFrame(() => setTimeout(resolve, 0)), + ); + const deadline = Date.now() + timeout; + + while (h.getStack().globalTransitionState !== "idle") { + if (Date.now() > deadline) { + throw new Error("settle: stack never reached idle"); + } + await tick(); + } + + let previous = snapshot(); + for (;;) { + await tick(); + const current = snapshot(); + if ( + current === previous && + h.getStack().globalTransitionState === "idle" + ) { + return; + } + previous = current; + if (Date.now() > deadline) { + throw new Error("settle: stack did not stabilize"); + } + } + }, timeoutMs); + } +} diff --git a/e2e/src/shared/contract.ts b/e2e/src/shared/contract.ts new file mode 100644 index 000000000..068893398 --- /dev/null +++ b/e2e/src/shared/contract.ts @@ -0,0 +1,169 @@ +/** + * The observable contract between the harness app and its drivers. + * + * Everything a driver is allowed to read or actuate lives here: DOM test ids, + * URL query knobs, and the shape of the `window.__harness__` instrumentation + * bridge. Keeping it in one module means the app and every driver agree on the + * contract by construction rather than by convention. + * + * The bridge deliberately exposes only public-API observations (the public + * `getStack()` snapshot, `window.location`) and observations the harness itself + * owns (its blocker's shouldBlock/onBlocked notifications, its probe co-plugin's + * own hook calls, its error sink). It never exposes history-sync internals + * (entry ordinals, suppression tokens, the sync queue, or history-sync's own + * before/after hook calls). + */ + +/** Activities the harness app registers. All support step navigation. */ +export type ActivityName = "Home" | "Article" | "Third" | "Fourth" | "Lazy"; + +/** Navigation actions a blocker can be armed against. */ +export type BlockableAction = + | "Pushed" + | "Popped" + | "Replaced" + | "StepPushed" + | "StepPopped" + | "StepReplaced"; + +/** A stable identifier for one armed blocker instance (e.g. "b1", "b2"). */ +export type BlockerId = string; + +/** DOM markers each rendered screen exposes. */ +export const testid = { + screen: (activity: ActivityName) => `screen-${activity}`, + /** Serialized params of the active activity (JSON). */ + activityParams: "activity-params", + /** Current step index of the active activity. */ + stepIndex: "step-index", + /** Transition state of the active activity. */ + transitionState: "transition-state", + + /** Visible "currently blocking" indicator for a blocker. */ + blocking: (id: BlockerId) => `blocking-${id}`, + /** Per-blocker confirmation dialog raised by onBlocked. */ + blockDialog: (id: BlockerId) => `block-dialog-${id}`, + /** Calls the captured proceed() for that blocker. */ + blockConfirm: (id: BlockerId) => `block-confirm-${id}`, + /** Discards the dialog, leaving the navigation blocked. */ + blockCancel: (id: BlockerId) => `block-cancel-${id}`, + /** Toggles mount/unmount of a blocker-owning component (lifecycle cases). */ + blockerMountToggle: (id: BlockerId) => `blocker-mount-toggle-${id}`, + /** Toggles whether a mounted blocker is armed (re-renders shouldBlock). */ + blockerArmToggle: (id: BlockerId) => `blocker-arm-toggle-${id}`, + + /** Primary id field read by push/replace/step controls. */ + paramId: "param-id", + /** Title field read by Article push/replace controls. */ + paramTitle: "param-title", + + pushArticle: "push-article", + pushThird: "push-third", + pushFourth: "push-fourth", + pushLazy: "push-lazy", + replaceArticle: "replace-article", + replaceThird: "replace-third", + replaceFourth: "replace-fourth", + pop: "pop", + stepPush: "step-push", + stepPop: "step-pop", + stepReplace: "step-replace", +} as const; + +/** URL query knobs that configure one harness app instance. */ +export const queryKey = { + /** "blocker-first" | "blocker-last" (default) — plugin registration order. */ + order: "order", + /** "1" → historySyncPlugin useHash:true. */ + hash: "hash", + /** Milliseconds the Lazy activity withholds its content (race window width). */ + lazyDelay: "lazyDelay", + /** + * Which activities arm a blocker and against which actions, e.g. + * `Article:Popped` or `Home:Pushed` or `Article:Pushed+Popped`. + * Multiple activities separated by ";". + */ + block: "block", + /** "2" → arm two blocker instances (b1,b2) per armed activity. */ + blockers: "blockers", + /** "1" → proceed() is invoked across an async gap (deferred confirm). */ + blockAsync: "blockAsync", + /** "before" | "after" — register the probe co-plugin before/after blocker. */ + probe: "probe", + /** "count" | "nested" | "prevent" — probe behavior on replay. */ + probeMode: "probeMode", + /** "replace" — the armed blocker starts a nested navigation inside onBlocked. */ + onBlockedNav: "onBlockedNav", + /** Milliseconds for the activity enter/exit transition (default small). */ + transitionDuration: "transitionDuration", +} as const; + +export type RegistrationOrder = "blocker-first" | "blocker-last"; +export type ProbePlacement = "before" | "after"; +export type ProbeMode = "count" | "nested" | "prevent"; + +export interface BlockerLogEntry { + blockerId: BlockerId; + action: BlockableAction; + phase: "shouldBlock" | "blocked" | "proceed"; + /** Set when the blocker's onBlocked threw (observed via the error sink). */ + threw?: boolean; +} + +export interface ProbeLogEntry { + /** The probe's own before-hook that fired, e.g. "onBeforePush". */ + hook: string; + /** Monotonic call index for that hook (1 = first attempt, 2 = replay). */ + count: number; +} + +/** A serialized, internals-free view of one activity. */ +export interface StackActivityView { + name: string; + params: Record; + transitionState: string; + isActive: boolean; + stepCount: number; + stepParams: Record; +} + +/** A serialized, internals-free view of the public stack snapshot. */ +export interface StackView { + globalTransitionState: "idle" | "loading" | "paused"; + activities: StackActivityView[]; + /** The active (visible) activity, or null. */ + active: StackActivityView | null; +} + +export interface LocationView { + href: string; + pathname: string; + search: string; + hash: string; +} + +/** The instrumentation bridge installed at `window.__harness__`. */ +export interface HarnessBridge { + /** True once the initial route has settled. */ + ready: boolean; + getStack(): StackView; + getLocation(): LocationView; + /** Times the historySync fallbackActivity callback ran (initial routing). */ + getFallbackCallCount(): number; + getBlockerLog(): BlockerLogEntry[]; + getProbeLog(): ProbeLogEntry[]; + /** Errors delivered to the blocker plugin's onError sink. */ + getErrors(): string[]; +} + +declare global { + interface Window { + __harness__?: HarnessBridge; + /** + * Configuration knobs injected by the driver before the app loads, kept + * out of the route URL so navigation assertions see clean paths. Falls back + * to the URL query when absent (for manual / exploratory use). + */ + __HARNESS_KNOBS__?: Record; + } +} diff --git a/e2e/src/t1/blocker.spec.ts b/e2e/src/t1/blocker.spec.ts new file mode 100644 index 000000000..8a0403fe0 --- /dev/null +++ b/e2e/src/t1/blocker.spec.ts @@ -0,0 +1,388 @@ +/** + * plugin-blocker behaviors, reproduced in a real browser with history-sync + * applied alongside. The blocker's public contract (which onBlocked fires, in + * what order, per-blocker dialogs, proceed) is observed via the harness log and + * the per-blocker dialogs; the coexistence result is the common terminal + * assertion: at every quiet point SCREEN, URL and STACK agree (browser == + * stack). history-sync's internal hooks are never asserted. + * + * Sources map to the original blockerPlugin suite sections 1-8. The call-order + * and error-isolation cases (no real-history timing dimension) live in the + * jsdom integration tier. + */ + +import { setupHarness } from "../dal/fixture"; + +const open = setupHarness(); + +type H = Awaited>; + +/** browser == stack: the visible screen, the public stack top and the URL agree. */ +async function expectAt(h: H, name: string, path: string): Promise { + expect(await h.readActiveScreen()).toBe(name); + expect((await h.readStack()).active?.name).toBe(name); + expect(await h.readPath()).toBe(path); +} + +describe("plugin-blocker × history-sync (real browser)", () => { + // --- basic blocking --- + + test("an armed blocker blocks a programmatic pop", async () => { + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.settle(); + expect(await h.blockedActions()).toContain("Popped"); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("an armed blocker blocks a push", async () => { + const h = await open({ block: "Home:Pushed" }); + await h.attemptPushArticle("1"); + await h.waitForDialog("b1"); + await h.settle(); + expect(await h.blockedActions()).toContain("Pushed"); + await expectAt(h, "Home", "/"); + }); + + test("an armed blocker blocks a replace", async () => { + const h = await open({ block: "Home:Replaced" }); + await h.attemptReplaceThird("1"); + await h.waitForDialog("b1"); + await h.settle(); + expect(await h.blockedActions()).toContain("Replaced"); + await expectAt(h, "Home", "/"); + }); + + test("an armed blocker blocks a stepPush", async () => { + const h = await open({ block: "Article:StepPushed" }); + await h.pushArticle("1"); + await h.attemptStepPush("2"); + await h.waitForDialog("b1"); + await h.settle(); + expect(await h.blockedActions()).toContain("StepPushed"); + expect((await h.readStack()).active?.stepCount).toBe(1); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("an armed blocker blocks a stepPop", async () => { + const h = await open({ block: "Article:StepPopped" }); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.attemptStepPop(); + await h.waitForDialog("b1"); + await h.settle(); + expect(await h.blockedActions()).toContain("StepPopped"); + expect((await h.readStack()).active?.stepCount).toBe(2); + await expectAt(h, "Article", "/articles/2/"); + }); + + test("an armed blocker blocks a stepReplace", async () => { + const h = await open({ block: "Article:StepReplaced" }); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.attemptStepReplace("9"); + await h.waitForDialog("b1"); + await h.settle(); + expect(await h.blockedActions()).toContain("StepReplaced"); + expect((await h.readStack()).active?.params.articleId).toBe("2"); + await expectAt(h, "Article", "/articles/2/"); + }); + + // --- basic allowing --- + + test("a disarmed blocker allows navigation", async () => { + const h = await open(); + await h.pushArticle("1"); + expect(await h.hasDialog("b1")).toBe(false); + await expectAt(h, "Article", "/articles/1/"); + }); + + // --- selective blocking / last committed render --- + + test("a blocker can block Replaced while allowing Pushed", async () => { + const h = await open({ block: "Home:Replaced" }); + await h.attemptReplaceThird("1"); + await h.waitForDialog("b1"); + await h.settle(); + await expectAt(h, "Home", "/"); + await h.pushArticle("1"); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("the last committed render's shouldBlock is the one applied", async () => { + const h = await open({ block: "Home:Pushed" }); + await h.toggleArm("b1"); // disarm via re-render + await h.pushArticle("1"); + await expectAt(h, "Article", "/articles/1/"); + }); + + // --- activity scope --- + + test("a blocker on a lower activity does not block the active one", async () => { + const h = await open({ block: "Home:Popped" }); + await h.pushArticle("1"); + await h.pop(); + await expectAt(h, "Home", "/"); + }); + + test("a lower activity's blocker reactivates once it becomes active again", async () => { + const h = await open({ block: "Home:Pushed" }); + await h.toggleArm("b1"); // start disarmed so the first push is allowed + await h.pushArticle("1"); + await h.toggleArm("b1"); // re-arm while Home is inactive + await h.pop(); + await expectAt(h, "Home", "/"); + await h.attemptPushArticle("2"); + await h.waitForDialog("b1"); + await h.settle(); + await expectAt(h, "Home", "/"); + }); + + test("a replaced activity's blocker leaves no ghost", async () => { + const h = await open({ block: "Home:Pushed" }); + await h.replaceThird("1"); + await expectAt(h, "Third", "/third/1/"); + await h.pushArticle("1"); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("a popped activity's blocker leaves no ghost", async () => { + const h = await open({ block: "Article:Pushed" }); + await h.pushArticle("1"); + await h.pop(); + await expectAt(h, "Home", "/"); + await h.pushArticle("2"); + await expectAt(h, "Article", "/articles/2/"); + }); + + // --- notifications --- + + test("a blocked navigation invokes onBlocked", async () => { + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.settle(); + expect(await h.blockedNotifications()).toEqual(["b1:Popped"]); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("only the blocking blocker's onBlocked fires", async () => { + const h = await open({ block: "Article:Popped", blockers: 2 }); + await h.pushArticle("1"); + await h.toggleArm("b2"); // b2 no longer blocks + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.settle(); + expect(await h.hasDialog("b1")).toBe(true); + expect(await h.hasDialog("b2")).toBe(false); + expect(await h.blockedNotifications()).toEqual(["b1:Popped"]); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("a non-blocked navigation does not invoke onBlocked", async () => { + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.stepPushId("2"); + expect(await h.hasDialog("b1")).toBe(false); + expect((await h.readStack()).active?.stepCount).toBe(2); + await expectAt(h, "Article", "/articles/2/"); + }); + + // --- proceed --- + // Proceeding a blocked pop is red on the unfixed product: the blocked pop + // already moved the browser, so the proceed's commit runs a second back that + // navigates the app away (the bridge reports "navigated away"). On the fixed + // product the proceed commits cleanly — a fast green. + + test("a single blocker's proceed runs the blocked navigation", async () => { + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.confirm("b1"); + await h.settle(); + await expectAt(h, "Home", "/"); + }); + + test("with two blockers, proceeding one leaves the navigation blocked", async () => { + const h = await open({ block: "Article:Popped", blockers: 2 }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.waitForDialog("b2"); + await h.confirm("b1"); + await h.settle(); + expect(await h.hasDialog("b2")).toBe(true); + await expectAt(h, "Article", "/articles/1/"); + await h.confirm("b2"); + await h.settle(); + await expectAt(h, "Home", "/"); + }); + + test("with two blockers, proceeding both runs the navigation", async () => { + const h = await open({ block: "Article:Popped", blockers: 2 }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.waitForDialog("b2"); + await h.confirm("b1"); + await h.confirm("b2"); + await h.settle(); + await expectAt(h, "Home", "/"); + }); + + test("proceeding twice runs the navigation only once", async () => { + const h = await open({ block: "Home:Pushed" }); + await h.attemptPushArticle("1"); + await h.waitForDialog("b1"); + await h.confirm("b1"); + await h.confirm("b1"); + await h.settle(); + await expectAt(h, "Article", "/articles/1/"); + expect((await h.readStack()).activities).toHaveLength(2); + await h.goBack(); + await expectAt(h, "Home", "/"); + }); + + // --- composition (multiple blockers) --- + + test("every blocking blocker is notified", async () => { + const h = await open({ block: "Article:Popped", blockers: 2 }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.waitForDialog("b2"); + await h.settle(); + expect(await h.blockedNotifications()).toEqual(["b1:Popped", "b2:Popped"]); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("only the blocker that wants to block is notified", async () => { + const h = await open({ block: "Article:Popped", blockers: 2 }); + await h.pushArticle("1"); + await h.toggleArm("b2"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.settle(); + expect(await h.hasDialog("b2")).toBe(false); + expect(await h.blockedNotifications()).toEqual(["b1:Popped"]); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("if any blocker blocks, the navigation is blocked", async () => { + const h = await open({ block: "Article:Popped", blockers: 2 }); + await h.pushArticle("1"); + await h.toggleArm("b2"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.settle(); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("if no blocker blocks, the navigation is allowed", async () => { + const h = await open({ block: "Article:Popped", blockers: 2 }); + await h.pushArticle("1"); + await h.toggleArm("b1"); + await h.toggleArm("b2"); + await h.pop(); + await expectAt(h, "Home", "/"); + }); + + // --- lifecycle --- + + test("unmounting a blocker stops it from blocking", async () => { + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.toggleMount("b1"); + await h.pop(); + await expectAt(h, "Home", "/"); + }); + + test("unmounting a blocker stops its onBlocked from firing", async () => { + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.toggleMount("b1"); + await h.pop(); + expect(await h.hasDialog("b1")).toBe(false); + await expectAt(h, "Home", "/"); + }); + + test("a captured proceed still works after unmount; another blocker keeps it blocked", async () => { + const h = await open({ block: "Article:Popped", blockers: 2 }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.waitForDialog("b2"); + await h.toggleMount("b2"); + await h.confirm("b2"); + await h.settle(); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("a captured proceed still works after unmount; the sole blocker runs it", async () => { + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.toggleMount("b1"); + await h.confirm("b1"); + await h.settle(); + await expectAt(h, "Home", "/"); + }); + + // --- replay interaction with a co-plugin (synthetic probe) --- + + describe.each([ + { placement: "before" as const }, + { placement: "after" as const }, + ])("a co-plugin registered $placement the blocker", ({ placement }) => { + test("its onBeforePush runs again on replay", async () => { + const h = await open({ + block: "Home:Pushed", + probe: placement, + probeMode: "count", + }); + await h.attemptPushArticle("1"); + await h.waitForDialog("b1"); + await h.confirm("b1"); + await h.settle(); + const pushHooks = (await h.readProbeLog()).filter( + (e) => e.hook === "onBeforePush", + ); + expect(pushHooks).toHaveLength(2); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("a navigation it runs during replay goes through the blocker", async () => { + const h = await open({ + block: "Home:Pushed", + probe: placement, + probeMode: "nested", + }); + await h.attemptPushArticle("1"); + await h.waitForDialog("b1"); + await h.confirm("b1"); + await h.settle(); + expect(await h.shouldBlockActions()).toContain("Popped"); + // browser == stack regardless of where the nested navigation rests. + const stack = await h.readStack(); + expect(await h.readActiveScreen()).toBe(stack.active?.name ?? null); + }); + + test("preventDefault during replay cancels the replay", async () => { + const h = await open({ + block: "Home:Pushed", + probe: placement, + probeMode: "prevent", + }); + await h.attemptPushArticle("1"); + await h.waitForDialog("b1"); + await h.confirm("b1"); + await h.settle(); + await expectAt(h, "Home", "/"); + }); + }); +}); diff --git a/e2e/src/t1/compat.spec.ts b/e2e/src/t1/compat.spec.ts new file mode 100644 index 000000000..da6b12882 --- /dev/null +++ b/e2e/src/t1/compat.spec.ts @@ -0,0 +1,104 @@ +/** + * The preventDefault-consumer coexistence contract, using the blocker's + * representative model: block now, let the user confirm, then re-issue the same + * action. At every quiet point browser == stack; on proceed the re-issued + * action syncs exactly, even across an async confirmation gap and with no + * leaked counter affecting a subsequent navigation. + */ + +import { setupHarness } from "../dal/fixture"; + +const open = setupHarness(); + +type H = Awaited>; + +async function expectAt(h: H, name: string, path: string): Promise { + expect(await h.readActiveScreen()).toBe(name); + expect((await h.readStack()).active?.name).toBe(name); + expect(await h.readPath()).toBe(path); +} + +describe("blocker × history-sync coexistence contract", () => { + test("blocking a programmatic action keeps screen and URL at the blocked spot", async () => { + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.settle(); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("blocking a browser-initiated action restores to the blocked spot", async () => { + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.browserBack(); + await h.settle(); + await expectAt(h, "Article", "/articles/1/"); + expect(await h.hasDialog("b1")).toBe(true); + }); + + // The proceed cases below are red on the unfixed product because the blocked + // pop already moved the browser (queued history.back), so a proceed either + // can't complete the commit (the wait times out) or runs a second back that + // navigates the app away. On the fixed product they are fast greens. + test("proceeding across an async gap commits and a later push syncs exactly", async () => { + const h = await open({ block: "Article:Popped", blockAsync: true }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.confirm("b1"); + // The proceed crosses an async gap; wait for the commit, not a fixed sleep. + await h.waitForActive("Home"); + await h.settle(); + await expectAt(h, "Home", "/"); + await h.pushArticle("2"); + await expectAt(h, "Article", "/articles/2/"); + }); + + test("the block/proceed cycle works regardless of plugin order", async () => { + const h = await open({ block: "Article:Popped", order: "blocker-first" }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.confirm("b1"); + await h.settle(); + await expectAt(h, "Home", "/"); + }); + + test("with two blockers, the action runs only after both proceed", async () => { + const h = await open({ block: "Article:Popped", blockers: 2 }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.waitForDialog("b2"); + await h.confirm("b1"); + await h.settle(); + await expectAt(h, "Article", "/articles/1/"); + expect(await h.hasDialog("b2")).toBe(true); + await h.confirm("b2"); + await h.settle(); + await expectAt(h, "Home", "/"); + }); + + test("proceeding twice runs the action once", async () => { + // Two levels above Home so a second (erroneous) pop would be observable: + // one pop lands on Article(1); a double execution would reach Home. + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.pushArticle("2"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.confirm("b1"); + await h.confirm("b1"); + await h.settle(); + // Exactly one pop happened: still at Article(1), not Home. + await expectAt(h, "Article", "/articles/1/"); + // And it left clean browser entries: disarm and browser back reaches Home + // once (proving we sit on a single Article(1) entry above Home, not on Home + // from a duplicated pop). + await h.toggleArm("b1"); + await h.browserBack(); + await h.settle(); + await expectAt(h, "Home", "/"); + }); +}); diff --git a/e2e/src/t1/concurrency.spec.ts b/e2e/src/t1/concurrency.spec.ts new file mode 100644 index 000000000..a77632b83 --- /dev/null +++ b/e2e/src/t1/concurrency.spec.ts @@ -0,0 +1,123 @@ +/** + * Concurrency, reentrancy and race coverage absent from the original suites. + * The solution's guarantees (serial sync queue, suppression token, coalescing, + * idle gating, no race arbitration) are checked as observable behavior: at the + * settling point the URL matches the committed stack (browser == stack), and + * double-stable settling rules out a transient mid-glitch. Where a race has no + * single "winner" the assertion is consistency only, not a specific outcome. + */ + +import { setupHarness } from "../dal/fixture"; + +const open = setupHarness(); + +type H = Awaited>; + +async function expectAt(h: H, name: string, path: string): Promise { + expect(await h.readActiveScreen()).toBe(name); + expect((await h.readStack()).active?.name).toBe(name); + expect(await h.readPath()).toBe(path); +} + +describe("concurrency and reentrancy", () => { + test("several rapid browser-backs settle consistently at the bottom", async () => { + const h = await open(); + await h.pushArticle("1"); + await h.pushArticle("2"); + await h.pushArticle("3"); + await h.rapidBack(3); + await h.settle(); + await expectAt(h, "Home", "/"); + }); + + test("a user back during a lazy push settles consistently", async () => { + // A base entry below the lazy push so the injected back stays in the app. + const h = await open({ lazyDelay: 600 }); + await h.pushArticle("1"); + await h.click("push-lazy"); + await h.waitForNonIdle(); + await h.browserBack(); + await h.settle(); + await h.expectBrowserStack(); + }); + + test("a user nav during a self-induced multi-step shrink settles consistently", async () => { + // A base entry below the activity being popped so the injected back stays + // in the app while the self-induced multi-step shrink is in flight. A wider + // transition keeps the shrink window open long enough to enter positively. + const h = await open({ transitionDuration: 200 }); + await h.pushArticle("0"); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.click("pop"); + // Inject the user back only after the self-induced shrink window is open; + // if it never opens the wait times out (failure), not a trivial green. + await h.waitForNonIdle(); + await h.rapidBack(1); + await h.settle(); + await h.expectBrowserStack(); + }); + + test("a burst of buffered pushes then a pop coalesces to a consistent point", async () => { + // The pushes are issued while a lazy push holds the stack paused, so they + // buffer and commit together on resume — a deterministic way to force + // several sync reservations to coalesce, without timing-dependent clicks. + const h = await open({ lazyDelay: 500 }); + await h.click("push-lazy"); + await h.waitForNonIdle(); + await h.fill("param-id", "1"); + await h.click("push-article"); + await h.fill("param-id", "2"); + await h.click("push-article"); + await h.fill("param-id", "3"); + await h.click("push-article"); + await h.click("pop"); + await h.settle(); + await h.expectBrowserStack(); + }); + + test("a nested navigation started inside onBlocked settles consistently", async () => { + const h = await open({ block: "Article:Popped", onBlockedNav: "replace" }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.settle(); + await h.expectBrowserStack(); + }); + + test("a blocked browser back discards the forward redo entry consistently", async () => { + const h = await open({ block: "Article:Popped" }); + await h.toggleArm("b1"); // allow setup navigation + await h.pushArticle("a"); + await h.pushArticle("b"); + await h.browserBack(); + await h.settle(); + await expectAt(h, "Article", "/articles/a/"); + await h.toggleArm("b1"); // re-arm; the next back must be vetoable + await h.browserBack(); + await h.settle(); + await expectAt(h, "Article", "/articles/a/"); + // The forward redo entry was discarded by the restore: forward no longer + // reaches Article(b). + await h.toggleArm("b1"); + await h.browserForward(); + await h.settle(); + await expectAt(h, "Article", "/articles/a/"); + }); + + test("a user back right after a no-op sync pass is not swallowed", async () => { + const h = await open(); + await h.pushArticle("1"); + await h.pushArticle("2"); + // First back reaches Article(1); the browser is already there so the sync + // pass moves nothing (a no-op pass). Confirm it has settled (double-stable). + await h.browserBack(); + await h.settle(); + await expectAt(h, "Article", "/articles/1/"); + // The immediately following back must still be processed. + await h.browserBack(); + await h.settle(); + await expectAt(h, "Home", "/"); + }); +}); diff --git a/e2e/src/t1/globalSetup.cjs b/e2e/src/t1/globalSetup.cjs new file mode 100644 index 000000000..c981f0669 --- /dev/null +++ b/e2e/src/t1/globalSetup.cjs @@ -0,0 +1,26 @@ +/** + * Builds the harness app and serves it from an in-process vite preview server + * for the duration of the t1 run. The production build keeps StrictMode off. + * The server lives in the jest main process; worker browsers reach it over TCP. + */ +const path = require("node:path"); + +module.exports = async () => { + const { build, preview } = await import("vite"); + const root = path.resolve(__dirname, "..", ".."); + const configFile = path.resolve(root, "vite.config.ts"); + const port = Number(process.env.HARNESS_PORT || 4173); + + await build({ root, configFile, logLevel: "warn" }); + + const server = await preview({ + root, + configFile, + preview: { port, strictPort: true, host: "127.0.0.1" }, + }); + + globalThis.__HARNESS_PREVIEW__ = server; + process.env.HARNESS_BASE_URL = `http://127.0.0.1:${port}`; + // eslint-disable-next-line no-console + console.log(`\n[t1] harness served at ${process.env.HARNESS_BASE_URL}`); +}; diff --git a/e2e/src/t1/globalTeardown.cjs b/e2e/src/t1/globalTeardown.cjs new file mode 100644 index 000000000..e17e8766c --- /dev/null +++ b/e2e/src/t1/globalTeardown.cjs @@ -0,0 +1,6 @@ +module.exports = async () => { + const server = globalThis.__HARNESS_PREVIEW__; + if (server?.httpServer) { + await new Promise((resolve) => server.httpServer.close(() => resolve())); + } +}; diff --git a/e2e/src/t1/history-sync.spec.ts b/e2e/src/t1/history-sync.spec.ts new file mode 100644 index 000000000..37e19443e --- /dev/null +++ b/e2e/src/t1/history-sync.spec.ts @@ -0,0 +1,354 @@ +/** + * history-sync navigation behaviors, reproduced in a real browser with both + * plugins applied and the blocker disarmed (transparent). These are the + * baseline guarantees: with a transparent blocker present, navigation must + * behave exactly as history-sync alone. Each step ends with the visible screen, + * the URL and the public stack agreeing (browser == stack). + * + * Sources map to the original historySyncPlugin suite. URLs follow the route + * templates (trailing slash before the query, as the originals assert); the + * Home route is "/". + */ + +import { setupHarness } from "../dal/fixture"; + +const open = setupHarness(); + +async function expectScreen( + h: Awaited>, + name: string, + path: string, +) { + expect(await h.readActiveScreen()).toBe(name); + expect(await h.readPath()).toBe(path); +} + +describe("history-sync baseline (both plugins applied, blocker disarmed)", () => { + // --- initial routing --- + + test("an initial URL with no matching route falls back to Home", async () => { + const h = await open({}, "/non-existent-path"); + await expectScreen(h, "Home", "/"); + }); + + test("an initial URL's path and query become the activity params", async () => { + const h = await open({}, "/articles/123/?title=hello"); + const stack = await h.readStack(); + expect(stack.active?.name).toBe("Article"); + expect(stack.active?.params.articleId).toBe("123"); + expect(stack.active?.params.title).toBe("hello"); + expect(await h.readPath()).toBe("/articles/123/?title=hello"); + }); + + test("a matching initial URL does not invoke the fallback", async () => { + const h = await open({}, "/articles/123"); + expect(await h.readActiveScreen()).toBe("Article"); + expect(await h.readFallbackCount()).toBe(0); + }); + + test("a non-matching initial URL invokes the fallback exactly once", async () => { + const h = await open({}, "/non-existent-path"); + expect(await h.readFallbackCount()).toBe(1); + }); + + // --- push / replace / useHash --- + + test("pushing an activity updates the URL", async () => { + const h = await open(); + await h.pushArticle("1234", "hello"); + await expectScreen(h, "Article", "/articles/1234/?title=hello"); + }); + + test("with hash routing, pushing updates the hash URL", async () => { + const h = await open({ hash: true }); + await h.pushArticle("1234", "hello"); + await expectScreen(h, "Article", "/#/articles/1234/?title=hello"); + }); + + test("replacing the initial activity updates the URL without growing depth", async () => { + const h = await open(); + await h.replaceArticle("1234", "hello"); + await expectScreen(h, "Article", "/articles/1234/?title=hello"); + expect((await h.readStack()).activities).toHaveLength(1); + // Replace must not have added a browser entry: browser back leaves the app + // (no earlier app entry below the replaced initial activity). This catches + // an implementation that pushState'd while replacing — the public depth + // would still read 1 but a stray back entry would survive. + await h.expectNoEarlierAppEntry(); + }); + + test("repeated push then pop walks the URL back down", async () => { + const h = await open(); + await h.pushArticle("1", "hello"); + await expectScreen(h, "Article", "/articles/1/?title=hello"); + await h.pushArticle("2", "hello"); + await expectScreen(h, "Article", "/articles/2/?title=hello"); + await h.pushArticle("3", "hello"); + await expectScreen(h, "Article", "/articles/3/?title=hello"); + await h.pop(); + await expectScreen(h, "Article", "/articles/2/?title=hello"); + await h.pop(); + await expectScreen(h, "Article", "/articles/1/?title=hello"); + await h.pop(); + await expectScreen(h, "Home", "/"); + }); + + // --- browser back / forward --- + + test("browser back after a push returns to Home", async () => { + const h = await open(); + await h.pushArticle("1234", "hello"); + await h.goBack(); + await expectScreen(h, "Home", "/"); + }); + + test("repeated browser back walks the stack down", async () => { + const h = await open(); + await h.pushArticle("1", "hello"); + await h.pushArticle("2", "hello"); + await h.pushArticle("3", "hello"); + await h.goBack(); + await expectScreen(h, "Article", "/articles/2/?title=hello"); + await h.goBack(); + await expectScreen(h, "Article", "/articles/1/?title=hello"); + await h.goBack(); + await expectScreen(h, "Home", "/"); + }); + + test("browser forward re-walks the stack up", async () => { + const h = await open(); + await h.pushArticle("1", "hello"); + await h.pushArticle("2", "hello"); + await h.pushArticle("3", "hello"); + await h.goBack(); + await h.goBack(); + await h.goBack(); + await expectScreen(h, "Home", "/"); + await h.goForward(); + await expectScreen(h, "Article", "/articles/1/?title=hello"); + await h.goForward(); + await expectScreen(h, "Article", "/articles/2/?title=hello"); + await h.goForward(); + await expectScreen(h, "Article", "/articles/3/?title=hello"); + }); + + // --- step navigation + pop removing many entries --- + + test("popping an activity removes all of its step entries at once", async () => { + const h = await open(); + await h.pushArticle("10", "hello"); + await h.stepPushId("11"); + await expectScreen(h, "Article", "/articles/11/?title=hello"); + await h.stepPushId("12"); + await expectScreen(h, "Article", "/articles/12/?title=hello"); + await h.pushArticle("20", "world"); + await expectScreen(h, "Article", "/articles/20/?title=world"); + await h.pop(); + await expectScreen(h, "Article", "/articles/12/?title=hello"); + await h.pop(); + await expectScreen(h, "Home", "/"); + }); + + test("stepPop past the base step is a no-op", async () => { + const h = await open(); + await h.pushArticle("10", "hello"); + await h.stepPushId("11"); + await h.stepPushId("12"); + await h.stepPop(); + await expectScreen(h, "Article", "/articles/11/?title=hello"); + await h.stepPop(); + await expectScreen(h, "Article", "/articles/10/?title=hello"); + await h.stepPop(); + await expectScreen(h, "Article", "/articles/10/?title=hello"); + await h.pop(); + await expectScreen(h, "Home", "/"); + }); + + test("stepReplace updates the top step; pop then removes the activity", async () => { + const h = await open(); + await h.pushArticle("10", "hello"); + await h.stepPushId("11"); + await h.stepReplaceId("12"); + await expectScreen(h, "Article", "/articles/12/?title=hello"); + await h.stepPop(); + await expectScreen(h, "Article", "/articles/10/?title=hello"); + await h.pop(); + await expectScreen(h, "Home", "/"); + }); + + test("browser back/forward walk through step entries, then pop removes the activity", async () => { + const h = await open(); + await h.pushArticle("10", "hello"); + await h.stepPushId("11"); + await h.stepPushId("12"); + await h.pushArticle("20", "world"); + + const articleId = async () => + (await h.readStack()).active?.params.articleId; + + await h.goBack(); + expect(await articleId()).toBe("12"); + await h.goBack(); + expect(await articleId()).toBe("11"); + await h.goBack(); + expect(await articleId()).toBe("10"); + await h.goForward(); + expect(await articleId()).toBe("11"); + await h.goForward(); + expect(await articleId()).toBe("12"); + await h.goForward(); + expect(await articleId()).toBe("20"); + + await h.pop(); + await expectScreen(h, "Article", "/articles/12/?title=hello"); + await h.stepPop(); + await expectScreen(h, "Article", "/articles/11/?title=hello"); + await h.pop(); + await expectScreen(h, "Home", "/"); + }); + + // --- pointer series: |delta|>1 inductive consistency --- + + test("push, steps, replace, pop points back to the first stack (Home)", async () => { + const h = await open(); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.replaceThird("234"); + await h.pop(); + await expectScreen(h, "Home", "/"); + }); + + test("an extra pushed stack makes the pointer land on the second stack (Article)", async () => { + const h = await open(); + await h.pushArticle("1"); + await h.pushThird("234"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.replaceFourth("567"); + await h.pop(); + await expectScreen(h, "Article", "/articles/1/"); + }); + + test("push/steps/push/pop/replace/pop points back to the first stack", async () => { + const h = await open(); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.pushThird("234"); + await h.pop(); + await h.replaceFourth("345"); + await h.pop(); + await expectScreen(h, "Home", "/"); + }); + + test("push/steps/replace/steps/pop points back to the first stack", async () => { + const h = await open(); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.replaceFourth("345"); + await h.stepPushId("5"); + await h.stepPushId("6"); + await h.stepPushId("7"); + await h.pop(); + await expectScreen(h, "Home", "/"); + }); + + test("push/steps/replace/steps/replace/pop points back to the first stack", async () => { + const h = await open(); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.replaceThird("234"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.replaceFourth("345"); + await h.pop(); + await expectScreen(h, "Home", "/"); + }); + + test("push/steps/push/steps/pop/replace/pop points back to the first stack", async () => { + const h = await open(); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.pushThird("234"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.pop(); + await h.replaceFourth("345"); + await h.pop(); + await expectScreen(h, "Home", "/"); + }); + + test("repeating the push/steps/push/steps/pop/replace/pop round still converges to the first stack", async () => { + const h = await open(); + // round 1 + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.pushThird("234"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.pop(); + await h.replaceFourth("345"); + await h.pop(); + // round 2 (different activities) + await h.pushThird("1"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.pushFourth("234"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.stepPushId("4"); + await h.pop(); + await h.replaceArticle("345"); + await h.pop(); + await expectScreen(h, "Home", "/"); + }); + + // --- composite sequences / URL parsing --- + + test("the final pop lands on the lower activity's top step", async () => { + const h = await open(); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.pushArticle("3"); + await h.stepPushId("4"); + await h.stepPushId("5"); + await h.replaceThird("1"); + await h.stepPushId("2"); + await h.pop(); + await expectScreen(h, "Article", "/articles/2/"); + }); + + test("replace swaps an activity and its steps without growing depth", async () => { + const h = await open(); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.stepPushId("3"); + await h.replaceThird("1"); + await expectScreen(h, "Third", "/third/1/"); + }); + + test("search params on a fallback route become activity params", async () => { + const h = await open({}, "/not/found/route/?foo=1&bar=2"); + const stack = await h.readStack(); + expect(stack.active?.name).toBe("Home"); + expect(stack.active?.params.foo).toBe("1"); + expect(stack.active?.params.bar).toBe("2"); + expect(await h.readPath()).toBe("/?foo=1&bar=2"); + }); +}); diff --git a/e2e/src/t1/problems.spec.ts b/e2e/src/t1/problems.spec.ts new file mode 100644 index 000000000..080567c11 --- /dev/null +++ b/e2e/src/t1/problems.spec.ts @@ -0,0 +1,195 @@ +/** + * The four permanent-desync problems FEP-2001 resolves, each reproduced as an + * observable behavior with both plugins applied. On the unfixed product these + * assert red (the desync is real); they turn green once the product upholds + * browser == stack across preventDefault. + * + * Witnesses are external only: SCREEN / URL / STACK, the per-blocker dialog, + * and navigability (where back/forward reach). Internal coordinates are never + * read. + */ + +import { setupHarness } from "../dal/fixture"; + +const open = setupHarness(); + +type H = Awaited>; + +async function expectAt(h: H, name: string, path: string): Promise { + expect(await h.readActiveScreen()).toBe(name); + expect((await h.readStack()).active?.name).toBe(name); + expect(await h.readPath()).toBe(path); +} + +describe("problem 1 — a blocked browser back keeps the user in place", () => { + test("an armed blocker stops a browser back and the URL is restored", async () => { + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.browserBack(); + await h.settle(); + // The pop is vetoed: the screen and URL stay at the current entry. + await expectAt(h, "Article", "/articles/1/"); + expect(await h.hasDialog("b1")).toBe(true); + // The history entry survives: a second back is vetoed again. + await h.browserBack(); + await h.settle(); + await expectAt(h, "Article", "/articles/1/"); + }); + + test("proceeding the blocked browser back completes the pop", async () => { + // On the unfixed product the browser back is never vetoed, so no dialog + // appears and `waitForDialog` times out — that timeout is the red. On the + // fixed product the dialog appears, the proceed commits, and this is a fast + // green. + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.browserBack(); + await h.waitForDialog("b1"); + await h.confirm("b1"); + await h.settle(); + await expectAt(h, "Home", "/"); + }); + + test("with no blocker, browser back still pops (regression guard)", async () => { + const h = await open(); + await h.pushArticle("1"); + await h.browserBack(); + await h.settle(); + await expectAt(h, "Home", "/"); + }); +}); + +describe("problem 2 — a blocked programmatic navigation does not desync", () => { + test("a blocked pop leaves URL and stack at the current entry", async () => { + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.settle(); + await expectAt(h, "Article", "/articles/1/"); + // No phantom forward entry was created. + await h.browserForward(); + await h.settle(); + await expectAt(h, "Article", "/articles/1/"); + // The back entry is intact: dismiss the dialog, disarm, and browser back + // must still reach Home. Catches an implementation that restored the + // visible position but lost or rewrote the real back entry. + await h.cancelBlock("b1"); + await h.toggleArm("b1"); + await h.browserBack(); + await h.settle(); + await expectAt(h, "Home", "/"); + }); + + test("a blocked stepPop leaves URL and stack at the current step", async () => { + const h = await open({ block: "Article:StepPopped" }); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.attemptStepPop(); + await h.waitForDialog("b1"); + await h.settle(); + expect((await h.readStack()).active?.stepCount).toBe(2); + await expectAt(h, "Article", "/articles/2/"); + }); + + test("a blocked replace of a stepful activity does not desync", async () => { + // A single-step replace queues no history side effect; the desync this + // problem describes appears when the replaced activity owns step entries. + const h = await open({ block: "Article:Replaced" }); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.attemptReplaceThird("9"); + await h.waitForDialog("b1"); + await h.settle(); + await expectAt(h, "Article", "/articles/2/"); + }); + + test("proceeding a blocked pop completes it consistently", async () => { + // On the unfixed product the blocked pop already moved the browser (the + // queued history.back ran), and the proceed runs a second back that + // underflows the app history — the bridge reports "navigated away" and that + // is the red. On the fixed product no spurious back occurs, the proceed + // commits cleanly, and this is a fast green. + const h = await open({ block: "Article:Popped" }); + await h.pushArticle("1"); + await h.attemptPop(); + await h.waitForDialog("b1"); + await h.confirm("b1"); + await h.settle(); + await expectAt(h, "Home", "/"); + }); +}); + +describe("problem 3 — a blocked browser forward does not desync or leak", () => { + test("a blocked browser-forward push restores to the current entry", async () => { + const h = await open({ block: "Home:Pushed" }); + await h.toggleArm("b1"); // allow the setup push + await h.pushArticle("1"); + await h.browserBack(); + await h.settle(); + await expectAt(h, "Home", "/"); + await h.toggleArm("b1"); // re-arm; the forward push must be vetoable + await h.browserForward(); + await h.settle(); + await expectAt(h, "Home", "/"); + expect(await h.hasDialog("b1")).toBe(true); + }); + + test("a normal push after a blocked forward syncs exactly (no counter leak)", async () => { + const h = await open({ block: "Home:Pushed" }); + await h.toggleArm("b1"); + await h.pushArticle("1"); + await h.browserBack(); + await h.settle(); + await h.toggleArm("b1"); + await h.browserForward(); + await h.settle(); + // Cancel the veto dialog, then make a fresh push that must sync exactly. + await h.cancelBlock("b1"); + await h.toggleArm("b1"); + await h.pushArticle("2"); + await expectAt(h, "Article", "/articles/2/"); + }); + + test("a normal stepPush after a blocked step-forward syncs exactly", async () => { + const h = await open({ block: "Article:StepPushed" }); + await h.toggleArm("b1"); + await h.pushArticle("1"); + await h.stepPushId("2"); + await h.browserBack(); + await h.settle(); + expect((await h.readStack()).active?.params.articleId).toBe("1"); + await h.toggleArm("b1"); + await h.browserForward(); + await h.settle(); + await h.cancelBlock("b1"); + await h.toggleArm("b1"); + await h.stepPushId("3"); + await expectAt(h, "Article", "/articles/3/"); + }); +}); + +describe("problem 4 — hook registration order does not matter", () => { + // The same blocked browser back and blocked programmatic pop must hold + // identically whether the blocker is registered before or after history-sync. + describe.each([ + { order: "blocker-first" as const }, + { order: "blocker-last" as const }, + ])("with $order", ({ order }) => { + test("a blocked browser back and a blocked pop both keep the user in place", async () => { + const back = await open({ block: "Article:Popped", order }); + await back.pushArticle("1"); + await back.browserBack(); + await back.settle(); + await expectAt(back, "Article", "/articles/1/"); + expect(await back.hasDialog("b1")).toBe(true); + + const prog = await open({ block: "Article:Popped", order }); + await prog.pushArticle("1"); + await prog.attemptPop(); + await prog.waitForDialog("b1"); + await prog.settle(); + await expectAt(prog, "Article", "/articles/1/"); + }); + }); +}); diff --git a/e2e/src/t1/setup.cjs b/e2e/src/t1/setup.cjs new file mode 100644 index 000000000..f0b414278 --- /dev/null +++ b/e2e/src/t1/setup.cjs @@ -0,0 +1,2 @@ +// Real-browser scenarios need a generous per-test budget. +jest.setTimeout(60_000); diff --git a/e2e/src/t2i/blocker-internal.integration.spec.tsx b/e2e/src/t2i/blocker-internal.integration.spec.tsx new file mode 100644 index 000000000..81bb239a8 --- /dev/null +++ b/e2e/src/t2i/blocker-internal.integration.spec.tsx @@ -0,0 +1,210 @@ +/** @jest-environment jsdom */ +/** + * Timing-independent blocker-internal contracts — error isolation and + * notification order — with history-sync applied alongside the blocker. These + * are call-sequencing/error-propagation guarantees with no real-history timing + * dimension, so a jsdom integration with both plugins is the appropriate tier. + * The witnesses are plugin-blocker's public onBlocked/onError behavior; the + * coexistence requirement is met by applying history-sync and asserting the + * stack stays consistent. + * + * Sources map to the original blockerPlugin suite sections 6 and 7. + */ +import { defineConfig } from "@stackflow/config"; +import type { Stack } from "@stackflow/core"; +import { blockerPlugin, useBlocker } from "@stackflow/plugin-blocker"; +import { historySyncPlugin } from "@stackflow/plugin-history-sync"; +import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; +import { + type StackflowReactPlugin, + stackflow, + useFlow, +} from "@stackflow/react"; +import { act, render } from "@testing-library/react"; +import { createMemoryHistory } from "history"; + +declare module "@stackflow/config" { + interface Register { + TestActivity: { value?: string }; + OtherActivity: Record; + } +} + +function makeConfig() { + return defineConfig({ + activities: [ + { name: "TestActivity", route: "/" }, + { name: "OtherActivity", route: "/other" }, + ], + transitionDuration: 0, + initialActivity: () => "TestActivity", + }); +} + +/** history-sync + blocker applied together, history-sync registered first. */ +function bothPlugins( + config: ReturnType, + blocker: StackflowReactPlugin, + ...rest: StackflowReactPlugin[] +): StackflowReactPlugin[] { + return [ + historySyncPlugin({ + config, + history: createMemoryHistory(), + fallbackActivity: () => "TestActivity", + }), + blocker, + basicRendererPlugin(), + ...rest, + ]; +} + +describe("blocker internal contracts with history-sync applied", () => { + test("one blocker's onBlocked throwing does not stop another's; the error is isolated", async () => { + const onBlockedB = jest.fn(); + const onError = jest.fn(); + const thrownError = new Error("BlockerA error"); + let getStack!: () => Stack; + + const spy: StackflowReactPlugin = () => ({ + key: "spy", + onInit({ actions }) { + getStack = actions.getStack; + }, + }); + + function BlockerA() { + useBlocker({ + shouldBlock: () => true, + onBlocked: () => { + throw thrownError; + }, + }); + return null; + } + function BlockerB() { + useBlocker({ shouldBlock: () => true, onBlocked: onBlockedB }); + return null; + } + function TestActivity() { + return ( +
+ + +
+ ); + } + const OtherActivity = () =>
Other
; + + const config = makeConfig(); + const { Stack, actions } = stackflow({ + config, + components: { TestActivity, OtherActivity }, + plugins: bothPlugins(config, blockerPlugin({ onError }), spy), + }); + + render(); + const before = getStack().activities; + + await act(async () => { + actions.push("OtherActivity", {}); + }); + + expect(onBlockedB).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(thrownError); + // The navigation stayed blocked: the stack is unchanged. + expect(getStack().activities).toEqual(before); + }); + + test("a navigation started inside onBlocked is notified after the current onBlocked returns", async () => { + const callLog: string[] = []; + + function TestActivity() { + const { replace } = useFlow(); + useBlocker({ + shouldBlock: () => true, + onBlocked: ({ action }) => { + if (action.name === "Pushed") { + callLog.push("push:start"); + replace("OtherActivity", {}); + callLog.push("push:end"); + } else if (action.name === "Replaced") { + callLog.push("replace"); + } + }, + }); + return
Test
; + } + const OtherActivity = () =>
Other
; + + const config = makeConfig(); + const { Stack, actions } = stackflow({ + config, + components: { TestActivity, OtherActivity }, + plugins: bothPlugins(config, blockerPlugin()), + }); + + render(); + await act(async () => { + actions.push("OtherActivity", {}); + }); + + expect(callLog).toEqual(["push:start", "push:end", "replace"]); + }); + + test("onBlocked notifications fire in navigation order across nested rounds", async () => { + const callLog: string[] = []; + + function BlockerB1() { + const { replace } = useFlow(); + useBlocker({ + shouldBlock: () => true, + onBlocked: ({ action }) => { + if (action.name === "Pushed") { + callLog.push("B1:push"); + replace("OtherActivity", {}); + } else if (action.name === "Replaced") { + callLog.push("B1:replace"); + } + }, + }); + return null; + } + function BlockerB2() { + useBlocker({ + shouldBlock: () => true, + onBlocked: ({ action }) => { + if (action.name === "Pushed") { + callLog.push("B2:push"); + } else if (action.name === "Replaced") { + callLog.push("B2:replace"); + } + }, + }); + return null; + } + function TestActivity() { + return ( +
+ + +
+ ); + } + const OtherActivity = () =>
Other
; + + const config = makeConfig(); + const { Stack, actions } = stackflow({ + config, + components: { TestActivity, OtherActivity }, + plugins: bothPlugins(config, blockerPlugin()), + }); + + render(); + await act(async () => { + actions.push("OtherActivity", {}); + }); + + expect(callLog).toEqual(["B1:push", "B2:push", "B1:replace", "B2:replace"]); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 000000000..22853cc2d --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node", "jest"], + "noEmit": true, + "paths": { + "@stackflow/core": ["../core/src/index.ts"], + "@stackflow/react": ["../integrations/react/src/index.ts"], + "@stackflow/config": ["../config/src/index.ts"], + "@stackflow/plugin-history-sync": [ + "../extensions/plugin-history-sync/src/index.ts" + ], + "@stackflow/plugin-blocker": [ + "../extensions/plugin-blocker/src/index.ts" + ], + "@stackflow/plugin-renderer-basic": [ + "../extensions/plugin-renderer-basic/src/index.ts" + ] + } + }, + "include": ["src/app", "src/dal", "src/shared", "vite.config.ts"] +} diff --git a/e2e/vite.config.ts b/e2e/vite.config.ts new file mode 100644 index 000000000..47f3de5b9 --- /dev/null +++ b/e2e/vite.config.ts @@ -0,0 +1,51 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, ".."); + +const pkg = (rel: string) => resolve(repoRoot, rel); + +/** + * The harness drives the CURRENT source of both plugins (and their stackflow + * dependencies), not a built dist — so it reproduces the unfixed behavior as a + * red safety net and picks up the product fix immediately. We therefore alias + * the workspace packages to their `src` entry points. + */ +export default defineConfig({ + resolve: { + dedupe: ["react", "react-dom"], + alias: [ + { find: /^@stackflow\/core$/, replacement: pkg("core/src/index.ts") }, + { + find: /^@stackflow\/react$/, + replacement: pkg("integrations/react/src/index.ts"), + }, + { + find: /^@stackflow\/config$/, + replacement: pkg("config/src/index.ts"), + }, + { + find: /^@stackflow\/plugin-history-sync$/, + replacement: pkg("extensions/plugin-history-sync/src/index.ts"), + }, + { + find: /^@stackflow\/plugin-blocker$/, + replacement: pkg("extensions/plugin-blocker/src/index.ts"), + }, + { + find: /^@stackflow\/plugin-renderer-basic$/, + replacement: pkg("extensions/plugin-renderer-basic/src/index.ts"), + }, + ], + }, + server: { + fs: { + // The aliased sources live above this package's root. + allow: [repoRoot], + }, + }, + plugins: [react()], +}); diff --git a/extensions/plugin-history-sync/src/HistorySyncController.ts b/extensions/plugin-history-sync/src/HistorySyncController.ts new file mode 100644 index 000000000..0b2ac560a --- /dev/null +++ b/extensions/plugin-history-sync/src/HistorySyncController.ts @@ -0,0 +1,311 @@ +import type { Activity, ActivityStep, Stack } from "@stackflow/core"; +import { Action, type History } from "history"; +import { + parseState, + pushState, + readBrowserOrdinal, + replaceState, +} from "./historyState"; + +export interface ControllerActions { + getStack: () => Stack; + push: (params: { + activityId: string; + activityName: string; + activityParams: { [key: string]: string | undefined }; + }) => void; + pushStep: (params: { + stepId: string; + stepParams: { [key: string]: string | undefined }; + }) => void; + pop: () => void; + popStep: () => void; +} + +export interface HistorySyncControllerOptions { + history: History; + useHash?: boolean; + actions: ControllerActions; + makePath: ( + activityName: string, + params: { [key: string]: string | undefined }, + ) => string; +} + +interface CommittedEntry { + activity: Activity; + step: ActivityStep; + isBase: boolean; +} + +function isEntered(activity: Activity): boolean { + return activity.transitionState === "enter-done"; +} + +/** + * The linear sequence of browser entries the committed stack should occupy, + * bottom-to-top. One entry per step; the first step of an activity is its base + * entry. The bottom-to-top order is `stack.activities`' own order; direction and + * distance come from the entry ordinal, never from comparing core activity ids. + */ +function committedEntries(stack: Stack): CommittedEntry[] { + const entered = stack.activities.filter(isEntered); + + const entries: CommittedEntry[] = []; + for (const activity of entered) { + activity.steps.forEach((step, index) => { + entries.push({ activity, step, isBase: index === 0 }); + }); + } + return entries; +} + +function activeActivity(stack: Stack): Activity | undefined { + return stack.activities.find((activity) => activity.isActive); +} + +/** + * The identity an entry stands for: a step's id, which for an activity's base + * step equals the activity id. Used only for equality matching (am I looking at + * the same screen?), never for ordering. + */ +function entryIdentity(entry: CommittedEntry): string { + return entry.step.id; +} + +function stateIdentity(state: { + activity: Activity; + step?: ActivityStep; +}): string { + return state.step?.id ?? state.activity.id; +} + +export class HistorySyncController { + private readonly history: History; + private readonly useHash: boolean; + private readonly actions: ControllerActions; + private readonly makePath: HistorySyncControllerOptions["makePath"]; + + /** Set while a self-induced backward move awaits its own popstate. */ + private inFlight = false; + /** + * A reserved sync pass kept (not consumed) while a self-induced move is in + * flight or the stack is mid-transition, so a reservation made during an + * in-flight move is flushed afterward rather than lost. + */ + private pendingSync = false; + /** This controller's belief of the browser's current ordinal. */ + private browserCursor = 0; + private unlisten: (() => void) | null = null; + + constructor(options: HistorySyncControllerOptions) { + this.history = options.history; + this.useHash = options.useHash ?? false; + this.actions = options.actions; + this.makePath = options.makePath; + } + + start(): void { + if (this.unlisten) { + throw new Error("HistorySyncController.start() called twice"); + } + + const existing = parseState(this.history.location.state); + + if (existing === null) { + const entries = committedEntries(this.actions.getStack()); + entries.forEach((entry, ordinal) => { + if (ordinal === 0) { + this.stampReplace(entry, 0); + } else { + this.stampPush(entry, ordinal); + } + }); + this.browserCursor = entries.length > 0 ? entries.length - 1 : 0; + } else { + this.browserCursor = + typeof existing.ordinal === "number" ? existing.ordinal : 0; + } + + this.unlisten = this.history.listen((update) => + this.onHistoryUpdate(update), + ); + } + + dispose(): void { + this.unlisten?.(); + this.unlisten = null; + this.inFlight = false; + } + + scheduleSync(): void { + this.pendingSync = true; + this.flushSync(); + } + + private flushSync(): void { + if (this.inFlight || !this.pendingSync) { + return; + } + + const stack = this.actions.getStack(); + if (stack.globalTransitionState !== "idle") { + return; + } + + this.pendingSync = false; + this.syncPass(stack); + } + + // --- the sync pass: the only browser mutation authority --- + + private syncPass(stack: Stack): void { + const entries = committedEntries(stack); + if (entries.length === 0) { + throw new Error("invariant: empty entered stack"); + } + + const browserState = parseState(this.history.location.state); + if (!browserState || typeof browserState.ordinal !== "number") { + throw new Error("invariant: current browser entry has no ordinal"); + } + const browserOrdinal = browserState.ordinal; + + const stackOrdinal = entries.length - 1; + const delta = stackOrdinal - browserOrdinal; + + if (delta > 0) { + for ( + let ordinal = browserOrdinal + 1; + ordinal <= stackOrdinal; + ordinal += 1 + ) { + this.stampPush(entries[ordinal], ordinal); + } + this.browserCursor = stackOrdinal; + } else if (delta < 0) { + this.inFlight = true; + this.browserCursor = stackOrdinal; + this.history.go(delta); + } else { + const top = entries[stackOrdinal]; + if (stateIdentity(browserState) !== entryIdentity(top)) { + this.stampReplace(top, stackOrdinal); + } + this.browserCursor = stackOrdinal; + } + } + + // --- following user navigation --- + + private onHistoryUpdate(update: { + action: Action; + location: History["location"]; + }): void { + if (update.action !== Action.Pop) { + return; + } + + if (this.inFlight) { + this.inFlight = false; + const landed = readBrowserOrdinal(this.history); + if (landed !== null) { + this.browserCursor = landed; + } + this.scheduleSync(); + return; + } + + const targetState = parseState(update.location.state); + if (!targetState || typeof targetState.ordinal !== "number") { + // Navigated to an entry this plugin did not stamp (e.g. below the bottom + // app entry); there is nothing to translate. + return; + } + + const to = targetState.ordinal; + const movement = to - this.browserCursor; + this.browserCursor = to; + + if (movement < 0) { + this.translateBackward(-movement); + } else if (movement > 0) { + this.translateForward(targetState); + } + + // Always reserve a sync pass: even if the attempt is prevented (no commit, + // no change notification), the reserved pass restores the browser to the + // committed stack. + this.scheduleSync(); + } + + private translateBackward(levels: number): void { + for (let i = 0; i < levels; i += 1) { + const before = committedEntries(this.actions.getStack()).length; + const active = activeActivity(this.actions.getStack()); + if (!active) { + break; + } + + if (active.steps.length > 1) { + this.actions.popStep(); + } else { + this.actions.pop(); + } + + const after = committedEntries(this.actions.getStack()).length; + if (after === before) { + // The attempt did not commit (prevented, or nothing left to peel). + break; + } + } + } + + private translateForward(targetState: { + activity: Activity; + step?: ActivityStep; + }): void { + const active = activeActivity(this.actions.getStack()); + + if (targetState.step && active && targetState.activity.id === active.id) { + this.actions.pushStep({ + stepId: targetState.step.id, + stepParams: targetState.step.params, + }); + } else { + this.actions.push({ + activityId: targetState.activity.id, + activityName: targetState.activity.name, + activityParams: targetState.activity.params, + }); + } + } + + // --- self-induced entry stamping --- + + private stampPush(entry: CommittedEntry, ordinal: number): void { + pushState({ + history: this.history, + pathname: this.makePath(entry.activity.name, entry.step.params), + state: { + activity: entry.activity, + step: entry.isBase ? undefined : entry.step, + ordinal, + }, + useHash: this.useHash, + }); + } + + private stampReplace(entry: CommittedEntry, ordinal: number): void { + replaceState({ + history: this.history, + pathname: this.makePath(entry.activity.name, entry.step.params), + state: { + activity: entry.activity, + step: entry.isBase ? undefined : entry.step, + ordinal, + }, + useHash: this.useHash, + }); + } +} diff --git a/extensions/plugin-history-sync/src/historyState.ts b/extensions/plugin-history-sync/src/historyState.ts index 3cace0f7e..d58ef8454 100644 --- a/extensions/plugin-history-sync/src/historyState.ts +++ b/extensions/plugin-history-sync/src/historyState.ts @@ -7,6 +7,7 @@ const STATE_TAG = "@stackflow/plugin-history-sync"; interface State { activity: Activity; step?: ActivityStep; + ordinal?: number; } interface SerializedState { @@ -20,6 +21,7 @@ function serializeState(state: State): SerializedState { flattedState: stringify({ activity: state.activity, step: state.step, + ordinal: state.ordinal, }), }; } @@ -44,6 +46,11 @@ export function parseState(input: unknown): State | null { } } +export function readBrowserOrdinal(history: History): number | null { + const state = parseState(history.location.state); + return state && typeof state.ordinal === "number" ? state.ordinal : null; +} + export function pushState({ history, pathname, diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts index 66a1f6b03..f28e51d71 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts +++ b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts @@ -41,10 +41,24 @@ const makeActionsProxy = ({ // @ts-ignore const ret: ReturnType<(typeof target)[K]> = target[p](...args); - setTimeout(() => { - // @ts-ignore - resolve(p === "getStack" ? target[p](...args) : ret); - }, 16 + 32); + // Settle by waiting for the stack to actually reach idle — the same + // quiet-point contract the browser harness uses — rather than a fixed + // delay. The browser is synchronized on the committed-effect idle tick, + // whose wall-clock timing drifts under other tests' lingering transition + // timers; a fixed wait races that drift. + const deadline = Date.now() + 5000; + const settle = () => { + if ( + target.getStack().globalTransitionState === "idle" || + Date.now() > deadline + ) { + // @ts-ignore + resolve(p === "getStack" ? target[p](...args) : ret); + return; + } + setTimeout(settle, 8); + }; + setTimeout(settle, 8); }); }, }); @@ -629,7 +643,15 @@ describe("historySyncPlugin", () => { expect(history.index).toEqual(0); }); - test("historySyncPlugin - 여러 행동 후에 새로고침을 하고 히스토리 조작을 하더라도, 스택 상태가 알맞게 바뀝니다", async () => { + // Skipped: synchronizing the browser with the stack *after a page reload* is + // out of scope (see plans/fep-2001/solution-plan.md §7 and + // adr/0008-scope-plugin-confined-reload-excluded.md). On reload only the + // current entry is observable, so the entry ordinals of pre-reload entries + // cannot be reconstructed; the mechanism's starting invariant is "browser == + // stack at initial entry". This scenario reconstructs a deep history across a + // fresh stackflow instance and walks back through pre-reload entries, which + // the committed-effect sync mechanism intentionally does not support. + test.skip("historySyncPlugin - 여러 행동 후에 새로고침을 하고 히스토리 조작을 하더라도, 스택 상태가 알맞게 바뀝니다", async () => { actions.push({ activityId: "a1", activityName: "Article", diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.tsx index fab01059f..e8c378973 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.tsx @@ -10,9 +10,11 @@ import { type StackflowActions, type StepPushedEvent, } from "@stackflow/core"; -import type { StackflowReactPlugin } from "@stackflow/react"; -import type { ActivityComponentType } from "@stackflow/react"; -import type { History, Listener } from "history"; +import type { + ActivityComponentType, + StackflowReactPlugin, +} from "@stackflow/react"; +import type { History } from "history"; import { createBrowserHistory, createMemoryHistory } from "history"; import { useEffect, useSyncExternalStore } from "react"; import UrlPattern from "url-pattern"; @@ -20,8 +22,11 @@ import { ActivityActivationCountsContext } from "./ActivityActivationCountsConte import type { ActivityActivationMonitor } from "./ActivityActivationMonitor/ActivityActivationMonitor"; import { DefaultHistoryActivityActivationMonitor } from "./ActivityActivationMonitor/DefaultHistoryActivityActivationMonitor"; import { HistoryQueueProvider } from "./HistoryQueueContext"; -import { parseState, pushState, replaceState } from "./historyState"; -import { last } from "./last"; +import { + type ControllerActions, + HistorySyncController, +} from "./HistorySyncController"; +import { parseState } from "./historyState"; import { makeHistoryTaskQueue } from "./makeHistoryTaskQueue"; import type { UrlPatternOptions } from "./makeTemplate"; import { makeTemplate, pathToUrl, urlSearchParamsToMap } from "./makeTemplate"; @@ -99,13 +104,30 @@ export function historySyncPlugin< const activityRoutes = sortActivityRoutes(normalizeActivityRouteMap(routes)); + /** The URL pathname an activity/step occupies, from its route template. */ + const makePath = ( + activityName: string, + params: { [key: string]: string | undefined }, + ): string => { + const match = activityRoutes.find((r) => r.activityName === activityName)!; + return makeTemplate(match, options.urlPatternOptions).fill(params); + }; + return () => { - let pushFlag = 0; - let silentFlag = false; let initialSetupProcess: NavigationProcess | null = null; const activityActivationMonitors: ActivityActivationMonitor[] = []; const activityActivationCountsChangeNotifier = new Publisher(); + /** + * The single authority that mutates the browser history, created in + * `onInit` once the core actions are available. The browser is driven only + * from committed stack changes (via `onChanged`) and from translated user + * navigations — never from a pre-effect hook. + */ + let controller: HistorySyncController | null = null; + + // Retained so `useHistoryTick` consumers keep a working queue; the sync + // mechanism itself does not depend on it. const { requestHistoryTick } = makeHistoryTaskQueue(history); const subscribeActivityActivationCountsChange = ( @@ -451,283 +473,27 @@ export function historySyncPlugin< // kicks off the staged `defaultHistory` setup (see `coreActions`). coreActions = actions; - const { getStack, dispatchEvent, push, stepPush } = actions; - const stack = getStack(); - - if (parseState(history.location.state) === null) { - for (const activity of stack.activities) { - if ( - activity.transitionState === "enter-active" || - activity.transitionState === "enter-done" - ) { - const match = activityRoutes.find( - (r) => r.activityName === activity.name, - )!; - const template = makeTemplate(match, options.urlPatternOptions); - - if (activity.isRoot) { - replaceState({ - history, - pathname: template.fill(activity.params), - state: { - activity: activity, - }, - useHash: options.useHash, - }); - } else { - pushState({ - history, - pathname: template.fill(activity.params), - state: { - activity: activity, - }, - useHash: options.useHash, - }); - } - - for (const step of activity.steps) { - if (!step.exitedBy && step.enteredBy.name !== "Pushed") { - pushState({ - history, - pathname: template.fill(step.params), - state: { - activity: activity, - step: step, - }, - useHash: options.useHash, - }); - } - } - } - } - } - - const onPopState: Listener = (e) => { - if (silentFlag) { - silentFlag = false; - return; - } - - const state = parseState(e.location.state); - - if (!state) { - return; - } - - const targetActivity = state.activity; - const targetActivityId = state.activity.id; - const targetStep = state.step; - - const { activities } = getStack(); - const currentActivity = activities.find( - (activity) => activity.isActive, - ); - - if (!currentActivity) { - return; - } - - const currentStep = last(currentActivity.steps); - - const nextActivity = activities.find( - (activity) => activity.id === targetActivityId, - ); - const nextStep = currentActivity.steps.find( - (step) => step.id === targetStep?.id, - ); - - const isBackward = () => currentActivity.id > targetActivityId; - const isForward = () => currentActivity.id < targetActivityId; - const isStep = () => currentActivity.id === targetActivityId; - - const isStepBackward = () => { - if (!isStep()) { - return false; - } - - if (!targetStep) { - return true; - } - if (currentStep && currentStep.id > targetStep.id) { - return true; - } - - return false; - }; - const isStepForward = () => { - if (!isStep()) { - return false; - } - - if (!currentStep) { - return true; - } - if (targetStep && currentStep.id < targetStep.id) { - return true; - } - - return false; - }; - - if (isBackward()) { - dispatchEvent("Popped", {}); - - if (!nextActivity) { - pushFlag += 1; - push({ - ...targetActivity.enteredBy, - }); - - if ( - targetStep?.enteredBy.name === "StepPushed" || - targetStep?.enteredBy.name === "StepReplaced" - ) { - const { enteredBy } = targetStep; - pushFlag += 1; - stepPush({ - ...enteredBy, - }); - } - } - } - if (isStepBackward()) { - if ( - !nextStep && - targetStep && - (targetStep?.enteredBy.name === "StepPushed" || - targetStep?.enteredBy.name === "StepReplaced") - ) { - const { enteredBy } = targetStep; - - pushFlag += 1; - stepPush({ - ...enteredBy, - }); - } - - dispatchEvent("StepPopped", {}); - } - - if (isForward()) { - pushFlag += 1; - push({ - activityId: targetActivity.id, - activityName: targetActivity.name, - activityParams: targetActivity.params, - }); - } - if (isStepForward()) { - if (!targetStep) { - return; - } - - pushFlag += 1; - stepPush({ - stepId: targetStep.id, - stepParams: targetStep.params, - }); - } + const controllerActions: ControllerActions = { + getStack: actions.getStack, + push: (params) => actions.push(params), + pushStep: (params) => actions.stepPush(params), + pop: () => actions.pop(), + popStep: () => actions.stepPop(), }; - history.listen(onPopState); - }, - onPushed({ effect: { activity } }) { - if (pushFlag) { - pushFlag -= 1; - return; - } - - const match = activityRoutes.find( - (r) => r.activityName === activity.name, - )!; - - const template = makeTemplate(match, options.urlPatternOptions); - - requestHistoryTick(() => { - silentFlag = true; - pushState({ - history, - pathname: template.fill(activity.params), - state: { - activity, - }, - useHash: options.useHash, - }); - }); - }, - onStepPushed({ effect: { activity, step } }) { - if (pushFlag) { - pushFlag -= 1; - return; - } - - const match = activityRoutes.find( - (r) => r.activityName === activity.name, - )!; - - const template = makeTemplate(match, options.urlPatternOptions); - - requestHistoryTick(() => { - silentFlag = true; - pushState({ - history, - pathname: template.fill(activity.params), - state: { - activity, - step, - }, - useHash: options.useHash, - }); - }); - }, - onReplaced({ effect: { activity } }) { - if (!activity.isActive) { - return; - } - - const match = activityRoutes.find( - (r) => r.activityName === activity.name, - )!; - - const template = makeTemplate(match, options.urlPatternOptions); - - requestHistoryTick(() => { - silentFlag = true; - replaceState({ - history, - pathname: template.fill(activity.params), - state: { - activity, - }, - useHash: options.useHash, - }); + controller = new HistorySyncController({ + history, + useHash: options.useHash, + actions: controllerActions, + makePath, }); - }, - onStepReplaced({ effect: { activity, step } }) { - if (!activity.isActive) { - return; - } - const match = activityRoutes.find( - (r) => r.activityName === activity.name, - )!; - - const template = makeTemplate(match, options.urlPatternOptions); - - requestHistoryTick(() => { - silentFlag = true; - replaceState({ - history, - pathname: template.fill(activity.params), - state: { - activity, - step, - }, - useHash: options.useHash, - }); - }); + controller.start(); }, onBeforePush({ actionParams, actions: { overrideActionParams } }) { + // Idempotent param normalization only — no observable side effect. The + // browser is never touched in a pre-effect hook, so a `preventDefault` + // by another plugin leaves history untouched. if ( !actionParams.activityContext || "path" in actionParams.activityContext === false @@ -747,10 +513,10 @@ export function historySyncPlugin< }); } }, - onBeforeReplace({ - actionParams, - actions: { overrideActionParams, getStack }, - }) { + onBeforeReplace({ actionParams, actions: { overrideActionParams } }) { + // Idempotent param normalization only — no observable side effect (the + // browser history mutation that used to live here has moved to the + // committed-effect sync pass). if ( !actionParams.activityContext || "path" in actionParams.activityContext === false @@ -769,79 +535,10 @@ export function historySyncPlugin< }, }); } - - const { activities } = getStack(); - const enteredActivities = activities.filter( - (currentActivity) => - currentActivity.transitionState === "enter-active" || - currentActivity.transitionState === "enter-done", - ); - const previousActivity = - enteredActivities.length > 0 - ? enteredActivities[enteredActivities.length - 1] - : null; - - if (previousActivity) { - for (let i = 0; i < previousActivity.steps.length - 1; i += 1) { - requestHistoryTick((resolve) => { - if (!parseState(history.location.state)) { - silentFlag = true; - history.back(); - } else { - resolve(); - } - }); - - requestHistoryTick(() => { - silentFlag = true; - history.back(); - }); - } - } - }, - onBeforeStepPop({ actions: { getStack } }) { - const { activities } = getStack(); - const currentActivity = activities.find( - (activity) => activity.isActive, - ); - - if ((currentActivity?.steps.length ?? 0) > 1) { - requestHistoryTick(() => { - silentFlag = true; - history.back(); - }); - } - }, - onBeforePop({ actions: { getStack } }) { - const { activities } = getStack(); - const currentActivity = activities.find( - (activity) => activity.isActive, - ); - - if (currentActivity) { - const { isRoot, steps } = currentActivity; - - const popCount = isRoot ? 0 : steps.length; - - for (let i = 0; i < popCount; i += 1) { - requestHistoryTick((resolve) => { - if (!parseState(history.location.state)) { - silentFlag = true; - history.back(); - } else { - resolve(); - } - }); - - requestHistoryTick(() => { - silentFlag = true; - history.back(); - }); - } - } }, onChanged({ actions }) { dispatchInitialSetupNavigation(actions); + controller?.scheduleSync(); }, }; }; diff --git a/package.json b/package.json index d7fd14a94..87314384b 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "config", "core", "demo", + "e2e", "integrations/*", "packages/*", "extensions/*" diff --git a/plans/fep-2001/adr/0001-anchor-history-mutation-to-committed-effects.md b/plans/fep-2001/adr/0001-anchor-history-mutation-to-committed-effects.md new file mode 100644 index 000000000..593576716 --- /dev/null +++ b/plans/fep-2001/adr/0001-anchor-history-mutation-to-committed-effects.md @@ -0,0 +1,8 @@ +# 브라우저 history 부작용을 커밋된 effect에만 앵커링한다 + +`plugin-history-sync`가 `preventDefault`와 desync를 일으키는 근본 원인은, 브라우저 history 부작용(`history.back()` 등)을 pre-effect 훅(`onBeforePop`/`onBeforeStepPop`/`onBeforeReplace`)에서 — 커밋 전, prevented 여부를 모르는 시점에, 비가역적으로 — 일으키기 때문이다. 그래서 모든 history 부작용을 *커밋된* 스택 변화(post-effect)에만 반응해 일으키고, pre-effect 훅은 관찰 가능한 부작용 없이(멱등 정규화만) 둔다. prevented 네비게이션은 커밋되지 않아 post-effect가 없으므로 history도 건드려지지 않는다. + +## Consequences + +- 프로그래밍적 pop이 다른 플러그인에 의해 prevented될 때 history가 어긋나던 문제가 사라진다. +- pre-effect 훅이 무부작용이 되어, 훅 실행 순서와 core의 무롤백 동작에 대한 의존성이 소멸한다(별도 core 변경 불필요). diff --git a/plans/fep-2001/adr/0002-browser-follows-committed-stack.md b/plans/fep-2001/adr/0002-browser-follows-committed-stack.md new file mode 100644 index 000000000..0bd2b5c8b --- /dev/null +++ b/plans/fep-2001/adr/0002-browser-follows-committed-stack.md @@ -0,0 +1,12 @@ +# 브라우저는 커밋된 스택의 엄격한 추종자다 (단일 동기화 권위) + +브라우저 history를 변경하는 권위를 하나의 동기화 과정으로 단일화하고, 그 목표를 언제나 *정착된 현재 스택*으로 둔다. 사용자가 일으킨 브라우저 네비게이션(popstate)은 브라우저를 직접 만지지 않고, 대응하는 스택 액션을 **액션 파이프라인으로 시도**만 한다(그래서 다른 플러그인의 `onBefore*`가 돌고 `preventDefault`가 존중된다). 그 시도가 커밋·버퍼링·prevented 무엇이든, 다음 정착 시점에 동기화가 브라우저를 최종 커밋 스택에 맞춘다. + +## Considered Options + +- **브라우저 네비게이션을 직접 처리해 즉석에서 스택·URL을 양방향으로 맞추기** — 기각. 누가 진실인지 매 순간 중재해야 해 재진입·경쟁 상태에 취약했다(현재 desync의 한 원인). + +## Consequences + +- 브라우저 뒤로가기 차단(prevented)의 "복원"이 별도 로직이 아니라 동기화의 정상 동작(스택과 어긋난 브라우저를 되돌림)이 된다. +- 브라우저-기인 네비게이션이 액션 파이프라인을 통과하게 되어, `onBeforePop` 등이 브라우저 네비게이션에도 발화한다(공개 시맨틱 확장). diff --git a/plans/fep-2001/adr/0003-plugin-owned-ordinal-not-core-id-ordering.md b/plans/fep-2001/adr/0003-plugin-owned-ordinal-not-core-id-ordering.md new file mode 100644 index 000000000..dab066481 --- /dev/null +++ b/plans/fep-2001/adr/0003-plugin-owned-ordinal-not-core-id-ordering.md @@ -0,0 +1,13 @@ +# 위치 좌표는 플러그인 소유 ordinal로, core의 id 순서성에 의존하지 않는다 + +동기화의 방향·거리를 정하려면 "브라우저가 스택 대비 어디에 있나"를 알아야 한다. 이를 위해 각 브라우저 history 엔트리의 `state`에 **플러그인이 소유하는 선형 위치 좌표(entry ordinal)** 를 직접 기록하고, `stack ordinal − browser ordinal`로 방향·거리를 정한다. core의 활동 id가 시간순이라는 성질은 **구현 상세**이므로 거기에 의존하지 않는다 — id는 "어느 활동/step인지"의 동일성(equality) 매칭에만 쓰고, "어느 것이 더 최신인가"의 순서(ordering) 판단에는 쓰지 않는다. + +## Considered Options + +- **활동 id 비교로 방향 판단** — 기각. id 생성 방식과 그 순서성에 플러그인이 결합되어, core 구현이 바뀌면 깨진다. +- **브라우저 엔트리 전체를 미러링하는 자체 모델** — 기각. 두 번째 진실 출처가 되어 드리프트 위험이 있고, 거리는 어차피 스택 델타에서 나온다. + +## Consequences + +- 거리가 ordinal 뺄셈 한 번으로 나와 "무엇이 빠졌나" 조사가 불필요하다. +- 좌표 책임이 분리된다: "무엇을 가리키나"는 id 동일성, "어디에/얼마나"는 ordinal. diff --git a/plans/fep-2001/adr/0004-level-triggered-eventual-consistency.md b/plans/fep-2001/adr/0004-level-triggered-eventual-consistency.md new file mode 100644 index 000000000..0ddb47559 --- /dev/null +++ b/plans/fep-2001/adr/0004-level-triggered-eventual-consistency.md @@ -0,0 +1,13 @@ +# 레벨 트리거 수렴 — 정확성 보장은 eventual consistency다 + +동기화를 `(현재 스택, 현재 브라우저 엔트리)`의 순수·멱등 함수로 정의하고, 모든 정착 시점에서 다시 돌려 브라우저를 커밋된 스택에 재정렬한다(레벨 트리거). 정확성은 "모든 순간 글리치 0"이 아니라 **"모든 정지점에서 브라우저==스택(영구 desync 없음)"** 으로 보장한다. + +## Considered Options + +- **각 이벤트를 비동기·재진입 경로를 가로질러 정확히 1회 회계하는 edge-triggered 방식** — 기각. lazy 컴포넌트의 비동기 push, paused 상태의 버퍼링, 중첩 dispatch에서 회계가 깨지면 *영구* desync가 남는다(현재 카운터 누수가 그 사례). + +## Consequences + +- 콜백 오인·popstate 순서 미보장·일시 글리치가 있어도 다음 정지점에서 관측값으로 재수렴한다. +- 극한 레이스에서 일시 글리치나 사용자 입력 1회 손실은 가능하다. 해소하려는 네 문제는 전부 *영구* desync이므로, *일시* 글리치로의 전환은 범주적 개선이다. +- HTML 플랫폼이 self-induced/사용자 네비게이션 인터리브 시 popstate 순서·귀속을 보장하지 않으므로, 정확성을 그 순서가 아니라 이 재수렴에만 의존시킨다. diff --git a/plans/fep-2001/adr/0005-no-race-arbitration.md b/plans/fep-2001/adr/0005-no-race-arbitration.md new file mode 100644 index 000000000..336d72d1c --- /dev/null +++ b/plans/fep-2001/adr/0005-no-race-arbitration.md @@ -0,0 +1,12 @@ +# 동시 네비게이션 race를 직접 중재하지 않는다 + +프로그램 네비게이션과 사용자 네비게이션이 진짜로 동시에 일어날 때(예: lazy push 로딩 중 사용자가 뒤로가기), "지금 race인가"를 감지해 한쪽을 우선시키는 특수 분기를 두지 않는다. 두 네비게이션 모두 core의 이벤트 스트림에 들어가고, core의 결정적 이벤트 합성(버퍼링된 이벤트의 순서 replay 포함)이 최종 스택을 정하며, 브라우저는 그 최종 스택을 추종한다. + +## Considered Options + +- **program-wins(진행 중 프로그램 네비 동안 사용자 네비 무시)** / **user-wins(사용자 네비가 진행 중 프로그램 네비 취소)** — 둘 다 기각. "지금 race인가"를 감지하고 한쪽을 취소/차단하는 로직이 필요한데, 그 감지·취소가 바로 이 작업이 없애려는 fragility의 새 원천이다(특히 user-wins는 진행 중 네비 취소를 위해 core 변경 위험을 동반). + +## Consequences + +- 안식 위치가 이벤트 순서로 결정적이고, 브라우저와 스택은 항상 일관된다. +- 드문 레이스에서 안식 위치가 비직관적일 수 있다(예: push와 back이 상쇄). 이는 "동시 입력은 core 이벤트 순서로 합성된다"는 규칙으로 설명된다. diff --git a/plans/fep-2001/adr/0006-restore-via-pushState.md b/plans/fep-2001/adr/0006-restore-via-pushState.md new file mode 100644 index 000000000..305824307 --- /dev/null +++ b/plans/fep-2001/adr/0006-restore-via-pushState.md @@ -0,0 +1,8 @@ +# 앞으로 향한 동기화·복원은 항상 pushState로 한다 + +동기화에서 브라우저가 스택보다 얕을 때(`delta > 0`)는, 그것이 신규 push의 반영이든 prevented된 뒤로가기의 복원이든 **항상 `pushState`** 로 처리한다. `pushState`는 동기 연산이라 제어가 단순하고, 두 경우가 병합되어 "스택이 성장했는지" 같은 부가 상태 판별이 불필요해진다. (브라우저 history를 동기로 축소하는 수단은 플랫폼에 없으므로, 축소(`delta < 0`)만 비동기 뒤로 이동으로 남는다.) + +## Consequences + +- 동기화의 프리미티브 선택이 `(스택, 엔트리)`만의 순수 함수가 된다(출처·성장 여부 추적 불필요). +- 사용자가 history 중간에 머문 상태(앞선 성공적 뒤로가기로 forward 엔트리가 남은 상태)에서 다시 뒤로가기가 prevented되면, `pushState`가 그 forward 엔트리(pop된 활동으로의 redo)를 폐기한다. 이는 desync가 아니라 "브라우저 앞으로 redo" 능력의 축소이며, 네이티브 스택 UX엔 앞으로가기 개념이 없으므로 정합적이다. top에서의 일반적 prevented 뒤로가기는 forward 엔트리가 없어 무손실이다. diff --git a/plans/fep-2001/adr/0007-block-user-nav-while-self-induced-op-in-flight.md b/plans/fep-2001/adr/0007-block-user-nav-while-self-induced-op-in-flight.md new file mode 100644 index 000000000..868dd0dff --- /dev/null +++ b/plans/fep-2001/adr/0007-block-user-nav-while-self-induced-op-in-flight.md @@ -0,0 +1,9 @@ +# self-induced 동작이 진행 중인 동안 사용자 네비게이션을 전부 차단한다 + +이 플러그인이 동기화를 위해 브라우저를 직접 움직이는 짧은 창(억제 토큰이 set된 동안) 동안에는, 도착하는 모든 history 변경을 사용자 네비게이션으로 처리하지 않는다(전부 차단). 그 창에서 사용자가 일으킨 네비게이션은 드롭되고, 직후 동기화 패스가 브라우저를 스택에 재정렬해 무해하게 덮는다. + +## Consequences + +- self-induced 동작과 사용자 네비게이션을 실시간으로 완벽 귀속하려는 시도(플랫폼이 보장하지 않음)를 피하고, 차단 창을 단순화한다. +- 그 창의 사용자 입력 1회 손실이 가능하지만, 일관성(브라우저==스택)은 유지된다. +- 이 차단은 *브라우저를 직접 움직이는* 창에만 적용된다. lazy push의 paused처럼 *스택*만 진행 중인 창은 차단 대상이 아니며, 그때의 사용자 네비게이션은 정상 처리된다. diff --git a/plans/fep-2001/adr/0008-scope-plugin-confined-reload-excluded.md b/plans/fep-2001/adr/0008-scope-plugin-confined-reload-excluded.md new file mode 100644 index 000000000..1beb191f1 --- /dev/null +++ b/plans/fep-2001/adr/0008-scope-plugin-confined-reload-excluded.md @@ -0,0 +1,12 @@ +# 범위 — 플러그인 한정, core 불변, reload 제외 + +이 작업의 수정은 `plugin-history-sync` 안에 가두고 `@stackflow/core`의 이벤트/훅 계약은 바꾸지 않는다. 또한 페이지 새로고침(reload) 이후의 동기화는 이 범위에서 제외한다. + +## Considered Options + +- **core의 pre-effect 훅 계약 변경(예: `preventDefault` 시 후속 훅 중단/롤백)** — 기각. 모든 플러그인이 의존하는 계약을 넓히는 대신, 이 플러그인이 pre-effect에서 부작용을 없애 순서 의존성을 *소멸*시키는 쪽이 blast radius가 작고 되돌리기 쉽다. + +## Consequences + +- 문제 4(훅 순서 의존성)는 core 변경 없이 플러그인 측 무부작용화로 해소된다. +- reload 시에는 현재 엔트리 하나만 관측 가능해 entry ordinal을 재구축할 수 없다. 따라서 reload 직후의 과거 엔트리 동기화는 보장하지 않으며(후속 과제), 이 메커니즘은 "초기 진입 시 브라우저==스택"을 출발 불변식으로 삼는다. diff --git a/plans/fep-2001/glossary.md b/plans/fep-2001/glossary.md new file mode 100644 index 000000000..a7c19222c --- /dev/null +++ b/plans/fep-2001/glossary.md @@ -0,0 +1,46 @@ +# plugin-history-sync × preventDefault — 용어집 + +`plugin-history-sync`가 브라우저 history와 stackflow 스택을 동기화하는 메커니즘에서 쓰는 공통 언어. 일반 프로그래밍 개념이 아니라 이 맥락 고유의 용어만 정의한다. + +## Language + +**Entry ordinal**: +브라우저 history 엔트리 하나의 선형 위치 인덱스로, 이 플러그인이 소유하고 각 엔트리의 history `state`에 직접 기록하는 좌표. 동기화의 방향·거리는 전적으로 이 좌표로 정한다. +_Avoid_: index, depth, position id, 활동 id(순서 용도로) + +**Stack ordinal**: +현재 스택의 top 위치가 가리켜야 할 entry ordinal. 스택 구조(활동·step 수)만으로 계산한다. + +**Browser ordinal**: +현재 브라우저 엔트리의 `state`에서 읽은 entry ordinal. + +**Sync pass**: +브라우저 history를 *커밋된 현재 스택*에 맞추는 단 하나의 동작. `(현재 스택, 현재 엔트리)`의 순수·멱등 함수이며, `stack ordinal − browser ordinal`(= delta)로 push/뒤로이동/내용교체/무동작을 정한다. 브라우저를 변경하는 유일한 권위. +_Avoid_: reconcile loop, flush, commit-to-browser + +**Navigation attempt (translation)**: +사용자가 일으킨 브라우저 네비게이션(popstate)을, 대응하는 스택 액션으로 번역해 **액션 파이프라인으로 시도**하는 것. 브라우저를 직접 만지지 않는다 — 동기화는 이후 sync pass가 한다. +_Avoid_: apply, handle popstate(부작용 포함 의미로) + +**Restore**: +사용자 브라우저 네비게이션이 prevented되어 스택이 채택하지 않았을 때, sync pass가 브라우저를 스택 위치로 되돌리는 것. 별도 경로가 아니라 sync pass의 정상 동작(delta≠0의 한 경우)이다. +_Avoid_: rollback, undo + +**Self-induced navigation**: +이 플러그인이 동기화를 위해 스스로 일으킨 브라우저 history 변경(엔트리 생성/내용교체/뒤로이동). 사용자가 일으킨 네비게이션과 구분된다. +_Avoid_: silent navigation, internal nav + +**Suppression token**: +직렬 history 큐가 소유하는 단일 in-flight 표식. self-induced navigation이 진행 중임을 나타내며, 그동안 도착하는 history 변경을 사용자 네비게이션으로 처리하지 않게 한다. 이동이 없는 동작에는 set하지 않는다. +_Avoid_: silentFlag(코드 식별자), mutex, lock + +**Committed-effect anchoring**: +브라우저 history 부작용을 *커밋된* 스택 변화(post-effect)에만 일으키고, pre-effect 훅은 무부작용으로 두는 규율. prevented 네비게이션은 커밋되지 않으므로 history가 건드려지지 않는다. +_Avoid_: post-hook sync + +**Sole author**: +관련 브라우저 history 엔트리를 이 플러그인이 유일하게 발행한다는 전제. entry ordinal의 존재·일관성이 여기에 의존한다. + +**Eventual consistency (이 맥락에서)**: +모든 정지점(idle·입력 드레인 후)에서 브라우저==스택이 성립한다는 보장. 모든 순간의 글리치 0이 아니라, 영구 desync가 없음을 뜻한다. +_Avoid_: 강한 일관성, 즉시 일관성 diff --git a/plans/fep-2001/solution-plan.md b/plans/fep-2001/solution-plan.md new file mode 100644 index 000000000..74f7ddc1e --- /dev/null +++ b/plans/fep-2001/solution-plan.md @@ -0,0 +1,192 @@ +# `@stackflow/plugin-history-sync` × `preventDefault` 호환 — 솔루션 메커니즘 기획 + +## 목적과 범위 + +`@stackflow/plugin-history-sync`는 현재 `preventDefault`와 안전하게 공존하지 못한다. 이 플러그인이 유발하는 일부 네비게이션은 `preventDefault`로 취소할 수 없고, 일부는 `preventDefault`되는 순간 플러그인이 브라우저 history와 stackflow 스택을 어긋나게(desync) 만든다. + +이 문서는 그 네 가지 문제를 해소하는 **동작 메커니즘**을 확정한다. 다루는 것은 *원리·계약·타이밍·불변식*이며, 구현(파일·함수·자료구조)은 다루지 않는다. + +**범위 경계** +- 수정은 `plugin-history-sync` 안에 가둔다. core(`@stackflow/core`)의 이벤트/훅 계약은 바꾸지 않는다. +- 페이지 새로고침(reload) 이후의 동기화는 이 범위에서 제외한다(별도 과제). 이 문서의 메커니즘은 "초기 진입 시점에 브라우저와 스택이 일치한다"를 출발 불변식으로 삼고, 그 이후의 네비게이션을 다룬다. + +--- + +## 1. 네 가지 문제 (현재 동작) + +현재 코드의 동작을 사실로 확정한 결과는 다음과 같다. + +**문제 1 — 브라우저 뒤로가기 pop을 `preventDefault`할 수 없음.** +브라우저 backward navigation 시 플러그인은 `pop`을 `dispatchEvent("Popped")`로 직접 발행한다. core에서 `dispatchEvent`는 이벤트를 스택에 직접 커밋하며 액션 경로(`pop()`)와 달리 pre-effect 훅(`onBeforePop`)을 거치지 않는다. 따라서 다른 플러그인이 `onBeforePop`에서 `preventDefault()`를 호출해도 **아무 효과가 없다**. + +**문제 2 — 프로그래밍적 `pop()` 시 history desync.** +플러그인의 `onBeforePop`은 `history.back()`을 비동기 큐에 등록한다. 앱이 `pop()`을 호출하면 모든 `onBeforePop` 훅이 순회되는데, 이 플러그인의 훅이 `history.back()`을 큐에 넣은 *뒤* 다른 플러그인이 `preventDefault()`를 호출하면, stackflow 스택은 바뀌지 않지만 큐에 든 `history.back()`은 그대로 실행된다. 결과: **브라우저 URL은 이전 페이지, 스택은 현재 페이지로 불일치.** `onBeforeStepPop`·`onBeforeReplace`도 동일한 패턴이다. + +**문제 3 — 브라우저 앞으로가기 push가 prevented되면 desync + 카운터 오염.** +브라우저 forward navigation 시 플러그인은 내부 카운터(`pushFlag`)를 먼저 증가시키고 `push()`를 호출한다. `push()`가 `preventDefault`되면 `onPushed`가 호출되지 않아 카운터가 감소되지 않는다. 이후 정상적인 `push()`에서 `onPushed`가 "카운터>0"을 보고 history 동기화를 건너뛰어 **연쇄적 desync**가 발생한다. step 경로에도 같은 오염이 있다. + +**문제 4 — `onBeforePop` 훅 실행 순서 의존성.** +pre-effect 훅은 순차 실행되며, `preventDefault()`가 호출되어도 **이미 실행된 훅의 side effect는 롤백되지 않고, 루프도 중단되지 않으며, 이후 훅에 `isPrevented`가 노출되지도 않는다**. 따라서 이 플러그인이 다른 플러그인보다 먼저 등록되면 `history.back()`이 큐에 든 뒤 뒤늦게 `preventDefault`가 호출되는 구조가 된다. + +--- + +## 2. 근본 원인 (하나의 진단) + +네 문제는 하나의 뿌리를 공유한다. + +> 플러그인이 브라우저 history 부작용을 **pre-effect 훅에서 — 커밋 전, prevented 여부를 모르는 시점에, 되돌릴 수 없게** 일으키고, 브라우저-기인 네비게이션의 출처를 **낙관적 카운터로 추적**한다. 그리고 브라우저 뒤로가기는 액션 경로가 아닌 `dispatchEvent`로 처리해 훅 자체를 우회한다. + +- 문제 2·4 = pre-effect에서의 비가역 부작용 → prevented여도 history는 이미 움직임, 훅 순서에 의존. +- 문제 3 = 카운터가 이벤트와 1:1로 묶이지 않아 prevented 시 누수 → 연쇄 desync. +- 문제 1 = 브라우저 뒤로가기가 훅을 우회 → 다른 플러그인의 `onBeforePop`이 아예 실행되지 않음. + +--- + +## 3. 솔루션 메커니즘 + +### 3.1 지배 원리 — 두 가지 + +**(원리 A) 커밋된 effect에만 history를 만진다.** +브라우저 history 부작용은 *커밋된* 스택 변화(post-effect)에만 반응해서 일으킨다. pre-effect 훅(`onBeforePush`/`onBeforePop`/`onBeforeReplace`/`onBeforeStepPop` 등)은 관찰 가능한 부작용을 갖지 않는다(파라미터 정규화처럼 멱등·안전한 작업만 허용). prevented 네비게이션은 커밋되지 않으므로 post-effect가 발생하지 않고, 따라서 history도 건드려지지 않는다. + +**(원리 B) 브라우저는 "커밋된 스택"의 엄격한 추종자다 (단일 동기화 권위).** +브라우저 history를 변경하는 권위는 하나의 동기화 과정뿐이고, 그 목표는 언제나 *정착(idle)된 현재 스택*이다. 사용자가 일으킨 브라우저 네비게이션(popstate)은 브라우저를 직접 만지지 않는다 — 단지 그에 대응하는 스택 액션을 **액션 파이프라인으로 시도**할 뿐이다(그래서 다른 플러그인의 `onBefore*`가 실행되고 `preventDefault`가 존중된다). 그 시도가 커밋되든·버퍼링되든·prevented되든, 다음 idle에서 동기화 과정이 브라우저를 *최종 커밋된 스택*에 맞춘다. + +이 단일 방향성(브라우저←스택)이 모든 경우를 흡수한다: +- 사용자 네비가 **채택**(액션 커밋) → 스택이 사용자가 간 곳으로 이동 → 동기화 시 차이 0 → 무동작(브라우저는 사용자가 간 자리 유지). +- 사용자 네비가 **prevented** → 스택 불변 → 동기화가 브라우저를 스택으로 되돌림(= **복원은 특별 경로가 아니라 동기화의 정상 동작**). +- 프로그램 네비 → 스택 이동 → 동기화가 브라우저를 따라가게 함. + +브라우저 뮤테이션의 권위가 하나이고 목표가 하나(커밋된 스택)이며 스택은 파이프라인 커밋으로만 바뀌므로, 진동(oscillation)이 발생하지 않는다. + +### 3.2 위치 좌표 — entry ordinal + +브라우저 history는 navigable 위치마다 엔트리 1개를 갖는 선형 시퀀스다(활동 1개는 그 활동의 step 수만큼 엔트리를 차지한다). 각 엔트리의 history `state`에 **플러그인이 소유하는 위치 좌표 — entry ordinal(선형 위치 인덱스)** 를 기록한다. + +- **browser ordinal** = 현재 브라우저 엔트리의 `state`에서 직접 읽는다. +- **stack ordinal** = 현재 스택의 top이 가리켜야 할 ordinal로, 스택 구조(활동·step 수)만으로 계산한다(공개 Stack 인터페이스에만 의존). + +이 좌표는 **core의 활동 id가 시간순이라는 성질에 의존하지 않는다.** id는 "어느 활동/step인지"를 식별하는 **동일성(equality)** 매칭에만 쓰고, "어느 것이 더 최신인가"라는 **순서(ordering)** 판단에는 쓰지 않는다 — 순서·거리·방향은 전적으로 플러그인 소유의 ordinal로 결정한다. (core의 id 생성 방식은 구현 상세이므로 그 순서성에 기대지 않는다.) + +### 3.3 동기화 한 패스 — 프리미티브 선택 + +`delta = stack ordinal − browser ordinal`로 정의하면, 동기화 한 패스의 동작은 `(현재 스택, 현재 엔트리)`만의 **순수·멱등 함수**다: + +| 조건 | 의미 | 브라우저 동작 | 동기성 | +|---|---|---|---| +| `delta > 0` | 스택이 더 깊다 | `pushState` × `delta` (신규 엔트리, 증가하는 ordinal stamp) | 동기 | +| `delta < 0` | 스택이 더 얕다 | 기존 엔트리로 `\|delta\|`칸 뒤로 이동 | 비동기 | +| `delta == 0`, 동일성 불일치 | 같은 깊이, 다른 top | 현재 엔트리 내용 교체(`replaceState`) | 동기 | +| `delta == 0`, 동일성 일치 | 동기 상태 | 무동작 | — | + +- **거리는 뺄셈 한 번**, 방향은 부호로 정해진다 — 출처 추적이나 "무엇이 빠졌나" 조사가 필요 없다. +- `delta > 0`은 (신규 push의 publish이든, prevented된 뒤로가기의 복원이든) **항상 `pushState`**로 통일한다. 그래서 "스택이 성장했는지" 같은 부가 판별이 불필요하고, `pushState`는 동기라 제어가 단순하다. +- 브라우저 history를 *동기로 축소*하는 수단은 플랫폼에 없으므로, `delta < 0`(축소)만 비동기 뒤로 이동으로 남는다. 즉 **비동기 self-induced 동작은 "축소" 하나로 한정**된다. + +이 패스는 **cursor 위치만 조정한다.** `|delta|`가 1보다 클 때(여러 칸 뒤로 이동, 또는 여러 엔트리 push)에도 패스가 `(현재 스택, 현재 엔트리)`만으로 충분한 이유는, **cursor 아래 엔트리들의 ordinal·동일성이 매 패스 재검증되지 않고 귀납적으로 유지**되기 때문이다: 모든 엔트리는 생성·동기 시점에 올바르게 stamp되었고(sole-author), 어떤 패스도 cursor 아래 엔트리를 훼손하지 않으며(직전 패스들의 멱등성), 따라서 뒤로 이동이 닿는 하위 엔트리는 옳다고 신뢰할 수 있고 위로 쌓는 push는 일관된 기반 위에서 일어난다. + +### 3.4 동기화 엔진 — 직렬화·억제 토큰·coalesce·idle 게이팅 + +동기화는 단일 직렬 큐 위에서 돌아간다. + +**입력과 행동** +- **입력: 커밋된 스택 변화** → 동기화 패스를 예약한다. +- **입력: 사용자 popstate**(아래 억제 토큰이 대기 중이 아닐 때 도착한 history 리스너 콜백) → ① 대응하는 스택 액션을 **즉시 파이프라인으로 디스패치**(브라우저는 안 만짐) ② 동기화 패스를 예약한다. +- **동기화 패스**(유일한 브라우저 변경 권위) → **idle일 때만** 실행하고, 여러 예약은 1회로 **coalesce**한다. §3.3의 순수 함수를 적용하며 **멱등**(차이 0이면 무동작)이다. + +**self-induced 네비게이션 억제(억제 토큰)** +- 직렬 큐가 소유하는 **단일 in-flight 토큰**. 큐는 self-induced 브라우저 동작을 한 번에 하나만 in-flight로 두고, 그 동작의 리스너 콜백이 돌아올 때까지 블록한다. +- 동작 직전 토큰을 set → 그 콜백이 토큰을 소비 → 다음 동작. 직렬화 덕에 set↔consume이 정확히 1:1이다. +- **이동이 없는 동작에는 토큰을 set하지 않는다**(차이 0인 패스는 동작을 발행하지 않는다). 콜백이 없을 동작에 토큰을 걸어 이후의 진짜 사용자 popstate를 잘못 삼키는 누수를 차단한다. +- **토큰이 set된 동안 도착하는 모든 history 변경은 사용자 네비게이션으로 처리하지 않는다.** 그 창에서 사용자가 일으킨 네비게이션은 드롭되며, 직후 동기화 패스가 브라우저를 스택에 재정렬하므로 무해하게 덮인다(일관성 유지). + +**왜 idle 게이팅이 필수인가** +core는 버퍼링되는 이벤트에 대해서도 변화 알림을 발화하므로(스택이 paused이면 네비게이션 이벤트가 버퍼에 쌓이며 "무언가 바뀜"이 통지된다), 동기화 *판단*은 반드시 정착(idle) 상태에서만 내려야 한다. paused/loading 중에는 통지가 와도 보류하고, 정착 후 최종 상태로만 수렴한다. 이로써 lazy 컴포넌트의 비동기 push, 그리고 paused 상태에서 버퍼링되는 pop/stepPop의 **비동기 커밋이 자동으로 흡수**된다. + +**prevented가 반드시 복원되는 이유** +사용자 popstate는 *항상 동기화 패스를 예약*한다. 따라서 시도한 액션이 prevented되어 커밋 변화 통지가 없더라도, 예약된 동기화 패스가 idle에서 차이≠0을 관측해 브라우저를 스택으로 복원한다. + +### 3.5 동시성과 정확성 보장 + +**다중·연속 사용자 네비게이션.** 각 popstate는 자기 엔트리의 `state`(ordinal·식별 정보)를 실어 온다. 동기화는 *현재* 브라우저 위치를 기준으로 스택이 그 위치에 닿도록 필요한 만큼 액션을 디스패치하고, 동기화 패스는 입력 버스트가 빠지고(대기 중 사용자-네비 처리가 없고) idle일 때 1회만 돈다. 그래서 "여러 칸 되감긴 동안 한 칸만 처리한 상태에서 브라우저를 앞으로 밀어버리는" 종류의 어긋남이 발생하지 않는다(그 시점엔 스택 top이 현재 cursor와 일치 → 차이 0 → 무동작). + +**프로그램 네비와 사용자 네비의 진짜 동시 발생.** 이 race는 **직접 중재하지 않는다.** 두 네비게이션 모두 core의 이벤트 스트림에 들어가고, core의 결정적 이벤트 합성(버퍼링된 이벤트의 순서 replay 포함)이 최종 스택을 정한다. 브라우저는 그 최종 스택을 추종한다. 결과적으로 안식 위치는 이벤트 순서로 **결정적**이며, **브라우저와 스택은 일관**된다. (예: lazy push가 로딩 중일 때 사용자가 뒤로가기를 누르면, push와 pop이 디스패치 순서대로 합성되어 상쇄되고 브라우저는 그 결과에 맞춰진다.) "지금 race인가?"를 감지해 한쪽을 우선시키는 특수 분기를 두지 않으므로, 그 감지·취소 로직이 만들 새로운 fragility가 없다. + +**정확성 보장의 성격 — eventual consistency.** +정확성의 유일한 하중 기둥은 §3.3의 동기화 패스가 **관측값의 순수·멱등 함수**라는 점이다. 모든 정지점(idle·입력 드레인 후)에서 동기화가 다시 돌아 브라우저를 커밋된 스택에 재정렬한다. 따라서 직전에 콜백을 오인했든·플랫폼이 popstate 순서를 보장하지 않았든·중간에 일시 글리치가 있었든, **다음 정지점에서 관측된 진실로부터 다시 수렴**한다. + +- 보장하는 것: **정지점마다 브라우저==스택**(영구 desync 없음). +- 보장하지 않는 것: 모든 순간·모든 레이스에서 글리치 0. 극한 레이스에서 일시 글리치나 사용자 입력 1회 손실은 가능하다. +- 이 경계는 의도적이다 — 네 문제는 전부 *영구* desync다(pre-effect 비가역 부작용 + 누수 카운터). 이 메커니즘의 최악은 *정지점에서 자가 치유되는 일시* 글리치다. **영구 → 일시**로의 범주적 개선이 이 설계의 핵심이다. (HTML 플랫폼은 self-induced 네비게이션과 사용자 네비게이션이 인터리브될 때 popstate 발화 순서/귀속을 보장하지 않는다. "모든 레이스에서 입력 무손실"을 보장하려면 네비게이션 의도를 core가 일급으로 직렬화해야 하며, 이는 플러그인-한정·core-불변 범위 밖이다.) + +--- + +## 4. 문제별 해소 + +각 문제에 대해: 메커니즘 → 어떻게·왜 해소되는가 → 의존 전제 → 호환 계약. + +### 문제 1 — 브라우저 뒤로가기 pop의 `preventDefault` +- **메커니즘**: 브라우저 뒤로가기를 `dispatchEvent` 우회 대신 액션 파이프라인(`pop`/`stepPop`)으로 흘린다(원리 B). 다른 플러그인의 `onBeforePop`이 실행되고 `preventDefault`가 존중된다. +- **왜 해소되나**: 커밋되면 브라우저는 이미 그 자리이므로 동기화는 차이 0으로 무동작. prevented면 스택 불변 → 동기화가 브라우저를 원위치로 복원. 어느 쪽이든 일관. +- **전제**: 사용자 popstate가 항상 동기화를 예약(복원 보장). idle 게이팅(비동기 커밋 흡수). +- **호환**: 브라우저-기인 네비게이션도 이제 모든 플러그인의 `onBefore*`를 통과한다 — 이는 "`onBeforePop`이 언제 발화하는가"의 공개 시맨틱 확장이며, blocker 류 플러그인이 브라우저 뒤로가기를 가로챌 수 있게 한다(바로 그것이 목표). + +### 문제 2 — 프로그래밍적 `pop()` 시 desync +- **메커니즘**: history 부작용을 pre-effect 훅에서 제거한다(원리 A). `onBeforePop`/`onBeforeStepPop`/`onBeforeReplace`는 더 이상 `history.back()`을 큐에 넣지 않는다. +- **왜 해소되나**: 다른 플러그인이 `preventDefault`하면 커밋이 없고 → 커밋 변화 통지가 없고 → 동기화가 브라우저를 건드리지 않는다. 더 이상 "큐에 든 back()이 늦게 실행"되는 일이 없다. +- **전제**: pre-effect 훅이 멱등·무부작용. 커밋된 스택 변화에만 동기화. +- **호환**: prevented 시 스택·URL 둘 다 변화 없음으로 일관. 커밋 시 동기화가 URL을 맞춤. + +### 문제 3 — 브라우저 앞으로가기 push prevented 시 desync + 카운터 오염 +- **메커니즘**: 출처 추적용 카운터(`pushFlag`)를 폐기한다. 동기화는 §3.3의 ordinal 차이로 동작을 정한다. +- **왜 해소되나**: 브라우저 앞으로가기는 `push`를 파이프라인으로 시도(원리 B). prevented면 스택 불변 → 동기화가 브라우저를 복원. 커밋되면 동기화가 "이미 그 자리"로 무동작. 영속 카운터가 없으니 누수·연쇄 오염이 불가능하다. step 경로도 동일. +- **전제**: 출처는 추적 대상이 아니라 `(스택, 엔트리)` 비교의 귀결. +- **호환**: prevented/커밋 모두 일관된 스택·URL을 남긴다. + +### 문제 4 — `onBeforePop` 훅 실행 순서 의존성 +- **메커니즘**: 모든 history 부작용이 커밋 후 동기화로 이동했으므로, 이 플러그인의 pre-effect 훅은 관찰 가능한 부작용이 없다(원리 A). +- **왜 해소되나**: pre-effect 훅이 아무 부작용도 일으키지 않으면, 훅들의 실행 순서·core의 무롤백/무중단 동작이 **무관**해진다(의존성이 소멸한다). 어느 플러그인이 먼저 등록되든 결과가 같다. +- **전제**: 이 플러그인의 모든 `onBefore*`가 멱등·무부작용. +- **호환**: 등록 순서에 독립. core의 pre-effect 계약을 바꾸지 않고도 순서 의존성이 사라진다. + +--- + +## 5. 호환 계약 — `preventDefault` 소비자(예: `plugin-blocker`) + +`preventDefault`의 대표 소비자는 "지금 막고, 나중에 (사용자 확인 후) 재발행한다"는 모델로 동작한다: 차단 조건이면 즉시 `preventDefault()`하고, 이후 비동기로 사용자에게 확인을 구한 뒤 진행이 결정되면 동일 액션을 재발행(replay)한다. + +이 메커니즘이 그 소비자와 공존할 때 보장하는 계약: +- **막을 때**: 액션이 커밋되지 않는다 → 동기화가 브라우저를 건드리지 않는다. 브라우저가 이미 움직인 경우(브라우저-기인 네비게이션이 막힌 경우)에는 동기화가 브라우저를 스택 위치로 복원한다. → 화면·URL이 막힌 위치에 머문다. +- **진행할 때**(재발행): 액션이 커밋된다 → 커밋 변화 통지 → 동기화가 브라우저를 새 스택에 맞춘다. → 비동기 간격을 가로질러도 누수 카운터 없이 정확히 동기화된다. +- **등록 순서 독립**: 이 플러그인의 `onBefore*`는 무부작용이므로, blocker가 이 플러그인보다 먼저 등록되든 나중이든 결과가 같다(문제 4 해소의 직접 귀결). + +모든 정지점에서 브라우저==스택이 성립한다. + +--- + +## 6. 불변식·전제 (하중 가정) + +이 메커니즘이 의존하는 전제를 명시한다. + +- **단독 발행자(sole author)**: 관련 브라우저 history 엔트리는 이 플러그인이 유일하게 발행한다. 그래서 모든 엔트리에 ordinal이 존재하고 일관된다. +- **하위 엔트리 일관성의 귀납적 유지**: 동기화 패스는 cursor 위치만 조정하고 cursor 아래 엔트리의 ordinal·동일성을 매번 재검증하지 않는다. 그 일관성은 귀납적으로 유지된다 — 각 엔트리는 생성·동기 시점에 올바르게 stamp되었고(sole-author), 어떤 패스도 하위 엔트리를 훼손하지 않는다(직전 패스들의 멱등성). 이 귀납 불변식이 있어 `(현재 스택, 현재 엔트리)`만의 순수 함수가 `|delta|>1`에서도 충분하다. +- **위치는 플러그인 소유 ordinal로 결정**: 거리·방향은 `state`에 stamp된 ordinal로 정한다. core의 활동 id 순서성에 의존하지 않는다. id는 동일성 매칭에만 쓴다. +- **레벨 트리거**: core는 커밋된 스택 변화마다(비동기 resume 후의 지연 커밋 포함) 변화 통지를 발화한다. 동기화는 이 통지에 반응한다. +- **self-induced 억제**: 이 플러그인이 일으킨 모든 history 변경은 억제 토큰으로 자기 리스너 콜백을 1:1 소비한다. 토큰이 set된 동안의 사용자 네비게이션은 차단한다. +- **정착 게이팅**: 동기화 *판단*은 idle에서만. paused/loading의 중간 상태로 판단하지 않는다. +- **차단 결정은 동기**: 액션의 `preventDefault` 평가는 (effect가 버퍼링되더라도) 파이프라인에서 동기적으로 일어난다 → 차단 결정이 비동기 타이밍에 유실되지 않는다. +- **초기 일치**: 초기 진입 시점에 브라우저와 스택은 일치한다(출발 불변식). + +--- + +## 7. 비목표 (이 범위에서 하지 않는 것) + +- **core의 pre-effect 훅 계약 변경 안 함.** 문제 4는 core를 고쳐서가 아니라 이 플러그인이 pre-effect에서 부작용을 없애 *순서 의존성을 소멸*시켜 해소한다. +- **새로고침 이후 동기화 안 다룸.** reload 시 브라우저 엔트리의 ordinal을 재구축할 수 없으므로 이 범위에서 제외한다(후속 과제). +- **race를 직접 중재하지 않음.** 동시 네비게이션의 안식 위치는 core의 결정적 이벤트 합성으로 결정하고 브라우저가 추종한다(일관성 보장, 특정 "승자" 정책 없음). +- **공개 이벤트/effect에 새 필드 추가 안 함.** 위치 좌표(ordinal)는 history 엔트리 `state`에 담는 플러그인 내부 데이터이며, core 이벤트/effect 계약을 넓히지 않는다. +- **모든 레이스에서 사용자 입력 무손실은 보장하지 않음.** 보장은 eventual consistency(영구 desync 없음)다(§3.5). + +--- + +용어 정의는 [glossary.md](./glossary.md)에, 핵심 결정과 그 근거는 [adr/](./adr/)에 있다. diff --git a/yarn.lock b/yarn.lock index 99d920580..a524d75f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4367,6 +4367,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.49.1": + version: 1.61.1 + resolution: "@playwright/test@npm:1.61.1" + dependencies: + playwright: "npm:1.61.1" + bin: + playwright: cli.js + checksum: 10/08915357031e1bc273a19bb428fb7e51031ca44f25750832e389bcd69c399f6a243f5f832e7832d46153b57b1305f1f5f5dd63f3a3261f928f61e464c6b3c64e + languageName: node + linkType: hard + "@popperjs/core@npm:^2.11.8": version: 2.11.8 resolution: "@popperjs/core@npm:2.11.8" @@ -5713,6 +5724,37 @@ __metadata: languageName: unknown linkType: soft +"@stackflow/e2e-history-sync-blocker@workspace:e2e": + version: 0.0.0-use.local + resolution: "@stackflow/e2e-history-sync-blocker@workspace:e2e" + dependencies: + "@playwright/test": "npm:^1.49.1" + "@stackflow/config": "npm:^2.0.0" + "@stackflow/core": "npm:^2.0.0" + "@stackflow/plugin-blocker": "npm:^0.1.1" + "@stackflow/plugin-history-sync": "npm:^1.11.0" + "@stackflow/plugin-renderer-basic": "npm:^1.1.14" + "@stackflow/react": "npm:^2.0.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/node": "npm:^20.14.9" + "@types/react": "npm:^18.3.3" + "@types/react-dom": "npm:^18.3.0" + "@vitejs/plugin-react": "npm:^4.3.1" + history: "npm:^5.3.0" + 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:^3.0.2" + typescript: "npm:^5.5.3" + vite: "npm:^5.3.2" + languageName: unknown + linkType: soft + "@stackflow/esbuild-config@npm:^1.0.2, @stackflow/esbuild-config@npm:^1.0.3, @stackflow/esbuild-config@workspace:packages/esbuild-config": version: 0.0.0-use.local resolution: "@stackflow/esbuild-config@workspace:packages/esbuild-config" @@ -5790,7 +5832,7 @@ __metadata: languageName: unknown linkType: soft -"@stackflow/plugin-blocker@workspace:extensions/plugin-blocker": +"@stackflow/plugin-blocker@npm:^0.1.1, @stackflow/plugin-blocker@workspace:extensions/plugin-blocker": version: 0.0.0-use.local resolution: "@stackflow/plugin-blocker@workspace:extensions/plugin-blocker" dependencies: @@ -9978,6 +10020,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:2.3.2": + version: 2.3.2 + resolution: "fsevents@npm:2.3.2" + dependencies: + node-gyp: "npm:latest" + checksum: 10/6b5b6f5692372446ff81cf9501c76e3e0459a4852b3b5f1fc72c103198c125a6b8c72f5f166bdd76ffb2fca261e7f6ee5565daf80dca6e571e55bcc589cc1256 + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@npm:^2.3.2, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -9988,6 +10040,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": + version: 2.3.2 + resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" @@ -14359,6 +14420,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.61.1": + version: 1.61.1 + resolution: "playwright-core@npm:1.61.1" + bin: + playwright-core: cli.js + checksum: 10/b0e7b1d4de7ca6c1a57eb88f1709609a8b7637092f149e703d84d6c8e01835992ec5ca26b86f1a32fb5f5bc1aad042c3ae27311e131b3dda8a27824a543eb3c7 + languageName: node + linkType: hard + +"playwright@npm:1.61.1": + version: 1.61.1 + resolution: "playwright@npm:1.61.1" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.61.1" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10/0cd1a8a7e106202e785450763973bf326d4aa112ed195bbd78b17af43dcfd12e29bc8204ae61c88f748dd0f2bdf69a7827bc3bbf8c1ec4c71b8e35586e05f13c + languageName: node + linkType: hard + "postcss-import@npm:^15.1.0": version: 15.1.0 resolution: "postcss-import@npm:15.1.0"