Skip to content

[WIP] fix(plugin-history-sync): prevent SSR hydration mismatch with non-empty defaultHistory#713

Open
ENvironmentSet wants to merge 13 commits into
mainfrom
feature/fep-2349
Open

[WIP] fix(plugin-history-sync): prevent SSR hydration mismatch with non-empty defaultHistory#713
ENvironmentSet wants to merge 13 commits into
mainfrom
feature/fep-2349

Conversation

@ENvironmentSet
Copy link
Copy Markdown
Collaborator

Problem

When an activity route declares a non-empty defaultHistory, the initial stack rendered on the server does not match the stack rendered on the client's first (hydration) render, producing a React hydration mismatch (Hydration failed because the initial UI does not match what was rendered on the server). React then discards the server-rendered HTML and re-renders on the client.

Root cause

historySyncPlugin builds the defaultHistory back stack as a staged SerialNavigationProcess: the constructor (overrideInitialEvents) releases only the first entry, and the destination activity is pushed later by a kickoff inside the onInit hook.

onInit runs synchronously during the first client render (via store.init() inside the <Stack> useMemo), but it is skipped on the server (it is browser-only). So the server renders the first frame ([firstEntry]) while the client's first render already contains [firstEntry, destination] — a structural mismatch.

Fix

Kick off the staged setup navigation from a post-commit useEffect in the plugin's wrapStack hook instead of from onInit. Effects never run during SSR and run after the first commit on the client, so:

  • The server and the client's first render now produce the same output → no hydration mismatch.
  • The staged "stacking" setup animation is preserved — it simply begins after the first paint, advancing through the existing onChanged notification path.

