Skip to content

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

Open
ENvironmentSet wants to merge 2 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 2 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: c554889

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

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Fixed SSR hydration mismatch in the history-sync plugin when using non-empty defaultHistory so server and client first renders remain synchronized, while staged stacking animations continue after hydration.
  • Tests

    • Added SSR hydration tests for non-empty vs empty defaultHistory and ensured hydration completes without recoverable errors.
    • Enhanced test environment with necessary runtime polyfills for SSR/react-dom/server scenarios.
  • Chores

    • Updated package test configuration and refreshed package resolution metadata to support the new test setup.

Walkthrough

This PR defers historySyncPlugin's initial defaultHistory setup from synchronous onInit execution to a guarded client-only post-commit useEffect, adds Jest polyfills and test infra, introduces SSR/hydration regression tests, and regenerates Yarn PnP entries for the added 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 a defaultHistory ancestor coercion test 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 defaultHistory still resolves to destination, 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 clearly and specifically describes the main change: fixing an SSR hydration mismatch caused by non-empty defaultHistory in plugin-history-sync.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing the problem, root cause, fix approach, and test coverage.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/fep-2349

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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: c554889
Status: ✅  Deploy successful!
Preview URL: https://ae7c031b.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 c554889 Commit Preview URL May 31 2026, 03:18 PM

@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: c554889

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

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