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 9b7c751a2..6a194ab38 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -6784,7 +6784,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"],\ @@ -6853,12 +6853,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],\ @@ -6869,7 +6873,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"],\ @@ -6898,12 +6904,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"],\ @@ -6911,7 +6921,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"],\ @@ -6941,7 +6953,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"],\ @@ -7406,6 +7418,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", [\ @@ -12905,10 +12939,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"],\ @@ -12918,7 +12952,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",\ @@ -13281,10 +13315,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"],\ @@ -13311,7 +13345,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": [\ @@ -19332,10 +19366,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 64d2e86df..0206c3156 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": "^2.0.0", "@stackflow/core": "^2.0.0", "@stackflow/esbuild-config": "^1.0.3", + "@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", "@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", @@ -70,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" }, diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx new file mode 100644 index 000000000..46a3145ac --- /dev/null +++ b/extensions/plugin-history-sync/src/historySyncPlugin.react.spec.tsx @@ -0,0 +1,376 @@ +/** @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, 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"; + +/** + * Regression tests for SSR hydration mismatch with non-empty `defaultHistory`. + * + * 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" { + interface Register { + Home: {}; + Article: { articleId: string }; + } +} + +const TRANSITION_DURATION = 32; + +const SSR_INITIAL_CONTEXT = { req: { path: "/articles/1" } }; + +type DefaultHistoryOption = "non-empty" | "empty" | "none"; + +function Home() { + return
home
; +} +function Article() { + return
article
; +} + +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(); + + useEffect(() => { + recordHomeActivity?.(activity); + }, [activity]); + + return null; +}; + +const ArticleActivity: ActivityComponentType<"Article"> = () => { + const activity = useActivity(); + + useEffect(() => { + recordArticleActivity?.(activity); + }, [activity]); + + return null; +}; + +function makeApp({ + defaultHistory = "non-empty", +}: { + defaultHistory?: DefaultHistoryOption; +} = {}) { + const articleRoute = (() => { + switch (defaultHistory) { + case "non-empty": + return { + path: "/articles/:articleId", + defaultHistory: () => [ + { activityName: "Home" as const, activityParams: {} }, + ], + }; + case "empty": + return { + path: "/articles/:articleId", + defaultHistory: () => [], + }; + case "none": + return "/articles/:articleId"; + } + })(); + + const config = defineConfig({ + transitionDuration: TRANSITION_DURATION, + activities: [ + { name: "Home", route: "/" }, + { + name: "Article", + route: articleRoute, + }, + ], + }); + + return stackflow({ + config, + components: { Home, Article }, + plugins: [ + basicRendererPlugin(), + historySyncPlugin({ + config, + history: createMemoryHistory({ initialEntries: ["/articles/1"] }), + fallbackActivity: () => "Home", + }), + ], + }); +} + +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 { + 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 app = makeApp({ + defaultHistory: "none", + }); + const html = renderServerHTML(app); + + 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 app = makeApp({ + defaultHistory: "empty", + }); + const html = renderServerHTML(app); + + // `defaultHistory: () => []` and a missing `defaultHistory` both yield no + // ancestor entries, so the destination lands immediately with no staged + // setup to replay. + expect(html).toContain('data-testid="article"'); + expect(html).not.toContain('data-testid="home"'); + }); + + test("the destination is not rendered during SSR", () => { + const app = makeApp(); + + const html = renderServerHTML(app); + + 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. 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"'); + + // --- Client hydration of that server HTML. + const container = document.createElement("div"); + container.innerHTML = serverHTML; + document.body.appendChild(container); + + // `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); + }, + }, + ); + }); + + // The client's first render equalled the server's frame 0 → no mismatch. + expect(recoverableErrors).toEqual([]); + + // The destination still mounts after hydration. + await act(async () => { + jest.advanceTimersByTime(TRANSITION_DURATION * 2); + }); + expect(container.querySelector('[data-testid="article"]')).not.toBeNull(); + } finally { + document.body.removeChild(container); + jest.useRealTimers(); + } + }); +}); + +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 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, + }, + }, + ], + }, + ], + }, + }, + ], + }); + + renderHistorySyncStack({ + config, + initialEntry: "/articles/9/", + }); + + await waitFor(() => { + const article = articleActivities.find((activity) => activity.isActive); + + expect(article?.params.articleId).toEqual("9"); + }); + + 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 () => { + // 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 homeActivities: ActivitySnapshot[] = []; + const articleActivities: ActivitySnapshot[] = []; + recordHomeActivity = (activity) => { + homeActivities.push(activity); + }; + recordArticleActivity = (activity) => { + articleActivities.push(activity); + }; + + const homeEncode = (params: unknown) => { + const visible = + typeof params === "object" && params !== null + ? (params as { visible?: unknown }).visible + : undefined; + + return { + visible: visible ? "y" : "n", + }; + }; + + const config = defineConfig({ + transitionDuration: TRANSITION_DURATION, + activities: [ + { + name: "Home", + route: { + path: "/home/", + encode: homeEncode, + }, + }, + { + name: "Article", + route: { + path: "/articles/:articleId", + defaultHistory: () => [ + { + activityName: "Home", + activityParams: { + visible: true as unknown as string, + }, + }, + ], + }, + }, + ], + }); + + const history = renderHistorySyncStack({ + config, + initialEntry: "/articles/9/?visible=true", + }); + + await waitFor(() => { + const article = articleActivities.find((activity) => activity.isActive); + + expect(article?.params.articleId).toEqual("9"); + }); + + await act(async () => { + history.back(); + }); + + await waitFor(() => { + const { pathname, search, hash } = history.location; + + expect(homeActivities.some((activity) => activity.isActive)).toEqual( + true, + ); + expect(`${pathname}${search}${hash}`).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 a88ce0730..21704c9ef 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts +++ b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts @@ -2965,68 +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 }); - 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 @@ -3448,62 +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 }); - // Allow defaultHistory replay (Home ancestor → Article target) to settle - // through onChanged → push/stepPush. - await a.getStack(); - await a.getStack(); - await a.getStack(); - - // 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(); diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.tsx index f821ce003..786f993ab 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"; 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"; @@ -171,6 +172,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 }) { @@ -180,6 +221,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 ( @@ -435,7 +493,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) { @@ -634,14 +697,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) { @@ -994,16 +1049,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 f38b4e1a2..a9e7e77b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5863,12 +5863,16 @@ __metadata: "@stackflow/config": "npm:^2.0.0" "@stackflow/core": "npm:^2.0.0" "@stackflow/esbuild-config": "npm:^1.0.3" + "@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" "@types/react-relay": "npm:^16.0.6" "@types/relay-runtime": "npm:^17" esbuild: "npm:^0.23.0" @@ -5876,7 +5880,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" @@ -5885,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