The URL reconciliation and popstate listener stay in onInit (they don't touch the core stack, so their timing is unchanged). Empty defaultHistory routes are unaffected, and the change is isolated to @stackflow/plugin-history-sync (both the stable and future integrations are covered through the shared renderer).

Tests

  • Unit: store.init() no longer advances a non-empty defaultHistory setup (the server frame), and an empty defaultHistory still resolves directly to its destination.
  • SSR/hydration (jsdom): renderToStringhydrateRoot asserts no recoverable hydration error, then advances timers to confirm the staged setup animation still plays. These tests fail on the previous code (reproducing the mismatch) and pass with this change.

yarn test, yarn typecheck, and yarn build pass.

🤖 Generated with Claude Code

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 31, 2026

🦋 Changeset detected

Latest commit: 90c18e7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@stackflow/plugin-history-sync Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 31, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Defers historySyncPlugin's initial defaultHistory setup from synchronous onInit to a guarded client post-commit useEffect, adds Jest polyfills and SSR/hydration tests, adjusts core tests to emulate the render-driven kickoff, and regenerates Yarn PnP entries for the new test dependencies.

Changes

SSR Hydration Mismatch Fix

Layer / File(s) Summary
Fix documentation
.changeset/fix-ssr-hydration-default-history.md
Changeset entry documents the SSR hydration mismatch and the migration of setup navigation from synchronous onInit to post-commit effect.
Plugin behavior refactor (move setup nav to effect)
extensions/plugin-history-sync/src/historySyncPlugin.tsx
historySyncPlugin stores core actions on init, introduces dispatchInitialSetupNavigation, removes synchronous initialization kickoff, adds a guarded one-time useEffect in wrapStack to run after first paint, and simplifies onChanged.
Test infrastructure setup
extensions/plugin-history-sync/jest.setup.js, extensions/plugin-history-sync/package.json
Adds Jest setup polyfill for TextEncoder/TextDecoder; updates Jest setupFiles and configures @swc/jest with React runtime: "automatic"; expands devDependencies for renderer and testing tooling.
Core test helper and spec change
extensions/plugin-history-sync/src/historySyncPlugin.spec.ts
Adds kickOffDefaultHistorySetup(coreStore) test helper and invokes it in defaultHistory-related core tests to emulate the render-driven kickoff in core-only tests.
SSR/hydration regression tests
extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx
Adds SSR tests that verify non-empty defaultHistory preserves server/client first render (Home/frame 0), empty/none defaultHistory resolves directly, SSR output omits destination, and hydration completes without recoverable errors with destination mounting after advancing transition timers.

Dependency Updates

Layer / File(s) Summary
Yarn PnP regeneration
.pnp.cjs
Regenerated Yarn PnP lockfile entries and virtual metadata for jest-environment-jsdom, @testing-library/react, react-dom, jsdom, ws, and workspace mappings for @stackflow/plugin-renderer-basic/@stackflow/react.

Sequence Diagram

sequenceDiagram
  participant Client as Client render (first paint)
  participant WrapStack as wrapStack useEffect
  participant Dispatcher as dispatchInitialSetupNavigation
  participant CoreActions as coreActions / actions

  Client->>WrapStack: first paint completes
  WrapStack->>Dispatcher: call dispatchInitialSetupNavigation(coreActions)
  Dispatcher->>CoreActions: read initialSetupProcess pending navigations
  Dispatcher->>CoreActions: call actions.push / actions.stepPush for each navigation
  Dispatcher->>Dispatcher: refresh activation monitors
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: preventing SSR hydration mismatch with non-empty defaultHistory in plugin-history-sync, which is the core objective of this PR.
Description check ✅ Passed The description comprehensively explains the problem, root cause, fix, tests, and rationale, all of which directly relate to the changeset modifications across the plugin-history-sync package.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/fep-2349

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 31, 2026

Deploying stackflow-demo with  Cloudflare Pages  Cloudflare Pages

Latest commit: 90c18e7
Status: ✅  Deploy successful!
Preview URL: https://07db8000.stackflow-demo.pages.dev
Branch Preview URL: https://feature-fep-2349.stackflow-demo.pages.dev

View logs

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 31, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
stackflow-docs 90c18e7 Commit Preview URL Jun 01 2026, 08:08 AM

@ENvironmentSet
Copy link
Copy Markdown
Collaborator Author

ENvironmentSet commented May 31, 2026

Design rationale / alternatives considered

The mismatch only needs one thing fixed: the staged defaultHistory setup must not advance the core stack during the first render. Several approaches achieve that — here's why this one was chosen.

Premise: you can't SSR a stack that is mid-stacking-animation. A server snapshot is a single static frame, so it must be frame 0, with the animation continuing on the client after hydration. Every viable fix therefore keeps the server at frame 0 and defers the kickoff past the first commit.

1. Collapse the staged setup under SSR (render the whole stack at rest). Treat SSR like skipDefaultHistorySetupTransition: true and resolve the entire stack in the constructor. Removes the mismatch, but renders the destination at rest on the server, so the tick-by-tick "stacking" setup animation is lost on hydrated loads. ❌ Rejected — the animation is the point of the staged setup.

2. Move store.init() into a post-commit useEffect in the React integration. Arguably the "most correct" fix (it also removes a render-phase side effect, aligning with the recent Concurrent-Mode purity work), but it changes init() timing for every app — CSR included. Disproportionate blast radius for apps that never had this bug. ❌

3. Defer only the kickoff under SSR via requestAnimationFrame inside onInit. Keeps CSR untouched, but it requests a custom scheduler. ❌

Chosen: kick off the staged setup from a useEffect in wrapStack. wrapStack is already a React component (it calls useSyncExternalStore), so a useEffect is idiomatic and runs post-commit, client-only. This:

  • Fixes the mismatch completely — the only thing that mutated the core stack during render (the kickoff) now runs after the first commit.
  • Preserves the staged animation — the kickoff still runs; the existing onChanged + transition interval advance the rest.
  • Leaves URL reconciliation and the popstate listener exactly where they were in onInit (they don't touch the core stack, so their timing is unchanged — lowest behavioral risk for back-button/URL handling).
  • Stays entirely inside @stackflow/plugin-history-sync (no core or integration API change) and covers both integrations through the shared renderer.

Trade-off: for a route with a non-empty defaultHistory, the first committed frame is now the underlay (frame 0) and the destination enters one frame later — visually identical, since the destination already animates in. This is inherent to keeping SSR and the staged animation compatible.

@ENvironmentSet ENvironmentSet changed the title fix(plugin-history-sync): prevent SSR hydration mismatch with non-empty defaultHistory [WIP] fix(plugin-history-sync): prevent SSR hydration mismatch with non-empty defaultHistory May 31, 2026
…ty 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) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
extensions/plugin-history-sync/src/historySyncPlugin.tsx (1)

203-209: ⚡ Quick win

Replace ternary with if/else to avoid misleading return.

The ternary expression returns a value that .forEach() ignores. While functionally correct, this style implies the return matters. Use explicit if/else for side-effect-only callbacks.

♻️ Proposed fix
     initialSetupProcess
       ?.captureNavigationOpportunity(stack)
-      .forEach((event) =>
-        event.name === "Pushed"
-          ? actions.push(event)
-          : actions.stepPush(event),
-      );
+      .forEach((event) => {
+        if (event.name === "Pushed") {
+          actions.push(event);
+        } else {
+          actions.stepPush(event);
+        }
+      });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@extensions/plugin-history-sync/src/historySyncPlugin.tsx` around lines 203 -
209, The forEach callback uses a ternary that suggests a return value even
though it's used only for side effects; update the callback passed to
initialSetupProcess?.captureNavigationOpportunity(stack).forEach(...) to use an
explicit if/else: call actions.push(event) when event.name === "Pushed"
otherwise call actions.stepPush(event). This change should be made where
initialSetupProcess.captureNavigationOpportunity and the forEach are used so the
intent is clear and no misleading return value is implied.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@extensions/plugin-history-sync/src/historySyncPlugin.tsx`:
- Around line 203-209: The forEach callback uses a ternary that suggests a
return value even though it's used only for side effects; update the callback
passed to initialSetupProcess?.captureNavigationOpportunity(stack).forEach(...)
to use an explicit if/else: call actions.push(event) when event.name ===
"Pushed" otherwise call actions.stepPush(event). This change should be made
where initialSetupProcess.captureNavigationOpportunity and the forEach are used
so the intent is clear and no misleading return value is implied.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c488c764-2cfe-4fb1-ac0f-22dcfdf0e31e

📥 Commits

Reviewing files that changed from the base of the PR and between b346473 and 0b48b62.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (6)
  • .changeset/fix-ssr-hydration-default-history.md
  • .pnp.cjs
  • extensions/plugin-history-sync/jest.setup.js
  • extensions/plugin-history-sync/package.json
  • extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx
  • extensions/plugin-history-sync/src/historySyncPlugin.tsx
✅ Files skipped from review due to trivial changes (1)
  • .changeset/fix-ssr-hydration-default-history.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • extensions/plugin-history-sync/jest.setup.js
  • extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx
  • extensions/plugin-history-sync/package.json
  • .pnp.cjs

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 31, 2026

  • @stackflow/demo

    yarn add https://pkg.pr.new/@stackflow/link@713.tgz
    
    yarn add https://pkg.pr.new/@stackflow/plugin-history-sync@713.tgz
    

commit: 90c18e7

Resolve the v2 (future -> stable, #695) integration for the SSR hydration fix:
- package.json: adopt @stackflow/* ^2.0.0; keep test-only devDeps
  (@stackflow/plugin-renderer-basic, react-dom, @testing-library/*, jest-environment-jsdom)
- Rewrite the SSR/hydration spec for the v2 config API
  (defineConfig + stackflow({ config, components }))
- In historySyncPlugin.spec.ts, trigger the staged defaultHistory kickoff
  explicitly in the one core-level test that asserts the target landed, since
  that push now runs in wrapStack's post-commit effect (not onInit). The shared
  test helper is left unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
extensions/plugin-history-sync/src/historySyncPlugin.tsx (1)

204-210: 💤 Low value

Prefer explicit branching over ternary in forEach callback.

The ternary returns a value that forEach ignores, triggering the Biome lint warning. Using if/else makes the intent (execute side effects) explicit.

♻️ Proposed fix
   initialSetupProcess
     ?.captureNavigationOpportunity(stack)
-    .forEach((event) =>
-      event.name === "Pushed"
-        ? actions.push(event)
-        : actions.stepPush(event),
-    );
+    .forEach((event) => {
+      if (event.name === "Pushed") {
+        actions.push(event);
+      } else {
+        actions.stepPush(event);
+      }
+    });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@extensions/plugin-history-sync/src/historySyncPlugin.tsx` around lines 204 -
210, Replace the ternary used inside the forEach callback on
initialSetupProcess.captureNavigationOpportunity(stack).forEach so the callback
performs explicit branching for side effects: locate the forEach where the
callback currently uses event.name === "Pushed" ? actions.push(event) :
actions.stepPush(event) and change it to an if/else that calls
actions.push(event) when event.name === "Pushed" and otherwise calls
actions.stepPush(event), avoiding returning a value from the callback.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@extensions/plugin-history-sync/src/historySyncPlugin.tsx`:
- Around line 204-210: Replace the ternary used inside the forEach callback on
initialSetupProcess.captureNavigationOpportunity(stack).forEach so the callback
performs explicit branching for side effects: locate the forEach where the
callback currently uses event.name === "Pushed" ? actions.push(event) :
actions.stepPush(event) and change it to an if/else that calls
actions.push(event) when event.name === "Pushed" and otherwise calls
actions.stepPush(event), avoiding returning a value from the callback.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1b703860-b16f-434a-b2b2-7a5a04cdf0e6

📥 Commits

Reviewing files that changed from the base of the PR and between 9d2f384 and c554889.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (7)
  • .changeset/fix-ssr-hydration-default-history.md
  • .pnp.cjs
  • extensions/plugin-history-sync/jest.setup.js
  • extensions/plugin-history-sync/package.json
  • extensions/plugin-history-sync/src/historySyncPlugin.spec.ts
  • extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx
  • extensions/plugin-history-sync/src/historySyncPlugin.tsx
✅ Files skipped from review due to trivial changes (2)
  • .changeset/fix-ssr-hydration-default-history.md
  • .pnp.cjs
🚧 Files skipped from review as they are similar to previous changes (3)
  • extensions/plugin-history-sync/jest.setup.js
  • extensions/plugin-history-sync/package.json
  • extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx

ENvironmentSet and others added 2 commits June 1, 2026 11:39
…itions

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) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx`:
- Around line 51-77: The TypeScript error arises because the inferred union type
of the local variable articleRoute isn't being recognized as a RouteLike by the
compiler; update buildCoreStore by either casting the computed articleRoute to
the proper type (e.g., assert articleRoute as RouteLike<unknown>) before passing
it into the routes object for historySyncPlugin, or remove the intermediate
variable and inline the switch directly inside the routes.Article value so the
expression is inferred as RouteLike; modify the symbol articleRoute (or the
inline switch in buildCoreStore when constructing routes for historySyncPlugin)
accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 08c903cd-1ba5-4a2c-a810-4a4501e6800d

📥 Commits

Reviewing files that changed from the base of the PR and between c554889 and 13b9b45.

📒 Files selected for processing (2)
  • extensions/plugin-history-sync/src/historySyncPlugin.spec.ts
  • extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx

Comment thread extensions/plugin-history-sync/src/historySyncPlugin.ssr.spec.tsx Outdated
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant