From 0b48b62352518dab5cff94357b6868bdeb4b0e65 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Sun, 31 May 2026 23:09:01 +0900 Subject: [PATCH 01/15] fix(plugin-history-sync): prevent SSR hydration mismatch with non-empty defaultHistory The staged `defaultHistory` setup navigation was kicked off synchronously in the `onInit` hook, which runs during the first client render. The client's first render therefore contained more activities than the server-rendered output, causing a React hydration mismatch. Kick off the setup navigation from a post-commit effect in `wrapStack` instead, so the server and the client's first render produce identical output. The staged "stacking" setup animation still plays after hydration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../fix-ssr-hydration-default-history.md | 9 + .pnp.cjs | 60 ++++-- extensions/plugin-history-sync/jest.setup.js | 10 + extensions/plugin-history-sync/package.json | 22 +- .../src/historySyncPlugin.ssr.spec.tsx | 188 ++++++++++++++++++ .../src/historySyncPlugin.tsx | 87 ++++++-- yarn.lock | 6 + 7 files changed, 348 insertions(+), 34 deletions(-) create mode 100644 .changeset/fix-ssr-hydration-default-history.md create mode 100644 extensions/plugin-history-sync/jest.setup.js create mode 100644 extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx diff --git a/.changeset/fix-ssr-hydration-default-history.md b/.changeset/fix-ssr-hydration-default-history.md new file mode 100644 index 000000000..10f376267 --- /dev/null +++ b/.changeset/fix-ssr-hydration-default-history.md @@ -0,0 +1,9 @@ +--- +"@stackflow/plugin-history-sync": patch +--- + +Fix an SSR hydration mismatch that occurred when an activity declared a non-empty `defaultHistory`. + +The staged `defaultHistory` setup navigation was kicked off synchronously inside the `onInit` hook, which runs during the first client render. As a result, the client's first render contained more activities than the server-rendered output, producing a React hydration mismatch. + +The setup navigation is now kicked off from a post-commit effect instead, so the server and the client's first render produce identical output (eliminating the mismatch) while the staged "stacking" setup animation still plays after hydration. diff --git a/.pnp.cjs b/.pnp.cjs index 562efbae1..f7dd840d8 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -6831,7 +6831,7 @@ const RAW_RUNTIME_STATE = ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.27.3"],\ ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ - ["jest-environment-jsdom", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:29.7.0"],\ ["react", "npm:18.3.1"],\ ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ ["rimraf", "npm:6.1.3"],\ @@ -6900,12 +6900,16 @@ const RAW_RUNTIME_STATE = ["@stackflow/config", "workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/plugin-renderer-basic", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic"],\ ["@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"],\ ["@types/react-relay", "npm:16.0.6"],\ ["@types/relay-runtime", "npm:17.0.4"],\ ["@types/stackflow__config", null],\ @@ -6916,7 +6920,9 @@ const RAW_RUNTIME_STATE = ["graphql", "npm:16.9.0"],\ ["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"],\ ["react-relay", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:17.0.0"],\ ["react18-use", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:0.4.1"],\ ["relay-compiler", "npm:17.0.0"],\ @@ -6945,12 +6951,16 @@ const RAW_RUNTIME_STATE = ["@stackflow/config", "workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/plugin-renderer-basic", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic"],\ ["@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"],\ ["@types/react-relay", "npm:16.0.6"],\ ["@types/relay-runtime", "npm:17.0.4"],\ ["esbuild", "npm:0.23.0"],\ @@ -6958,7 +6968,9 @@ const RAW_RUNTIME_STATE = ["graphql", "npm:16.9.0"],\ ["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"],\ ["react-relay", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:17.0.0"],\ ["react18-use", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:0.4.1"],\ ["relay-compiler", "npm:17.0.0"],\ @@ -6988,7 +7000,7 @@ const RAW_RUNTIME_STATE = ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.27.3"],\ ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ - ["jest-environment-jsdom", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:29.7.0"],\ ["react", "npm:18.3.1"],\ ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ ["rimraf", "npm:6.1.3"],\ @@ -7536,6 +7548,28 @@ const RAW_RUNTIME_STATE = "react"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:16.3.2", {\ + "packageLocation": "./.yarn/__virtual__/@testing-library-react-virtual-2b91e4da33/0/cache/@testing-library-react-npm-16.3.2-67b0b894c8-0ca88c6f67.zip/node_modules/@testing-library/react/",\ + "packageDependencies": [\ + ["@testing-library/react", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:16.3.2"],\ + ["@babel/runtime", "npm:7.25.0"],\ + ["@testing-library/dom", "npm:10.4.1"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/react-dom", "npm:18.3.0"],\ + ["@types/testing-library__dom", null],\ + ["react", "npm:18.3.1"],\ + ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"]\ + ],\ + "packagePeers": [\ + "@testing-library/dom",\ + "@types/react-dom",\ + "@types/react",\ + "@types/testing-library__dom",\ + "react-dom",\ + "react"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@theguild/remark-mermaid", [\ @@ -13035,10 +13069,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:29.7.0", {\ - "packageLocation": "./.yarn/__virtual__/jest-environment-jsdom-virtual-03ba513b4a/0/cache/jest-environment-jsdom-npm-29.7.0-0b72dd0e0b-23bbfc9bca.zip/node_modules/jest-environment-jsdom/",\ + ["virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:29.7.0", {\ + "packageLocation": "./.yarn/__virtual__/jest-environment-jsdom-virtual-d73fb547c8/0/cache/jest-environment-jsdom-npm-29.7.0-0b72dd0e0b-23bbfc9bca.zip/node_modules/jest-environment-jsdom/",\ "packageDependencies": [\ - ["jest-environment-jsdom", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:29.7.0"],\ ["@jest/environment", "npm:29.7.0"],\ ["@jest/fake-timers", "npm:29.7.0"],\ ["@jest/types", "npm:29.6.3"],\ @@ -13048,7 +13082,7 @@ const RAW_RUNTIME_STATE = ["canvas", null],\ ["jest-mock", "npm:29.7.0"],\ ["jest-util", "npm:29.7.0"],\ - ["jsdom", "virtual:03ba513b4a4f2f49a0ee779e0b1da3ef4f41cbf0cff4a27f151a6c11d5162aae67852dc5c3f387d71c020640c3547cdf783b461f72a6ebbd7907fd3300ce6913#npm:20.0.3"]\ + ["jsdom", "virtual:d73fb547c83cd373e54051241006dd5111f3929c35082fa12022d7454abb68ba65a5b3d1da439c25f6e074dab2bfdfd7fe108f112eb53571cb23e5b6dfd39eb0#npm:20.0.3"]\ ],\ "packagePeers": [\ "@types/canvas",\ @@ -13411,10 +13445,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:03ba513b4a4f2f49a0ee779e0b1da3ef4f41cbf0cff4a27f151a6c11d5162aae67852dc5c3f387d71c020640c3547cdf783b461f72a6ebbd7907fd3300ce6913#npm:20.0.3", {\ - "packageLocation": "./.yarn/__virtual__/jsdom-virtual-09fbede01d/0/cache/jsdom-npm-20.0.3-906a2f7005-a4cdcff5b0.zip/node_modules/jsdom/",\ + ["virtual:d73fb547c83cd373e54051241006dd5111f3929c35082fa12022d7454abb68ba65a5b3d1da439c25f6e074dab2bfdfd7fe108f112eb53571cb23e5b6dfd39eb0#npm:20.0.3", {\ + "packageLocation": "./.yarn/__virtual__/jsdom-virtual-0438050dbf/0/cache/jsdom-npm-20.0.3-906a2f7005-a4cdcff5b0.zip/node_modules/jsdom/",\ "packageDependencies": [\ - ["jsdom", "virtual:03ba513b4a4f2f49a0ee779e0b1da3ef4f41cbf0cff4a27f151a6c11d5162aae67852dc5c3f387d71c020640c3547cdf783b461f72a6ebbd7907fd3300ce6913#npm:20.0.3"],\ + ["jsdom", "virtual:d73fb547c83cd373e54051241006dd5111f3929c35082fa12022d7454abb68ba65a5b3d1da439c25f6e074dab2bfdfd7fe108f112eb53571cb23e5b6dfd39eb0#npm:20.0.3"],\ ["@types/canvas", null],\ ["abab", "npm:2.0.6"],\ ["acorn", "npm:8.16.0"],\ @@ -13441,7 +13475,7 @@ const RAW_RUNTIME_STATE = ["whatwg-encoding", "npm:2.0.0"],\ ["whatwg-mimetype", "npm:3.0.0"],\ ["whatwg-url", "npm:11.0.0"],\ - ["ws", "virtual:09fbede01d752e610be1714c18909368fe4fa709b16e76a5ed8cde05b6dbb3342f037902ae401113b5bbbb44b9753fbd2ba83c3277f1f798491ade558971e25f#npm:8.19.0"],\ + ["ws", "virtual:0438050dbf5ed1c2c6d4f1cdc7bf2ebf7d6d9e79021718a789406c9fd2099a8bd4116fcb07828b27c3c9ecfcdbdaeabf767186e6d032791bcca61f3edb6a5d9e#npm:8.19.0"],\ ["xml-name-validator", "npm:4.0.0"]\ ],\ "packagePeers": [\ @@ -19462,10 +19496,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:09fbede01d752e610be1714c18909368fe4fa709b16e76a5ed8cde05b6dbb3342f037902ae401113b5bbbb44b9753fbd2ba83c3277f1f798491ade558971e25f#npm:8.19.0", {\ - "packageLocation": "./.yarn/__virtual__/ws-virtual-99b0ff26e3/0/cache/ws-npm-8.19.0-c967c046a5-26e4901e93.zip/node_modules/ws/",\ + ["virtual:0438050dbf5ed1c2c6d4f1cdc7bf2ebf7d6d9e79021718a789406c9fd2099a8bd4116fcb07828b27c3c9ecfcdbdaeabf767186e6d032791bcca61f3edb6a5d9e#npm:8.19.0", {\ + "packageLocation": "./.yarn/__virtual__/ws-virtual-eaabac7ea7/0/cache/ws-npm-8.19.0-c967c046a5-26e4901e93.zip/node_modules/ws/",\ "packageDependencies": [\ - ["ws", "virtual:09fbede01d752e610be1714c18909368fe4fa709b16e76a5ed8cde05b6dbb3342f037902ae401113b5bbbb44b9753fbd2ba83c3277f1f798491ade558971e25f#npm:8.19.0"],\ + ["ws", "virtual:0438050dbf5ed1c2c6d4f1cdc7bf2ebf7d6d9e79021718a789406c9fd2099a8bd4116fcb07828b27c3c9ecfcdbdaeabf767186e6d032791bcca61f3edb6a5d9e#npm:8.19.0"],\ ["@types/bufferutil", null],\ ["@types/utf-8-validate", null],\ ["bufferutil", null],\ diff --git a/extensions/plugin-history-sync/jest.setup.js b/extensions/plugin-history-sync/jest.setup.js new file mode 100644 index 000000000..2ea7287cd --- /dev/null +++ b/extensions/plugin-history-sync/jest.setup.js @@ -0,0 +1,10 @@ +const { TextDecoder, TextEncoder } = require("node:util"); + +// `jest-environment-jsdom` does not expose `TextEncoder`/`TextDecoder`, which +// `react-dom/server` requires. Polyfill them for the SSR/hydration tests. +if (typeof globalThis.TextEncoder === "undefined") { + globalThis.TextEncoder = TextEncoder; +} +if (typeof globalThis.TextDecoder === "undefined") { + globalThis.TextDecoder = TextDecoder; +} diff --git a/extensions/plugin-history-sync/package.json b/extensions/plugin-history-sync/package.json index 8005836a6..6a2c526c8 100644 --- a/extensions/plugin-history-sync/package.json +++ b/extensions/plugin-history-sync/package.json @@ -36,8 +36,22 @@ "index.ts", "index.tsx" ], + "setupFiles": [ + "/jest.setup.js" + ], "transform": { - "^.+\\.(t|j)sx?$": "@swc/jest" + "^.+\\.(t|j)sx?$": [ + "@swc/jest", + { + "jsc": { + "transform": { + "react": { + "runtime": "automatic" + } + } + } + } + ] } }, "dependencies": { @@ -51,18 +65,24 @@ "@stackflow/config": "^1.2.1", "@stackflow/core": "^1.3.0", "@stackflow/esbuild-config": "^1.0.3", + "@stackflow/plugin-renderer-basic": "^1.1.13", "@stackflow/react": "^1.7.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", "@types/react-relay": "^16.0.6", "@types/relay-runtime": "^17", "esbuild": "^0.23.0", "graphql": "^16.9.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "react": "^18.3.1", + "react-dom": "^18.3.1", "react-relay": "^17.0.0", "relay-compiler": "^17.0.0", "relay-runtime": "^17.0.0", diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx new file mode 100644 index 000000000..cdedda998 --- /dev/null +++ b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx @@ -0,0 +1,188 @@ +/** @jest-environment jsdom */ +import { + type CoreStore, + makeCoreStore, + makeEvent, + type Stack, +} from "@stackflow/core"; +import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; +import { type ActivityComponentType, stackflow } from "@stackflow/react"; +import { act } from "@testing-library/react"; +import { createMemoryHistory } from "history"; +import { hydrateRoot } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { historySyncPlugin } from "./historySyncPlugin"; + +/** + * Regression tests for the SSR hydration mismatch that occurred when an + * activity declared a non-empty `defaultHistory`. + * + * The server renders the "frame 0" stack (the first `defaultHistory` entry), + * because the staged setup navigation is now kicked off from a post-commit + * effect that never runs during SSR. The client's first render must therefore + * match that frame, and the staged "stacking" setup animation must still play + * after hydration. + */ + +const TRANSITION_DURATION = 32; + +const SSR_INITIAL_CONTEXT = { req: { path: "/articles/1" } }; + +let eventDateOffset = 0; +const pastEventDate = () => { + eventDateOffset += 1; + return new Date(Date.now() - 60 * 1000).getTime() + eventDateOffset; +}; + +const liveActivityNames = (stack: Stack) => + stack.activities + .filter((activity) => activity.transitionState !== "exit-done") + .map((activity) => activity.name); + +function makeHistorySyncPlugin(options: { withDefaultHistory: boolean }) { + return historySyncPlugin({ + history: createMemoryHistory({ initialEntries: ["/articles/1"] }), + routes: { + Home: "/", + Article: options.withDefaultHistory + ? { + path: "/articles/:articleId", + defaultHistory: () => [ + { activityName: "Home", activityParams: {} }, + ], + } + : "/articles/:articleId", + }, + fallbackActivity: () => "Home", + }); +} + +function buildCoreStore(options: { withDefaultHistory: boolean }): CoreStore { + return makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration: TRANSITION_DURATION, + eventDate: pastEventDate(), + }), + makeEvent("ActivityRegistered", { + activityName: "Home", + eventDate: pastEventDate(), + }), + makeEvent("ActivityRegistered", { + activityName: "Article", + eventDate: pastEventDate(), + }), + ], + initialContext: SSR_INITIAL_CONTEXT, + plugins: [makeHistorySyncPlugin(options)], + }); +} + +const Home: ActivityComponentType = () =>
home
; +const Article: ActivityComponentType = () => ( +
article
+); + +function makeApp() { + return stackflow({ + transitionDuration: TRANSITION_DURATION, + activities: { Home, Article }, + plugins: [ + basicRendererPlugin(), + makeHistorySyncPlugin({ withDefaultHistory: true }), + ], + }); +} + +describe("historySyncPlugin - SSR hydration with defaultHistory", () => { + test("store.init() no longer advances a non-empty defaultHistory setup (the server renders frame 0)", () => { + const coreStore = buildCoreStore({ withDefaultHistory: true }); + + // The constructor releases only the first (underlay) entry. The server, + // which never calls init(), renders exactly this frame. + expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Home"]); + + // init() must NOT push the destination anymore — that now happens in a + // post-commit effect — so the client's first render matches the server. + coreStore.init(); + expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Home"]); + }); + + test("an empty defaultHistory still resolves directly to the destination (unchanged)", () => { + const coreStore = buildCoreStore({ withDefaultHistory: false }); + + expect(liveActivityNames(coreStore.actions.getStack())).toEqual([ + "Article", + ]); + coreStore.init(); + expect(liveActivityNames(coreStore.actions.getStack())).toEqual([ + "Article", + ]); + }); + + test("the destination is not rendered during SSR (it is deferred to a post-commit effect)", () => { + const app = makeApp(); + + const html = renderToString( + , + ); + + expect(html).toContain('data-testid="home"'); + expect(html).not.toContain('data-testid="article"'); + }); + + test("hydration produces no mismatch and the staged setup animation plays afterwards", async () => { + jest.useFakeTimers(); + + // --- Server render: simulate a non-browser runtime so `store.init()` is + // skipped, exactly as it is on a real server. The server commits frame 0. + const serverApp = makeApp(); + const originalWindow = global.window; + let serverHTML = ""; + try { + // biome-ignore lint/performance/noDelete: simulate a non-browser runtime + delete (global as { window?: unknown }).window; + serverHTML = renderToString( + , + ); + } finally { + (global as { window?: unknown }).window = originalWindow; + } + + expect(serverHTML).toContain('data-testid="home"'); + expect(serverHTML).not.toContain('data-testid="article"'); + + // --- Client hydration of that server HTML. + const container = document.createElement("div"); + container.innerHTML = serverHTML; + document.body.appendChild(container); + + const recoverableErrors: unknown[] = []; + const clientApp = makeApp(); + + await act(async () => { + hydrateRoot( + container, + , + { + onRecoverableError: (error: unknown) => { + recoverableErrors.push(error); + }, + }, + ); + }); + + // The client's first render equalled the server's frame 0 → no mismatch. + expect(recoverableErrors).toEqual([]); + + // The staged setup navigation ran in the post-commit effect, so the + // destination mounts after hydration — the stacking animation plays. + await act(async () => { + jest.advanceTimersByTime(TRANSITION_DURATION * 2); + }); + expect(container.querySelector('[data-testid="article"]')).not.toBeNull(); + + document.body.removeChild(container); + jest.useRealTimers(); + }); +}); diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.tsx index 2428d0407..abb140493 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.tsx @@ -7,13 +7,14 @@ import { id, type PushedEvent, type Stack, + type StackflowActions, type StepPushedEvent, } from "@stackflow/core"; import type { StackflowReactPlugin } from "@stackflow/react"; import type { ActivityComponentType } from "@stackflow/react/future"; import type { History, Listener } from "history"; import { createBrowserHistory, createMemoryHistory } from "history"; -import { useSyncExternalStore } from "react"; +import { useEffect, useSyncExternalStore } from "react"; import UrlPattern from "url-pattern"; import { ActivityActivationCountsContext } from "./ActivityActivationCountsContext"; import type { ActivityActivationMonitor } from "./ActivityActivationMonitor/ActivityActivationMonitor"; @@ -170,6 +171,46 @@ export function historySyncPlugin< } }; + /** + * The `defaultHistory` setup kickoff (see `dispatchInitialSetupNavigation`) + * needs the core `actions` to dispatch navigation, but it runs inside + * `wrapStack`'s post-commit effect, which is not passed `actions`. So we + * capture them here from `onInit` — the earliest hook that receives + * `actions` — and read them back in that effect. + * + * The ordering is guaranteed: `onInit` runs synchronously via `store.init()` + * during the first client render (before any effect), while the effect that + * reads `coreActions` runs after the first commit. `onInit` is browser-only, + * so on the server neither the capture nor the effect runs. + */ + let coreActions: StackflowActions | null = null; + + /** + * Guards the one-time initial kickoff so React StrictMode's double-invoked + * effect does not advance the setup process twice. + */ + let hasDispatchedInitialSetupNavigation = false; + + /** + * Advances the `defaultHistory` setup process by one step: dispatches the + * next pending navigation (if any) and refreshes the activation monitors. + * Used for the initial kickoff (from `wrapStack`'s post-commit effect) and + * for every subsequent step (from `onChanged`). + */ + const dispatchInitialSetupNavigation = (actions: StackflowActions) => { + const stack = actions.getStack(); + + initialSetupProcess + ?.captureNavigationOpportunity(stack) + .forEach((event) => + event.name === "Pushed" + ? actions.push(event) + : actions.stepPush(event), + ); + + runActivityActivationMonitors(stack); + }; + return { key: "plugin-history-sync", wrapStack({ stack }) { @@ -179,6 +220,23 @@ export function historySyncPlugin< getActivityActivationCounts, ); + /** + * Kick off the `defaultHistory` setup navigation in a post-commit + * effect instead of synchronously in `onInit`. This keeps the first + * client render identical to the server-rendered output (frame 0), + * eliminating the hydration mismatch, while the staged "stacking" setup + * animation still plays — just after the first paint. (`coreActions` is + * captured in `onInit`, which always runs before this effect.) + */ + useEffect(() => { + if (hasDispatchedInitialSetupNavigation || !coreActions) { + return; + } + + hasDispatchedInitialSetupNavigation = true; + dispatchInitialSetupNavigation(coreActions); + }, []); + return ( @@ -384,7 +442,12 @@ export function historySyncPlugin< }; }); }, - onInit({ actions: { getStack, dispatchEvent, push, stepPush } }) { + onInit({ actions }) { + // Capture core actions for the post-commit `wrapStack` effect that + // 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) { @@ -563,14 +626,6 @@ export function historySyncPlugin< }; history.listen(onPopState); - - initialSetupProcess - ?.captureNavigationOpportunity(stack) - .forEach((event) => - event.name === "Pushed" ? push(event) : stepPush(event), - ); - - runActivityActivationMonitors(stack); }, onPushed({ effect: { activity } }) { if (pushFlag) { @@ -781,16 +836,8 @@ export function historySyncPlugin< } } }, - onChanged({ actions: { getStack, push, stepPush } }) { - const stack = getStack(); - - initialSetupProcess - ?.captureNavigationOpportunity(stack) - .forEach((event) => - event.name === "Pushed" ? push(event) : stepPush(event), - ); - - runActivityActivationMonitors(stack); + onChanged({ actions }) { + dispatchInitialSetupNavigation(actions); }, }; }; diff --git a/yarn.lock b/yarn.lock index 7ed3caca3..96d1d9e16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5877,12 +5877,16 @@ __metadata: "@stackflow/config": "npm:^1.2.1" "@stackflow/core": "npm:^1.3.0" "@stackflow/esbuild-config": "npm:^1.0.3" + "@stackflow/plugin-renderer-basic": "npm:^1.1.13" "@stackflow/react": "npm:^1.7.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" "@types/react-relay": "npm:^16.0.6" "@types/relay-runtime": "npm:^17" esbuild: "npm:^0.23.0" @@ -5890,7 +5894,9 @@ __metadata: graphql: "npm:^16.9.0" 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" react-relay: "npm:^17.0.0" react18-use: "npm:^0.4.1" relay-compiler: "npm:^17.0.0" From c1e8780867e4ea773699429529a6e89025923ce5 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 11:39:41 +0900 Subject: [PATCH 02/15] test(plugin-history-sync): harden defaultHistory SSR/setup test definitions Review-driven fixes for the defaultHistory hydration test suite: - spec.ts (T-O-5): drive the render-only setup kickoff via kickOffDefaultHistorySetup and assert the "Article" destination lands before history.back(), so the destination replay can no longer silently regress now that init() no longer pushes it. - ssr.spec.tsx: delete global.window in test 3 so it faithfully simulates a non-browser server runtime; split the empty-defaultHistory coverage into explicit "empty" (() => []) and "none" route cases; wrap the hydration assertions in try/finally so fake timers and the container are always cleaned up on failure. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/historySyncPlugin.spec.ts | 12 +- .../src/historySyncPlugin.ssr.spec.tsx | 115 ++++++++++++------ 2 files changed, 88 insertions(+), 39 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts index be7bd8e50..1953f1f84 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts +++ b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts @@ -3509,11 +3509,21 @@ describe("historySyncPlugin", () => { ], }); const a = makeActionsProxy({ actions: coreStore.actions }); + + // The destination push is render-driven (the plugin's `wrapStack` effect); + // trigger it explicitly since this is a core-level test. + kickOffDefaultHistorySetup(coreStore); + // Allow defaultHistory replay (Home ancestor → Article target) to settle // through onChanged → push/stepPush. await a.getStack(); await a.getStack(); - await a.getStack(); + const stack = await a.getStack(); + + // The Article target must actually land before we exercise history.back(); + // otherwise back() is a no-op at index 0 and this test silently stops + // exercising the ancestor-URL replay it claims to cover. + expect(activeActivity(stack)?.name).toEqual("Article"); // Walk back to the Home ancestor entry to inspect its URL. history.back(); diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx index 8d6724dcb..af8c137ee 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx @@ -51,19 +51,33 @@ const liveActivityNames = (stack: Stack) => * Core-level wiring (no React), mirroring how the integration builds the store: * apply the plugin's `overrideInitialEvents` via `makeCoreStore`, then `init()`. */ -function buildCoreStore(options: { withDefaultHistory: boolean }): CoreStore { +function buildCoreStore(options: { + defaultHistory: "non-empty" | "empty" | "none"; +}): CoreStore { + const articleRoute = (() => { + switch (options.defaultHistory) { + case "non-empty": + return { + path: "/articles/:articleId", + defaultHistory: () => [ + { activityName: "Home", activityParams: {} }, + ], + }; + case "empty": + return { + path: "/articles/:articleId", + defaultHistory: () => [], + }; + case "none": + return "/articles/:articleId"; + } + })(); + const plugin = historySyncPlugin({ history: createMemoryHistory({ initialEntries: ["/articles/1"] }), routes: { Home: "/", - Article: options.withDefaultHistory - ? { - path: "/articles/:articleId", - defaultHistory: () => [ - { activityName: "Home", activityParams: {} }, - ], - } - : "/articles/:articleId", + Article: articleRoute, }, fallbackActivity: () => "Home", }); @@ -130,7 +144,7 @@ function makeApp() { describe("historySyncPlugin - SSR hydration with defaultHistory", () => { test("store.init() no longer advances a non-empty defaultHistory setup (the server renders frame 0)", () => { - const coreStore = buildCoreStore({ withDefaultHistory: true }); + const coreStore = buildCoreStore({ defaultHistory: "non-empty" }); // The constructor releases only the first (underlay) entry. The server, // which never calls init(), renders exactly this frame. @@ -142,9 +156,20 @@ describe("historySyncPlugin - SSR hydration with defaultHistory", () => { expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Home"]); }); - test("an empty defaultHistory still resolves directly to the destination (unchanged)", () => { - const coreStore = buildCoreStore({ withDefaultHistory: false }); + test("a route with no defaultHistory still resolves directly to the destination (unchanged)", () => { + const coreStore = buildCoreStore({ defaultHistory: "none" }); + + expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Article"]); + coreStore.init(); + expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Article"]); + }); + + test("an explicit empty defaultHistory resolves directly to the destination (unchanged)", () => { + const coreStore = buildCoreStore({ defaultHistory: "empty" }); + // `defaultHistory: () => []` and a missing `defaultHistory` both yield no + // ancestor entries, so the destination lands immediately with no staged + // setup to defer — there is nothing for the post-commit effect to advance. expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Article"]); coreStore.init(); expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Article"]); @@ -153,9 +178,18 @@ describe("historySyncPlugin - SSR hydration with defaultHistory", () => { test("the destination is not rendered during SSR (it is deferred to a post-commit effect)", () => { const app = makeApp(); - const html = renderToString( - , - ); + // Simulate a real non-browser runtime by removing `window`, exactly as on a + // server. Otherwise jsdom's `window` lets browser-only paths run and this + // would no longer reflect SSR fidelity. + const originalWindow = global.window; + let html = ""; + try { + // biome-ignore lint/performance/noDelete: simulate a non-browser runtime + delete (global as { window?: unknown }).window; + html = renderToString(); + } finally { + (global as { window?: unknown }).window = originalWindow; + } expect(html).toContain('data-testid="home"'); expect(html).not.toContain('data-testid="article"'); @@ -187,32 +221,37 @@ describe("historySyncPlugin - SSR hydration with defaultHistory", () => { container.innerHTML = serverHTML; document.body.appendChild(container); - const recoverableErrors: unknown[] = []; - const clientApp = makeApp(); + // `finally` guarantees DOM + timer cleanup even if an assertion throws, + // so a failure here cannot leak fake timers or a stray container into + // later tests. + try { + const recoverableErrors: unknown[] = []; + const clientApp = makeApp(); - await act(async () => { - hydrateRoot( - container, - , - { - onRecoverableError: (error: unknown) => { - recoverableErrors.push(error); + await act(async () => { + hydrateRoot( + container, + , + { + onRecoverableError: (error: unknown) => { + recoverableErrors.push(error); + }, }, - }, - ); - }); - - // The client's first render equalled the server's frame 0 → no mismatch. - expect(recoverableErrors).toEqual([]); + ); + }); - // The staged setup navigation ran in the post-commit effect, so the - // destination mounts after hydration — the stacking animation plays. - await act(async () => { - jest.advanceTimersByTime(TRANSITION_DURATION * 2); - }); - expect(container.querySelector('[data-testid="article"]')).not.toBeNull(); + // The client's first render equalled the server's frame 0 → no mismatch. + expect(recoverableErrors).toEqual([]); - document.body.removeChild(container); - jest.useRealTimers(); + // The staged setup navigation ran in the post-commit effect, so the + // destination mounts after hydration — the stacking animation plays. + await act(async () => { + jest.advanceTimersByTime(TRANSITION_DURATION * 2); + }); + expect(container.querySelector('[data-testid="article"]')).not.toBeNull(); + } finally { + document.body.removeChild(container); + jest.useRealTimers(); + } }); }); From 13b9b450ea94ba72032d12c587d7d95d49f24219 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 12:07:19 +0900 Subject: [PATCH 03/15] test(plugin-history-sync): simplify SSR regression comment --- .../src/historySyncPlugin.ssr.spec.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx index af8c137ee..1cb42f336 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx @@ -15,14 +15,11 @@ import { renderToString } from "react-dom/server"; import { historySyncPlugin } from "./historySyncPlugin"; /** - * Regression tests for the SSR hydration mismatch that occurred when an - * activity declared a non-empty `defaultHistory`. + * Regression tests for SSR hydration mismatch with non-empty `defaultHistory`. * - * The server renders the "frame 0" stack (the first `defaultHistory` entry), - * because the staged setup navigation is now kicked off from a post-commit - * effect that never runs during SSR. The client's first render must therefore - * match that frame, and the staged "stacking" setup animation must still play - * after hydration. + * Server HTML and the client's first render must both contain only the initial + * `defaultHistory` frame, avoiding hydration mismatch. After hydration, the + * destination activity should still appear. */ declare module "@stackflow/config" { From 6269389bef2d5b74366a46261b7aae1d290eb434 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 12:30:13 +0900 Subject: [PATCH 04/15] test(plugin-history-sync): use deterministic SSR event dates --- .../src/historySyncPlugin.ssr.spec.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx index 1cb42f336..f30bb0f89 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx @@ -33,12 +33,6 @@ const TRANSITION_DURATION = 32; const SSR_INITIAL_CONTEXT = { req: { path: "/articles/1" } }; -let eventDateOffset = 0; -const pastEventDate = () => { - eventDateOffset += 1; - return new Date(Date.now() - 60 * 1000).getTime() + eventDateOffset; -}; - const liveActivityNames = (stack: Stack) => stack.activities .filter((activity) => activity.transitionState !== "exit-done") @@ -83,15 +77,15 @@ function buildCoreStore(options: { initialEvents: [ makeEvent("Initialized", { transitionDuration: TRANSITION_DURATION, - eventDate: pastEventDate(), + eventDate: 1, }), makeEvent("ActivityRegistered", { activityName: "Home", - eventDate: pastEventDate(), + eventDate: 2, }), makeEvent("ActivityRegistered", { activityName: "Article", - eventDate: pastEventDate(), + eventDate: 3, }), ], initialContext: SSR_INITIAL_CONTEXT, From e97c2ad5c16d5332b016c23cc148911d9c161b0d Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 13:19:23 +0900 Subject: [PATCH 05/15] test(plugin-history-sync): use react stackflow in SSR spec --- .../src/historySyncPlugin.ssr.spec.tsx | 188 +++++++----------- 1 file changed, 76 insertions(+), 112 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx index f30bb0f89..3dc616023 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx @@ -1,13 +1,8 @@ /** @jest-environment jsdom */ import { defineConfig } from "@stackflow/config"; -import { - type CoreStore, - makeCoreStore, - makeEvent, - type Stack, -} from "@stackflow/core"; +import type { Stack } from "@stackflow/core"; import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; -import { stackflow } from "@stackflow/react"; +import { stackflow, type StackflowReactPlugin } from "@stackflow/react"; import { act } from "@testing-library/react"; import { createMemoryHistory } from "history"; import { hydrateRoot } from "react-dom/client"; @@ -38,20 +33,39 @@ const liveActivityNames = (stack: Stack) => .filter((activity) => activity.transitionState !== "exit-done") .map((activity) => activity.name); -/** - * Core-level wiring (no React), mirroring how the integration builds the store: - * apply the plugin's `overrideInitialEvents` via `makeCoreStore`, then `init()`. - */ -function buildCoreStore(options: { - defaultHistory: "non-empty" | "empty" | "none"; -}): CoreStore { +type DefaultHistoryOption = "non-empty" | "empty" | "none"; + +function Home() { + return
home
; +} +function Article() { + return
article
; +} + +function stackProbePlugin(onStack: (stack: Stack) => void): StackflowReactPlugin { + return () => ({ + key: "stack-probe", + wrapStack({ stack }) { + onStack(stack); + return <>{stack.render()}; + }, + }); +} + +function makeApp({ + defaultHistory = "non-empty", + extraPlugins = [], +}: { + defaultHistory?: DefaultHistoryOption; + extraPlugins?: StackflowReactPlugin[]; +} = {}) { const articleRoute = (() => { - switch (options.defaultHistory) { + switch (defaultHistory) { case "non-empty": return { path: "/articles/:articleId", defaultHistory: () => [ - { activityName: "Home", activityParams: {} }, + { activityName: "Home" as const, activityParams: {} }, ], }; case "empty": @@ -64,57 +78,13 @@ function buildCoreStore(options: { } })(); - const plugin = historySyncPlugin({ - history: createMemoryHistory({ initialEntries: ["/articles/1"] }), - routes: { - Home: "/", - Article: articleRoute, - }, - fallbackActivity: () => "Home", - }); - - return makeCoreStore({ - initialEvents: [ - makeEvent("Initialized", { - transitionDuration: TRANSITION_DURATION, - eventDate: 1, - }), - makeEvent("ActivityRegistered", { - activityName: "Home", - eventDate: 2, - }), - makeEvent("ActivityRegistered", { - activityName: "Article", - eventDate: 3, - }), - ], - initialContext: SSR_INITIAL_CONTEXT, - plugins: [plugin], - }); -} - -function Home() { - return
home
; -} -function Article() { - return
article
; -} - -/** - * React-level wiring (v2 config API). The destination (`Article`) declares a - * non-empty `defaultHistory` so the staged setup process is exercised. - */ -function makeApp() { const config = defineConfig({ transitionDuration: TRANSITION_DURATION, activities: [ { name: "Home", route: "/" }, { name: "Article", - route: { - path: "/articles/:articleId", - defaultHistory: () => [{ activityName: "Home", activityParams: {} }], - }, + route: articleRoute, }, ], }); @@ -129,58 +99,64 @@ function makeApp() { history: createMemoryHistory({ initialEntries: ["/articles/1"] }), fallbackActivity: () => "Home", }), + ...extraPlugins, ], }); } -describe("historySyncPlugin - SSR hydration with defaultHistory", () => { - test("store.init() no longer advances a non-empty defaultHistory setup (the server renders frame 0)", () => { - const coreStore = buildCoreStore({ defaultHistory: "non-empty" }); - - // The constructor releases only the first (underlay) entry. The server, - // which never calls init(), renders exactly this frame. - expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Home"]); - - // init() must NOT push the destination anymore — that now happens in a - // post-commit effect — so the client's first render matches the server. - coreStore.init(); - expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Home"]); - }); +function renderServerHTML(app: ReturnType) { + const originalWindow = global.window; + try { + // biome-ignore lint/performance/noDelete: simulate a non-browser runtime + delete (global as { window?: unknown }).window; + return renderToString(); + } finally { + (global as { window?: unknown }).window = originalWindow; + } +} +describe("historySyncPlugin - SSR hydration with defaultHistory", () => { test("a route with no defaultHistory still resolves directly to the destination (unchanged)", () => { - const coreStore = buildCoreStore({ defaultHistory: "none" }); - - expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Article"]); - coreStore.init(); - expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Article"]); + let capturedStack: Stack | undefined; + const app = makeApp({ + defaultHistory: "none", + extraPlugins: [ + stackProbePlugin((stack) => { + capturedStack = stack; + }), + ], + }); + const html = renderServerHTML(app); + + expect(liveActivityNames(capturedStack!)).toEqual(["Article"]); + expect(html).toContain('data-testid="article"'); + expect(html).not.toContain('data-testid="home"'); }); test("an explicit empty defaultHistory resolves directly to the destination (unchanged)", () => { - const coreStore = buildCoreStore({ defaultHistory: "empty" }); + let capturedStack: Stack | undefined; + const app = makeApp({ + defaultHistory: "empty", + extraPlugins: [ + stackProbePlugin((stack) => { + capturedStack = stack; + }), + ], + }); + const html = renderServerHTML(app); // `defaultHistory: () => []` and a missing `defaultHistory` both yield no // ancestor entries, so the destination lands immediately with no staged // setup to defer — there is nothing for the post-commit effect to advance. - expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Article"]); - coreStore.init(); - expect(liveActivityNames(coreStore.actions.getStack())).toEqual(["Article"]); + expect(liveActivityNames(capturedStack!)).toEqual(["Article"]); + expect(html).toContain('data-testid="article"'); + expect(html).not.toContain('data-testid="home"'); }); - test("the destination is not rendered during SSR (it is deferred to a post-commit effect)", () => { + test("the destination is not rendered during SSR", () => { const app = makeApp(); - // Simulate a real non-browser runtime by removing `window`, exactly as on a - // server. Otherwise jsdom's `window` lets browser-only paths run and this - // would no longer reflect SSR fidelity. - const originalWindow = global.window; - let html = ""; - try { - // biome-ignore lint/performance/noDelete: simulate a non-browser runtime - delete (global as { window?: unknown }).window; - html = renderToString(); - } finally { - (global as { window?: unknown }).window = originalWindow; - } + const html = renderServerHTML(app); expect(html).toContain('data-testid="home"'); expect(html).not.toContain('data-testid="article"'); @@ -189,20 +165,9 @@ describe("historySyncPlugin - SSR hydration with defaultHistory", () => { test("hydration produces no mismatch and the staged setup animation plays afterwards", async () => { jest.useFakeTimers(); - // --- Server render: simulate a non-browser runtime so `store.init()` is - // skipped, exactly as it is on a real server. The server commits frame 0. - const serverApp = makeApp(); - const originalWindow = global.window; - let serverHTML = ""; - try { - // biome-ignore lint/performance/noDelete: simulate a non-browser runtime - delete (global as { window?: unknown }).window; - serverHTML = renderToString( - , - ); - } finally { - (global as { window?: unknown }).window = originalWindow; - } + // --- Server render: simulate a non-browser runtime. The server commits + // the same initial frame that the client must hydrate from. + const serverHTML = renderServerHTML(makeApp()); expect(serverHTML).toContain('data-testid="home"'); expect(serverHTML).not.toContain('data-testid="article"'); @@ -234,8 +199,7 @@ describe("historySyncPlugin - SSR hydration with defaultHistory", () => { // The client's first render equalled the server's frame 0 → no mismatch. expect(recoverableErrors).toEqual([]); - // The staged setup navigation ran in the post-commit effect, so the - // destination mounts after hydration — the stacking animation plays. + // The destination still mounts after hydration. await act(async () => { jest.advanceTimersByTime(TRANSITION_DURATION * 2); }); From 1b4964e6dc2280783f145ef995124f0b6060ff0e Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 14:43:09 +0900 Subject: [PATCH 06/15] test(plugin-history-sync): avoid render-side stack probe --- .../src/historySyncPlugin.ssr.spec.tsx | 35 +------------------ 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx index 3dc616023..79520ca45 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx @@ -1,8 +1,7 @@ /** @jest-environment jsdom */ import { defineConfig } from "@stackflow/config"; -import type { Stack } from "@stackflow/core"; import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; -import { stackflow, type StackflowReactPlugin } from "@stackflow/react"; +import { stackflow } from "@stackflow/react"; import { act } from "@testing-library/react"; import { createMemoryHistory } from "history"; import { hydrateRoot } from "react-dom/client"; @@ -28,11 +27,6 @@ const TRANSITION_DURATION = 32; const SSR_INITIAL_CONTEXT = { req: { path: "/articles/1" } }; -const liveActivityNames = (stack: Stack) => - stack.activities - .filter((activity) => activity.transitionState !== "exit-done") - .map((activity) => activity.name); - type DefaultHistoryOption = "non-empty" | "empty" | "none"; function Home() { @@ -42,22 +36,10 @@ function Article() { return
article
; } -function stackProbePlugin(onStack: (stack: Stack) => void): StackflowReactPlugin { - return () => ({ - key: "stack-probe", - wrapStack({ stack }) { - onStack(stack); - return <>{stack.render()}; - }, - }); -} - function makeApp({ defaultHistory = "non-empty", - extraPlugins = [], }: { defaultHistory?: DefaultHistoryOption; - extraPlugins?: StackflowReactPlugin[]; } = {}) { const articleRoute = (() => { switch (defaultHistory) { @@ -99,7 +81,6 @@ function makeApp({ history: createMemoryHistory({ initialEntries: ["/articles/1"] }), fallbackActivity: () => "Home", }), - ...extraPlugins, ], }); } @@ -117,38 +98,24 @@ function renderServerHTML(app: ReturnType) { describe("historySyncPlugin - SSR hydration with defaultHistory", () => { test("a route with no defaultHistory still resolves directly to the destination (unchanged)", () => { - let capturedStack: Stack | undefined; const app = makeApp({ defaultHistory: "none", - extraPlugins: [ - stackProbePlugin((stack) => { - capturedStack = stack; - }), - ], }); const html = renderServerHTML(app); - expect(liveActivityNames(capturedStack!)).toEqual(["Article"]); expect(html).toContain('data-testid="article"'); expect(html).not.toContain('data-testid="home"'); }); test("an explicit empty defaultHistory resolves directly to the destination (unchanged)", () => { - let capturedStack: Stack | undefined; const app = makeApp({ defaultHistory: "empty", - extraPlugins: [ - stackProbePlugin((stack) => { - capturedStack = stack; - }), - ], }); const html = renderServerHTML(app); // `defaultHistory: () => []` and a missing `defaultHistory` both yield no // ancestor entries, so the destination lands immediately with no staged // setup to defer — there is nothing for the post-commit effect to advance. - expect(liveActivityNames(capturedStack!)).toEqual(["Article"]); expect(html).toContain('data-testid="article"'); expect(html).not.toContain('data-testid="home"'); }); From 0cfea5567404f58b063dd62e8e329391c467073e Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 15:46:13 +0900 Subject: [PATCH 07/15] test(plugin-history-sync): render defaultHistory setup tests --- ...c.tsx => historySyncPlugin.react.spec.tsx} | 169 +++++++++++++++++- .../src/historySyncPlugin.spec.ts | 148 --------------- 2 files changed, 165 insertions(+), 152 deletions(-) rename extensions/plugin-history-sync/src/{historySyncPlugin.ssr.spec.tsx => historySyncPlugin.react.spec.tsx} (53%) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx similarity index 53% rename from extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx rename to extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx index 79520ca45..29512ac63 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx @@ -1,12 +1,14 @@ /** @jest-environment jsdom */ import { defineConfig } from "@stackflow/config"; import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; -import { stackflow } from "@stackflow/react"; -import { act } from "@testing-library/react"; +import { stackflow, useActivity } from "@stackflow/react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import type { Location, MemoryHistory } from "history"; import { createMemoryHistory } from "history"; import { hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { historySyncPlugin } from "./historySyncPlugin"; +import type { RouteLike } from "./RouteLike"; /** * Regression tests for SSR hydration mismatch with non-empty `defaultHistory`. @@ -27,6 +29,9 @@ const TRANSITION_DURATION = 32; const SSR_INITIAL_CONTEXT = { req: { path: "/articles/1" } }; +const path = (location: Location) => + location.pathname + location.search + location.hash; + type DefaultHistoryOption = "non-empty" | "empty" | "none"; function Home() { @@ -36,6 +41,32 @@ function Article() { return
article
; } +function HomeActivity() { + const activity = useActivity(); + + return ( +
+ ); +} + +function ArticleActivity() { + const activity = useActivity(); + + return ( +
+ ); +} + function makeApp({ defaultHistory = "non-empty", }: { @@ -88,7 +119,6 @@ function makeApp({ function renderServerHTML(app: ReturnType) { const originalWindow = global.window; try { - // biome-ignore lint/performance/noDelete: simulate a non-browser runtime delete (global as { window?: unknown }).window; return renderToString(); } finally { @@ -96,6 +126,43 @@ function renderServerHTML(app: ReturnType) { } } +const renderHistorySyncStack = ({ + history, + routes, +}: { + history: MemoryHistory; + routes: { + Home: RouteLike; + Article: RouteLike; + }; +}) => { + const config = defineConfig({ + transitionDuration: TRANSITION_DURATION, + activities: [ + { name: "Home", route: routes.Home }, + { name: "Article", route: routes.Article }, + ], + }); + + const app = stackflow({ + config, + components: { + Home: HomeActivity, + Article: ArticleActivity, + }, + plugins: [ + basicRendererPlugin(), + historySyncPlugin({ + config, + history, + fallbackActivity: () => "Home", + }), + ], + }); + + render(); +}; + describe("historySyncPlugin - SSR hydration with defaultHistory", () => { test("a route with no defaultHistory still resolves directly to the destination (unchanged)", () => { const app = makeApp({ @@ -115,7 +182,7 @@ describe("historySyncPlugin - SSR hydration with defaultHistory", () => { // `defaultHistory: () => []` and a missing `defaultHistory` both yield no // ancestor entries, so the destination lands immediately with no staged - // setup to defer — there is nothing for the post-commit effect to advance. + // setup to replay. expect(html).toContain('data-testid="article"'); expect(html).not.toContain('data-testid="home"'); }); @@ -177,3 +244,97 @@ describe("historySyncPlugin - SSR hydration with defaultHistory", () => { } }); }); + +describe("historySyncPlugin - defaultHistory setup through React rendering", () => { + test("historySyncPlugin - FEP-1061: defaultHistory ancestor entries with typed activityParams + stepParams coerce (T-I-NEW-6)", async () => { + // T-I-NEW-6: `historyEntryToEvents` is invoked for `defaultHistory` + // ancestor entries. Exercise it through the real React `` path + // because the destination only lands after the rendered setup flow runs. + const history = createMemoryHistory({ + initialEntries: ["/articles/9/"], + }); + + renderHistorySyncStack({ + history, + routes: { + Home: "/home/", + Article: { + path: "/articles/:articleId", + defaultHistory: () => [ + { + activityName: "Home", + activityParams: { + count: 42 as unknown as string, + }, + additionalSteps: [ + { + stepParams: { + offset: 7 as unknown as string, + }, + }, + ], + }, + ], + }, + }, + }); + + await waitFor(() => { + expect(screen.getByTestId("article").dataset.active).toEqual("true"); + expect(screen.getByTestId("article").dataset.articleId).toEqual("9"); + }); + + const home = screen.getByTestId("home"); + expect(home.dataset.count).toEqual("42"); + expect(home.dataset.offset).toEqual("7"); + }); + + test("historySyncPlugin - FEP-1061: T-O-5 defaultHistory ancestor URL uses ancestor's route encode (not currentPath)", async () => { + // Arrive on Article URL with a typed defaultHistory chain. The ancestor + // URL pushed during setup must use Home's route encode, not the current + // Article path. + const history = createMemoryHistory({ + initialEntries: ["/articles/9/?visible=true"], + }); + + const homeEncode = jest.fn((p: Record) => ({ + articleId: String(p.articleId ?? ""), + visible: p.visible ? "y" : "n", + })); + + renderHistorySyncStack({ + history, + routes: { + Home: { + path: "/home/", + encode: homeEncode, + }, + Article: { + path: "/articles/:articleId", + defaultHistory: () => [ + { + activityName: "Home", + activityParams: { + visible: true as unknown as string, + }, + }, + ], + }, + }, + }); + + await waitFor(() => { + expect(screen.getByTestId("article").dataset.active).toEqual("true"); + expect(screen.getByTestId("article").dataset.articleId).toEqual("9"); + }); + + await act(async () => { + history.back(); + }); + + await waitFor(() => { + expect(screen.getByTestId("home").dataset.active).toEqual("true"); + expect(path(history.location)).toEqual("/home/?visible=y"); + }); + }); +}); diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts index 1953f1f84..21704c9ef 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts +++ b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts @@ -110,21 +110,6 @@ const stackflow = ({ return coreStore; }; -/** - * The destination push of a `defaultHistory` setup is kicked off by the - * renderer — `historySyncPlugin`'s `wrapStack` post-commit effect — rather than - * by `coreStore.init()`. Core-level tests (which never render) must trigger - * that kickoff explicitly to land the target activity. For routes without a - * `defaultHistory` the setup process is already terminated, so this is a no-op. - */ -const kickOffDefaultHistorySetup = (coreStore: CoreStore) => { - for (const pluginInstance of coreStore.pluginInstances) { - pluginInstance.onChanged?.({ - actions: coreStore.actions, - } as Parameters>[0]); - } -}; - const activeActivity = (stack: Stack) => stack.activities.find((a) => a.isActive); @@ -2980,73 +2965,6 @@ describe("historySyncPlugin", () => { expect(typeof active?.params.offset).toEqual("string"); }); - test("historySyncPlugin - FEP-1061: defaultHistory ancestor entries with typed activityParams + stepParams coerce (T-I-NEW-6)", async () => { - // T-I-NEW-6: `historyEntryToEvents` (historySyncPlugin.tsx:276-309) is - // invoked for `defaultHistory` ancestor entries. Boot via URL-arrival on - // a route whose `defaultHistory` returns an ancestor with TYPED - // `activityParams` and TYPED `stepParams`. Both must be coerced when - // the ancestor events are emitted. - const historyForDefault = createMemoryHistory({ - initialEntries: ["/articles/9/"], - }); - - const coreStore = stackflow({ - activityNames: ["Home", "Article"], - plugins: [ - historySyncPlugin({ - history: historyForDefault, - routes: { - Home: "/home/", - Article: { - path: "/articles/:articleId", - defaultHistory: () => [ - { - activityName: "Home", - // TYPED — should be coerced via historyEntryToEvents. - activityParams: { - count: 42 as unknown as string, - }, - additionalSteps: [ - { - stepParams: { - offset: 7 as unknown as string, - }, - }, - ], - }, - ], - }, - }, - fallbackActivity: () => "Home", - }), - ], - }); - - const proxyActions = makeActionsProxy({ actions: coreStore.actions }); - - // The destination push is render-driven (the plugin's `wrapStack` effect); - // trigger it explicitly since this is a core-level test. - kickOffDefaultHistorySetup(coreStore); - - const stack = await proxyActions.getStack(); - - // The ancestor "Home" activity from defaultHistory. - const homeAncestor = stack.activities.find((a) => a.name === "Home"); - // After additionalSteps processing, `homeAncestor.params` reflects the - // LAST step's params (`makeActivityReducer.ts:78`). The original Pushed - // activityParams land in `steps[0].params`. - expect(homeAncestor?.steps[0]?.params.count).toEqual("42"); - expect(typeof homeAncestor?.steps[0]?.params.count).toEqual("string"); - // The step's stepParams are coerced and surfaced via homeAncestor.params - // (current-step alias) and also in the last step's params. - expect(homeAncestor?.params.offset).toEqual("7"); - expect(typeof homeAncestor?.params.offset).toEqual("string"); - - // The target Article activity — sanity-check it landed. - const article = stack.activities.find((a) => a.name === "Article"); - expect(article?.params.articleId).toEqual("9"); - }); - test("historySyncPlugin - FEP-1061: popstate isStepBackward branch preserves coercion (T-I-NEW-9)", async () => { // T-I-NEW-9: popstate `isStepBackward` (historySyncPlugin.tsx:538-554). // When a back() navigates to a step that's no longer in the stack, the @@ -3468,72 +3386,6 @@ describe("historySyncPlugin", () => { expect(path(history.location)).toEqual("/articles/3/?visible=y"); }); - test("historySyncPlugin - FEP-1061: T-O-5 defaultHistory ancestor URL uses ancestor's route encode (not currentPath)", async () => { - // Arrive on Article URL with a typed-decode chain; the defaultHistory - // declares Home as the ancestor. The ancestor URL pushed in - // historyEntryToEvents should reflect Home's route encode (or its plain - // template), NOT the current Article path. - history = createMemoryHistory({ - initialEntries: ["/articles/9/?visible=true"], - }); - - const homeEncode = jest.fn((p: Record) => ({ - articleId: String(p.articleId ?? ""), - visible: p.visible ? "y" : "n", - })); - - const coreStore = stackflow({ - activityNames: ["Home", "Article"], - plugins: [ - historySyncPlugin({ - history, - routes: { - Home: { - path: "/home/", - encode: homeEncode, - }, - Article: { - path: "/articles/:articleId", - defaultHistory: () => [ - { - activityName: "Home", - activityParams: { - visible: true as unknown as string, - }, - }, - ], - }, - } as any, - fallbackActivity: () => "Home", - }), - ], - }); - const a = makeActionsProxy({ actions: coreStore.actions }); - - // The destination push is render-driven (the plugin's `wrapStack` effect); - // trigger it explicitly since this is a core-level test. - kickOffDefaultHistorySetup(coreStore); - - // Allow defaultHistory replay (Home ancestor → Article target) to settle - // through onChanged → push/stepPush. - await a.getStack(); - await a.getStack(); - const stack = await a.getStack(); - - // The Article target must actually land before we exercise history.back(); - // otherwise back() is a no-op at index 0 and this test silently stops - // exercising the ancestor-URL replay it claims to cover. - expect(activeActivity(stack)?.name).toEqual("Article"); - - // Walk back to the Home ancestor entry to inspect its URL. - history.back(); - await a.getStack(); - - // Ancestor URL pushed during defaultHistory replay must use Home's encode - // output (visible=y), NOT the Article path the user arrived on. - expect(path(history.location)).toEqual("/home/?visible=y"); - }); - test("historySyncPlugin - FEP-1061: T-O-6 popstate forward (activity boundary): encode receives typed-via-context, NOT coerced strings", async () => { history = createMemoryHistory(); From 4f0c061be10a96df2754f6960179cc567b7353e6 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 16:05:29 +0900 Subject: [PATCH 08/15] test(plugin-history-sync): inline history location assertion --- .../src/historySyncPlugin.react.spec.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx index 29512ac63..0bb40b43e 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx @@ -3,7 +3,7 @@ import { defineConfig } from "@stackflow/config"; import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; import { stackflow, useActivity } from "@stackflow/react"; import { act, render, screen, waitFor } from "@testing-library/react"; -import type { Location, MemoryHistory } from "history"; +import type { MemoryHistory } from "history"; import { createMemoryHistory } from "history"; import { hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; @@ -29,9 +29,6 @@ const TRANSITION_DURATION = 32; const SSR_INITIAL_CONTEXT = { req: { path: "/articles/1" } }; -const path = (location: Location) => - location.pathname + location.search + location.hash; - type DefaultHistoryOption = "non-empty" | "empty" | "none"; function Home() { @@ -333,8 +330,10 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () }); await waitFor(() => { + const { pathname, search, hash } = history.location; + expect(screen.getByTestId("home").dataset.active).toEqual("true"); - expect(path(history.location)).toEqual("/home/?visible=y"); + expect(`${pathname}${search}${hash}`).toEqual("/home/?visible=y"); }); }); }); From 345fe280eaf45727bb5102ff7b6d3f4f4459caa2 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 16:10:37 +0900 Subject: [PATCH 09/15] test(plugin-history-sync): reuse makeApp for react setup tests --- .../src/historySyncPlugin.react.spec.tsx | 75 ++++++++----------- 1 file changed, 32 insertions(+), 43 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx index 0bb40b43e..2da99d6f8 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx @@ -66,8 +66,20 @@ function ArticleActivity() { function makeApp({ defaultHistory = "non-empty", + history = createMemoryHistory({ initialEntries: ["/articles/1"] }), + routes, + components = { Home, Article }, }: { defaultHistory?: DefaultHistoryOption; + history?: MemoryHistory; + routes?: { + Home: RouteLike; + Article: RouteLike; + }; + components?: { + Home: typeof Home; + Article: typeof Article; + }; } = {}) { const articleRoute = (() => { switch (defaultHistory) { @@ -87,26 +99,30 @@ function makeApp({ return "/articles/:articleId"; } })(); + const appRoutes = routes ?? { + Home: "/", + Article: articleRoute, + }; const config = defineConfig({ transitionDuration: TRANSITION_DURATION, activities: [ - { name: "Home", route: "/" }, + { name: "Home", route: appRoutes.Home }, { name: "Article", - route: articleRoute, + route: appRoutes.Article, }, ], }); return stackflow({ config, - components: { Home, Article }, + components, plugins: [ basicRendererPlugin(), historySyncPlugin({ config, - history: createMemoryHistory({ initialEntries: ["/articles/1"] }), + history, fallbackActivity: () => "Home", }), ], @@ -123,43 +139,6 @@ function renderServerHTML(app: ReturnType) { } } -const renderHistorySyncStack = ({ - history, - routes, -}: { - history: MemoryHistory; - routes: { - Home: RouteLike; - Article: RouteLike; - }; -}) => { - const config = defineConfig({ - transitionDuration: TRANSITION_DURATION, - activities: [ - { name: "Home", route: routes.Home }, - { name: "Article", route: routes.Article }, - ], - }); - - const app = stackflow({ - config, - components: { - Home: HomeActivity, - Article: ArticleActivity, - }, - plugins: [ - basicRendererPlugin(), - historySyncPlugin({ - config, - history, - fallbackActivity: () => "Home", - }), - ], - }); - - render(); -}; - describe("historySyncPlugin - SSR hydration with defaultHistory", () => { test("a route with no defaultHistory still resolves directly to the destination (unchanged)", () => { const app = makeApp({ @@ -251,7 +230,7 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () initialEntries: ["/articles/9/"], }); - renderHistorySyncStack({ + const app = makeApp({ history, routes: { Home: "/home/", @@ -274,7 +253,12 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () ], }, }, + components: { + Home: HomeActivity, + Article: ArticleActivity, + }, }); + render(); await waitFor(() => { expect(screen.getByTestId("article").dataset.active).toEqual("true"); @@ -299,7 +283,7 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () visible: p.visible ? "y" : "n", })); - renderHistorySyncStack({ + const app = makeApp({ history, routes: { Home: { @@ -318,7 +302,12 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () ], }, }, + components: { + Home: HomeActivity, + Article: ArticleActivity, + }, }); + render(); await waitFor(() => { expect(screen.getByTestId("article").dataset.active).toEqual("true"); From 7b5316686af76075a0e0110c095371fcc1339f20 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 16:12:13 +0900 Subject: [PATCH 10/15] test(plugin-history-sync): remove defaultHistory setup comment --- .../plugin-history-sync/src/historySyncPlugin.react.spec.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx index 2da99d6f8..397fb3876 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx @@ -223,9 +223,6 @@ describe("historySyncPlugin - SSR hydration with defaultHistory", () => { describe("historySyncPlugin - defaultHistory setup through React rendering", () => { test("historySyncPlugin - FEP-1061: defaultHistory ancestor entries with typed activityParams + stepParams coerce (T-I-NEW-6)", async () => { - // T-I-NEW-6: `historyEntryToEvents` is invoked for `defaultHistory` - // ancestor entries. Exercise it through the real React `` path - // because the destination only lands after the rendered setup flow runs. const history = createMemoryHistory({ initialEntries: ["/articles/9/"], }); From b9e00a02bcfd2e05e9dad620f9f6ab4c6c768195 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 16:18:06 +0900 Subject: [PATCH 11/15] test(plugin-history-sync): inspect activity snapshots --- .../src/historySyncPlugin.react.spec.tsx | 221 +++++++++++------- 1 file changed, 132 insertions(+), 89 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx index 397fb3876..0a5a26ef1 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx @@ -1,14 +1,14 @@ /** @jest-environment jsdom */ import { defineConfig } from "@stackflow/config"; import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; +import type { ActivityComponentType } from "@stackflow/react"; import { stackflow, useActivity } from "@stackflow/react"; -import { act, render, screen, waitFor } from "@testing-library/react"; -import type { MemoryHistory } from "history"; +import { act, render, waitFor } from "@testing-library/react"; import { createMemoryHistory } from "history"; +import { useEffect } from "react"; import { hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; import { historySyncPlugin } from "./historySyncPlugin"; -import type { RouteLike } from "./RouteLike"; /** * Regression tests for SSR hydration mismatch with non-empty `defaultHistory`. @@ -38,48 +38,40 @@ function Article() { return
article
; } -function HomeActivity() { +type ActivitySnapshot = ReturnType; + +let recordHomeActivity: ((activity: ActivitySnapshot) => void) | undefined; +let recordArticleActivity: ((activity: ActivitySnapshot) => void) | undefined; + +afterEach(() => { + recordHomeActivity = undefined; + recordArticleActivity = undefined; +}); + +const HomeActivity: ActivityComponentType<"Home"> = () => { const activity = useActivity(); - return ( -
- ); -} + useEffect(() => { + recordHomeActivity?.(activity); + }, [activity]); -function ArticleActivity() { + return null; +}; + +const ArticleActivity: ActivityComponentType<"Article"> = () => { const activity = useActivity(); - return ( -
- ); -} + useEffect(() => { + recordArticleActivity?.(activity); + }, [activity]); + + return null; +}; function makeApp({ defaultHistory = "non-empty", - history = createMemoryHistory({ initialEntries: ["/articles/1"] }), - routes, - components = { Home, Article }, }: { defaultHistory?: DefaultHistoryOption; - history?: MemoryHistory; - routes?: { - Home: RouteLike; - Article: RouteLike; - }; - components?: { - Home: typeof Home; - Article: typeof Article; - }; } = {}) { const articleRoute = (() => { switch (defaultHistory) { @@ -99,30 +91,26 @@ function makeApp({ return "/articles/:articleId"; } })(); - const appRoutes = routes ?? { - Home: "/", - Article: articleRoute, - }; const config = defineConfig({ transitionDuration: TRANSITION_DURATION, activities: [ - { name: "Home", route: appRoutes.Home }, + { name: "Home", route: "/" }, { name: "Article", - route: appRoutes.Article, + route: articleRoute, }, ], }); return stackflow({ config, - components, + components: { Home, Article }, plugins: [ basicRendererPlugin(), historySyncPlugin({ config, - history, + history: createMemoryHistory({ initialEntries: ["/articles/1"] }), fallbackActivity: () => "Home", }), ], @@ -226,45 +214,71 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () const history = createMemoryHistory({ initialEntries: ["/articles/9/"], }); - - const app = makeApp({ - history, - routes: { - Home: "/home/", - Article: { - path: "/articles/:articleId", - defaultHistory: () => [ - { - activityName: "Home", - activityParams: { - count: 42 as unknown as string, - }, - additionalSteps: [ - { - stepParams: { - offset: 7 as unknown as string, - }, + const homeActivities: ActivitySnapshot[] = []; + const articleActivities: ActivitySnapshot[] = []; + recordHomeActivity = (activity) => { + homeActivities.push(activity); + }; + recordArticleActivity = (activity) => { + articleActivities.push(activity); + }; + + const config = defineConfig({ + transitionDuration: TRANSITION_DURATION, + activities: [ + { name: "Home", route: "/home/" }, + { + name: "Article", + route: { + path: "/articles/:articleId", + defaultHistory: () => [ + { + activityName: "Home", + activityParams: { + count: 42 as unknown as string, }, - ], - }, - ], + additionalSteps: [ + { + stepParams: { + offset: 7 as unknown as string, + }, + }, + ], + }, + ], + }, }, - }, + ], + }); + + const app = stackflow({ + config, components: { Home: HomeActivity, Article: ArticleActivity, }, + plugins: [ + basicRendererPlugin(), + historySyncPlugin({ + config, + history, + fallbackActivity: () => "Home", + }), + ], }); render(); await waitFor(() => { - expect(screen.getByTestId("article").dataset.active).toEqual("true"); - expect(screen.getByTestId("article").dataset.articleId).toEqual("9"); + const article = articleActivities.find((activity) => activity.isActive); + + expect(article?.params.articleId).toEqual("9"); }); - const home = screen.getByTestId("home"); - expect(home.dataset.count).toEqual("42"); - expect(home.dataset.offset).toEqual("7"); + const home = homeActivities.at(-1); + expect(home?.steps[0]?.params.count).toEqual("42"); + expect(typeof home?.steps[0]?.params.count).toEqual("string"); + expect(home?.params.offset).toEqual("7"); + expect(typeof home?.params.offset).toEqual("string"); }); test("historySyncPlugin - FEP-1061: T-O-5 defaultHistory ancestor URL uses ancestor's route encode (not currentPath)", async () => { @@ -274,41 +288,68 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () const history = createMemoryHistory({ initialEntries: ["/articles/9/?visible=true"], }); + const homeActivities: ActivitySnapshot[] = []; + const articleActivities: ActivitySnapshot[] = []; + recordHomeActivity = (activity) => { + homeActivities.push(activity); + }; + recordArticleActivity = (activity) => { + articleActivities.push(activity); + }; const homeEncode = jest.fn((p: Record) => ({ articleId: String(p.articleId ?? ""), visible: p.visible ? "y" : "n", })); - const app = makeApp({ - history, - routes: { - Home: { - path: "/home/", - encode: homeEncode, + const config = defineConfig({ + transitionDuration: TRANSITION_DURATION, + activities: [ + { + name: "Home", + route: { + path: "/home/", + encode: homeEncode, + }, }, - Article: { - path: "/articles/:articleId", - defaultHistory: () => [ - { - activityName: "Home", - activityParams: { - visible: true as unknown as string, + { + name: "Article", + route: { + path: "/articles/:articleId", + defaultHistory: () => [ + { + activityName: "Home", + activityParams: { + visible: true as unknown as string, + }, }, - }, - ], + ], + }, }, - }, + ], + }); + + const app = stackflow({ + config, components: { Home: HomeActivity, Article: ArticleActivity, }, + plugins: [ + basicRendererPlugin(), + historySyncPlugin({ + config, + history, + fallbackActivity: () => "Home", + }), + ], }); render(); await waitFor(() => { - expect(screen.getByTestId("article").dataset.active).toEqual("true"); - expect(screen.getByTestId("article").dataset.articleId).toEqual("9"); + const article = articleActivities.find((activity) => activity.isActive); + + expect(article?.params.articleId).toEqual("9"); }); await act(async () => { @@ -318,7 +359,9 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () await waitFor(() => { const { pathname, search, hash } = history.location; - expect(screen.getByTestId("home").dataset.active).toEqual("true"); + expect(homeActivities.some((activity) => activity.isActive)).toEqual( + true, + ); expect(`${pathname}${search}${hash}`).toEqual("/home/?visible=y"); }); }); From 90c18e758c71b215100692afe4bbac6e6d541437 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 17:03:51 +0900 Subject: [PATCH 12/15] test(plugin-history-sync): refactor react setup specs --- .../src/historySyncPlugin.react.spec.tsx | 85 ++++++++++--------- 1 file changed, 47 insertions(+), 38 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx index 0a5a26ef1..40971cc6b 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx @@ -117,6 +117,38 @@ function makeApp({ }); } +function renderHistorySyncStack({ + config, + initialEntry, +}: { + config: Parameters[0]["config"]; + initialEntry: string; +}) { + const history = createMemoryHistory({ + initialEntries: [initialEntry], + }); + + const app = stackflow({ + config, + components: { + Home: HomeActivity, + Article: ArticleActivity, + }, + plugins: [ + basicRendererPlugin(), + historySyncPlugin({ + config, + history, + fallbackActivity: () => "Home", + }), + ], + }); + + render(); + + return history; +} + function renderServerHTML(app: ReturnType) { const originalWindow = global.window; try { @@ -211,9 +243,6 @@ describe("historySyncPlugin - SSR hydration with defaultHistory", () => { describe("historySyncPlugin - defaultHistory setup through React rendering", () => { test("historySyncPlugin - FEP-1061: defaultHistory ancestor entries with typed activityParams + stepParams coerce (T-I-NEW-6)", async () => { - const history = createMemoryHistory({ - initialEntries: ["/articles/9/"], - }); const homeActivities: ActivitySnapshot[] = []; const articleActivities: ActivitySnapshot[] = []; recordHomeActivity = (activity) => { @@ -251,22 +280,10 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () ], }); - const app = stackflow({ + renderHistorySyncStack({ config, - components: { - Home: HomeActivity, - Article: ArticleActivity, - }, - plugins: [ - basicRendererPlugin(), - historySyncPlugin({ - config, - history, - fallbackActivity: () => "Home", - }), - ], + initialEntry: "/articles/9/", }); - render(); await waitFor(() => { const article = articleActivities.find((activity) => activity.isActive); @@ -285,9 +302,6 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () // Arrive on Article URL with a typed defaultHistory chain. The ancestor // URL pushed during setup must use Home's route encode, not the current // Article path. - const history = createMemoryHistory({ - initialEntries: ["/articles/9/?visible=true"], - }); const homeActivities: ActivitySnapshot[] = []; const articleActivities: ActivitySnapshot[] = []; recordHomeActivity = (activity) => { @@ -297,10 +311,17 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () articleActivities.push(activity); }; - const homeEncode = jest.fn((p: Record) => ({ - articleId: String(p.articleId ?? ""), - visible: p.visible ? "y" : "n", - })); + const homeEncode = jest.fn((params: unknown) => { + const record = + typeof params === "object" && params !== null + ? (params as Record) + : {}; + + return { + articleId: String(record.articleId ?? ""), + visible: record.visible ? "y" : "n", + }; + }); const config = defineConfig({ transitionDuration: TRANSITION_DURATION, @@ -329,22 +350,10 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () ], }); - const app = stackflow({ + const history = renderHistorySyncStack({ config, - components: { - Home: HomeActivity, - Article: ArticleActivity, - }, - plugins: [ - basicRendererPlugin(), - historySyncPlugin({ - config, - history, - fallbackActivity: () => "Home", - }), - ], + initialEntry: "/articles/9/?visible=true", }); - render(); await waitFor(() => { const article = articleActivities.find((activity) => activity.isActive); From 40d55676cefd2eb7ea1fcbcd377dbb3b360693e5 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 21:20:59 +0900 Subject: [PATCH 13/15] test(plugin-history-sync): drop incidental articleId from T-O-5 home encode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Home route has no articleId, so the encode's `articleId: ""` field was always dropped by makeTemplate's empty-value handling — the `/home/?visible=y` assertion only held by relying on that incidental behavior. Return only `visible` so the assertion isolates the ancestor-encode behavior it targets. Also drop the unused `jest.fn` wrapper (no call was ever asserted). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/historySyncPlugin.react.spec.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx index 40971cc6b..46a3145ac 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx @@ -311,17 +311,16 @@ describe("historySyncPlugin - defaultHistory setup through React rendering", () articleActivities.push(activity); }; - const homeEncode = jest.fn((params: unknown) => { - const record = + const homeEncode = (params: unknown) => { + const visible = typeof params === "object" && params !== null - ? (params as Record) - : {}; + ? (params as { visible?: unknown }).visible + : undefined; return { - articleId: String(record.articleId ?? ""), - visible: record.visible ? "y" : "n", + visible: visible ? "y" : "n", }; - }); + }; const config = defineConfig({ transitionDuration: TRANSITION_DURATION, From 845512fff747ec55c5bf5a56a2241d0dc4057728 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 21:29:02 +0900 Subject: [PATCH 14/15] fix(plugin-history-sync): widen @stackflow/* peer deps to support stackflow v1 Allow installation alongside the stackflow v1 line in addition to v2: core/react accept ^1.0.0 || ^2.0.0, config accepts ^1.1.0 || ^2.0.0 (config was first published at 1.1.0). devDependencies stay on ^2.0.0 since this workspace builds and tests against v2. Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/plugin-history-sync/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/plugin-history-sync/package.json b/extensions/plugin-history-sync/package.json index f4e0f4e25..0206c3156 100644 --- a/extensions/plugin-history-sync/package.json +++ b/extensions/plugin-history-sync/package.json @@ -90,9 +90,9 @@ "typescript": "^5.5.3" }, "peerDependencies": { - "@stackflow/config": "^2.0.0", - "@stackflow/core": "^2.0.0", - "@stackflow/react": "^2.0.0", + "@stackflow/config": "^1.1.0 || ^2.0.0", + "@stackflow/core": "^1.0.0 || ^2.0.0", + "@stackflow/react": "^1.0.0 || ^2.0.0", "@types/react": ">=16.8.0", "react": ">=16.8.0" }, From d95426604efed55c99936cdc24d8bc03f7b3f709 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 1 Jun 2026 21:34:06 +0900 Subject: [PATCH 15/15] chore(plugin-history-sync): sync yarn.lock with widened peer deps Regenerate the lockfile for the `^1.x || ^2.x` peer-dependency ranges so `yarn install --immutable` passes in CI (the package.json change in 845512ff left the lockfile out of date). Co-Authored-By: Claude Opus 4.8 (1M context) --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 62e22a33b..a9e7e77b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5891,9 +5891,9 @@ __metadata: typescript: "npm:^5.5.3" url-pattern: "npm:^1.0.3" peerDependencies: - "@stackflow/config": ^2.0.0 - "@stackflow/core": ^2.0.0 - "@stackflow/react": ^2.0.0 + "@stackflow/config": ^1.1.0 || ^2.0.0 + "@stackflow/core": ^1.0.0 || ^2.0.0 + "@stackflow/react": ^1.0.0 || ^2.0.0 "@types/react": ">=16.8.0" react: ">=16.8.0" languageName: unknown