Skip to content

Fix TanStack Router SSR hydration mismatch in Pro async path#2932

Merged
justin808 merged 13 commits intomainfrom
fix/tanstack-router-ssr-hydration-mismatch
Apr 6, 2026
Merged

Fix TanStack Router SSR hydration mismatch in Pro async path#2932
justin808 merged 13 commits intomainfrom
fix/tanstack-router-ssr-hydration-mismatch

Conversation

@Seifeldin7
Copy link
Copy Markdown
Contributor

@Seifeldin7 Seifeldin7 commented Apr 3, 2026

Summary

Fixes React hydration mismatch when using createTanStackRouterRenderFunction from react-on-rails-pro/tanstack-router with the async SSR path (serverRenderTanStackAppAsync).

Problem

RouterClient (from @tanstack/react-router/ssr/client) wraps RouterProvider in <Await> which always suspends on first render — defer() starts with status pending and resolves on the next microtask. Since the server renders with RouterProvider directly (no <Await> wrapper), the structural mismatch causes:

  • Hydration failed because the initial UI does not match what was rendered on the server
  • Failed to execute 'removeChild' on 'Node'
  • DehydratedFragment errors that crash ErrorBoundaries

React recovers by discarding the server HTML and re-rendering client-side, defeating SSR performance benefits.

Fix

Use RouterProvider directly on the client (matching the server tree) with synchronous route match injection:

  • router.matchRoutes() + router.__store.setState() to inject matches (same pattern as TanStack Router's own hydrate() in @tanstack/router-core/ssr/ssr-client.js)
  • router.ssr = { manifest: undefined } to prevent Transitioner auto-load during hydration
  • Override match status from pending to success for routes with loaders/beforeLoad (otherwise MatchInner throws loadPromise causing Suspense suspension)
  • Clear router.ssr in .finally() so it clears even on load failure

Changes

  • clientHydrate.ts: Replace RouterClient with RouterProvider + synchronous match hydration. RouterClient param kept for backward compat.
  • types.ts: Add matchRoutes and __store to TanStackRouter interface.
  • index.ts: Mark RouterClient as @deprecated in TanStackRouterDeps.
  • tanstackRouter.test.ts: Add regression tests and fix buildRouter() mock.

Verification

Two regression tests were added to tanstackRouter.test.ts that reproduce the hydration mismatch:

  1. "renders RouterProvider (not RouterClient) on client hydration with SSR match data" — Provides both RouterProvider and RouterClient as distinct mock components with SSR payload containing ssrRouter data. Asserts RouterProvider renders, not RouterClient.
  2. "injects SSR match data into router store to prevent Suspense suspension during hydration" — Verifies matchRoutes() + __store.setState() are called synchronously, transforming pending matches to success with server-provided loaderData.

Both tests were verified to fail without the fix (reverting clientHydrate.ts to select RouterClient when ssrRouter exists produces Received string: "<div data-testid=\"client\">RouterClient</div>") and pass with the fix (all 14 tests green).

Tested with

  • Printivity production app (Rails + React on Rails Pro + TanStack Router v1.139.0)
  • Homepage, CMS pages, authenticated routes, guest-only routes
  • 10+ page refreshes with zero hydration errors after fix

Test plan

  • Regression tests fail without fix, pass with fix
  • Existing TanStackRouterAppAsync system test passes
  • No hydration errors in browser console on SSR pages
  • Client-side navigation works after hydration
  • Client-only renders (prerender: false) still work (router.ssr not set without SSR payload)
  • Post-hydration router.load() failure clears router.ssr (no broken navigation)

Summary by CodeRabbit

  • Bug Fixes

    • More reliable SSR hydration: client restores server-provided match data (loader data, statuses), avoids unnecessary suspension/loading, and prevents initial-render mismatches.
  • Documentation

    • RouterClient marked deprecated; RouterProvider is used consistently for hydration.
  • Refactor

    • Simplified client hydration flow to apply server payload directly and manage a temporary SSR flag during hydration.
  • Tests

    • Added regression and behavior tests for match restoration, nested-route ID handling, and post-hydration state preservation.
  • Public API

    • Legacy optional client parameter retained for compatibility but no longer used during hydration.

RouterClient (from @tanstack/react-router/ssr/client) wraps
RouterProvider in <Await> which always suspends on first render —
defer() starts with status 'pending' and resolves on the next
microtask. Since the server renders with RouterProvider directly
(no <Await> wrapper), the structural difference causes React
hydration mismatch errors.

Fix: use RouterProvider directly on the client with synchronous
route match injection, matching the server-rendered component tree.

Changes:
- clientHydrate.ts: Replace RouterClient with RouterProvider +
  synchronous match hydration via matchRoutes/__store.setState.
  Override match status from 'pending' to 'success' for routes
  with loaders/beforeLoad (MatchInner throws loadPromise on pending).
  Clear router.ssr in .finally() so it clears even on load failure.
  Guard matchRoutes/__store with validation and clear error message.
  RouterClient param kept for backward compat but unused.
- types.ts: Add matchRoutes and __store to TanStackRouter interface.
- index.ts: Mark RouterClient as @deprecated in TanStackRouterDeps.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts Outdated
Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts Outdated
Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts Outdated
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 3, 2026

Review

The fix is well-motivated and the approach — using RouterProvider directly instead of RouterClient — correctly resolves the structural hydration mismatch. The code is clearly documented and the deprecation of RouterClient is handled cleanly. A few things worth addressing before merge:

Issues flagged inline

  1. Direct mutation of matchRoutes() return values (line 87–92) — match objects are mutated in-place before being handed to __store.setState. If a future TanStack Router version returns references to internal state objects rather than clones, this corrupts state before the setState. Prefer immutable transform inside the setState updater.

  2. router.ssr not cleared on render-phase exception (line 104) — the flag is set synchronously during render but is only cleared inside useEffect → .finally(). A throw during render means useEffect never runs, leaving router.ssr permanently set and blocking the Transitioner on all future navigations. A try/finally around the block or explicit documentation of the risk would help.

  3. .finally() and the cancelled flag (line 142–147) — minor clarity issue: the cancelled flag guards only .catch, not .finally. Since each mount creates a fresh router instance there's no cross-instance leak, but a comment explaining why this is safe would prevent future confusion.

  4. _RouterClient: unknown weakens the public API type (line 179) — restoring the original ComponentType<{ router: TanStackRouter }> | undefined keeps callers type-checked while still signalling the parameter is intentionally ignored.

Not in the diff but worth a follow-up

  • types.ts line 105: the JSDoc on DehydratedRouterState.ssrRouter still says "used by RouterClient hydration" — now stale since that path is deprecated.
  • TanStackSsrRouterState and TanStackSsrMatch are exported but no longer consumed by any code in this package. Consider marking them @deprecated or noting they're kept only for consumers who may be serialising the wire format.

Reliance on private APIs

The use of router.__store.setState and router.matchRoutes as internal APIs is a necessary trade-off given that RouterClient is broken for this use case. The version guard (>=1.139.0) and the explicit error message provide reasonable protection. It would be worth adding a note to the pro changelog / upgrade guide so users know to re-test after any @tanstack/react-router minor bump.

Overall the change is solid — the four inline items above are the main blockers.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 3, 2026

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

Client hydration for TanStack Router now relies on a dehydrated payload (__tanstackRouterDehydratedState), computes and injects matches synchronously into the router store, temporarily sets router.ssr during initial load, clears it after router.load() settles, and always mounts RouterProvider (no RouterClient-based hydration).

Changes

Cohort / File(s) Summary
Hydration Flow Refactor
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Removed global SSR bootstrap and window.$_TSR usage. Hydration gated on __tanstackRouterDehydratedState; sync router.matchRoutes(...) used to derive matches; server match fields are overlaid (including loaderData, status, updatedAt, error, ssr) and pendingsuccess coerced for client-only matches. Matches injected via router.__store.setState(...). Introduced temporary router.ssr flag during hydration and clear-on-settle logic. Always mounts RouterProvider. Exported clientHydrateTanStackApp parameter renamed to _RouterClient and is unused.
Types & Router API
packages/react-on-rails-pro/src/tanstack-router/types.ts
Added optional matchRoutes?: (location: unknown) => unknown[] and __store?: { setState(...) } to TanStackRouter to support synchronous match derivation and direct store mutation during hydration. Adjusted ssr JSDoc to indicate client-set behavior.
Deprecation Notice
packages/react-on-rails-pro/src/tanstack-router/index.ts
Documented TanStackRouterDeps.RouterClient as @deprecated in JSDoc; clarified that RouterProvider is used directly for hydration and RouterClient is retained only for backward compatibility.
Server Dehydration Payload
packages/react-on-rails-pro/src/tanstack-router/serverRender.ts
Server-side dehydratedState now includes ssrRouter via buildSsrRouterState(router) so client receives server-derived match data for restoration.
Tests
packages/react-on-rails-pro/tests/tanstackRouter.test.ts
Updated test stubs to include router.matchRoutes() and mocked __store.setState(); added tests asserting RouterProvider-only mounting, single setState invocation, pending→success transitions, loaderData overlay from SSR payload, nested-route ID restoration (null-sep → /), and preservation of user-supplied router.ssr after hydration.

Sequence Diagram(s)

sequenceDiagram
  participant Server as Server
  participant Browser as Browser
  participant Dehydrated as DehydratedState
  participant Router as Router
  participant Store as Store
  participant Provider as RouterProvider

  Browser->>Dehydrated: read __tanstackRouterDehydratedState
  alt Dehydrated payload present
    Browser->>Router: router.matchRoutes(router.state.location)
    Router-->>Browser: sync matches
    Browser->>Dehydrated: read ssrRouter.matches (optional)
    Browser->>Router: rehydrate match IDs and overlay server fields
    Browser->>Store: router.__store.setState({ status: "idle", resolvedLocation, matches })
    Browser->>Router: set temporary router.ssr
  end
  Browser->>Provider: mount RouterProvider with router
  Provider->>Router: router.load()
  Router-->>Browser: load settles
  Browser->>Router: clear temporary router.ssr (if still present)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I nudged old globals into the ground,
I stitch server matches, one leap, then bound,
Provider plants roots where Client will wake,
Store turns idle, pending dreams to take,
Hop—hydrate—snuggle bugs away, and celebrate.

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly and accurately summarizes the main change: fixing a TanStack Router SSR hydration mismatch in the Pro async path, which is precisely what the changeset addresses.

✏️ 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 fix/tanstack-router-ssr-hydration-mismatch

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.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 3, 2026

Greptile Summary

This PR fixes a React hydration mismatch caused by RouterClient wrapping RouterProvider in <Await> (which suspends on first render) while the server renders RouterProvider directly. The fix switches the client to use RouterProvider directly and synchronously injects route matches via TanStack Router's internal router.matchRoutes() + router.__store.setState() APIs, mirroring the pattern in TanStack Router's own ssr-client.js.

Confidence Score: 5/5

Safe to merge — all remaining findings are P2 style/robustness suggestions that don't affect correctness on the current code path.

The core fix is well-reasoned, production-tested, and the three inline comments are all P2: an in-place mutation that works today but could be fragile in future TanStack Router versions, a cross-instance race on router.ssr that is vanishingly unlikely in practice, and a missing pre-check for user-set router.ssr values. None of these represent a present defect in the shipped code.

clientHydrate.ts — uses internal TanStack Router APIs (__store, matchRoutes); worth re-testing if @tanstack/react-router is upgraded past 1.139.x.

Important Files Changed

Filename Overview
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts Core fix: replaces RouterClient with direct RouterProvider + synchronous match injection via internal router APIs. Logic is sound; minor fragility around in-place mutation of match objects and unconditional router.ssr overwrite.
packages/react-on-rails-pro/src/tanstack-router/types.ts Adds matchRoutes and __store to TanStackRouter interface; existing types preserved for backward compatibility.
packages/react-on-rails-pro/src/tanstack-router/index.ts Deprecates RouterClient in TanStackRouterDeps JSDoc; no functional changes to the render function.

Sequence Diagram

sequenceDiagram
    participant Server
    participant Client
    participant Router
    participant ReactDOM

    Server->>Router: createRouter() + router.load()
    Server->>ReactDOM: renderToString(<RouterProvider router={router}>)
    Server-->>Client: HTML + dehydratedState (__tanstackRouterDehydratedState)

    Client->>Router: createRouter() + router.update({ history: browserHistory })
    Client->>Router: router.matchRoutes(location) → matches[]
    Note over Client,Router: Override pending→success on all matches
    Client->>Router: router.__store.setState({ status:'idle', resolvedLocation, matches })
    Client->>Router: router.ssr = { manifest: undefined }
    Client->>ReactDOM: hydrateRoot(<RouterProvider router={router}>)
    Note over ReactDOM: Tree matches server HTML ✓

    ReactDOM-->>Client: Mount complete (useEffect fires)
    Client->>Router: router.load()
    Router-->>Client: load settled (.finally)
    Client->>Router: router.ssr = undefined
    Note over Client,Router: Transitioner re-enabled for navigation
Loading

Reviews (1): Last reviewed commit: "Fix TanStack Router SSR hydration mismat..." | Re-trigger Greptile

Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts Outdated
Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 3, 2026

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.63 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.63 KB (0%)
react-on-rails/client bundled (brotli) 53.7 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.7 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.65 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.65 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.67 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.67 KB (0%)
registerServerComponent/client bundled (gzip) 127.46 KB (0%)
registerServerComponent/client bundled (gzip) (time) 127.46 KB (0%)
registerServerComponent/client bundled (brotli) 61.65 KB (0%)
registerServerComponent/client bundled (brotli) (time) 61.65 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 122.02 KB (+0.01% 🔺)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.02 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.77 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.77 KB (0%)

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ebbf2ba62a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts Outdated
- Immutable match mapping: use .map() with spread instead of in-place
  mutation, safe if matchRoutes() returns shared references in future
- Apply dehydrated SSR match data (loaderData, beforeLoadContext,
  status, errors) from server payload when available, so routes that
  render from loader results hydrate correctly
- Guard router.ssr with !router.ssr check to preserve user-set values
- Guard .finally() with !cancelled to avoid clearing router.ssr on a
  stale mount after unmount
- Restore narrower ComponentType for _RouterClient param type
- Add rehydrateMatchId helper to convert \0 separator back to /

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 3, 2026

Review: Fix TanStack Router SSR hydration mismatch

The core approach here is sound and well-reasoned. Using RouterProvider directly instead of RouterClient to avoid the <Await>/defer() suspension mismatch is the correct fix, and the synchronous match injection before first render mirrors TanStack Router's own internal hydration contract.

A few issues worth addressing before merge:


Bug: router.ssr is unconditionally cleared in .finally()

The old code guarded this with didSetLegacySsrFlagRef.current to only clear router.ssr if this module set it. The new .finally() block clears it unconditionally:

if (!cancelled) {
  router.ssr = undefined;  // clears user-set router.ssr too
}

If a user's createRouter() sets router.ssr themselves (e.g. for TanStack Start compatibility), the if (!router.ssr) guard on line ~150 correctly preserves it — but the .finally() will still clear it after the first router.load(). The didSetSsrFlagRef tracking should be restored to guard this.


Bug: Error message version constraint doesn't match peerDependencies

The validation error says >=1.139.0 but peerDependencies specifies >=1.139.0 <2.0.0. These should match to avoid misleading error messages.


Design concern: Internal APIs required in public interface

matchRoutes and __store.setState are private TanStack Router internals. Making them required (non-optional) in the TanStackRouter interface is a semantic breaking change for users who mock or stub the router in tests. Consider:

  • Making them optional (matchRoutes?, __store?) with graceful degradation, or
  • Moving them to a separate internal interface not exported as part of the public API contract.

Missing test coverage

The buildRouter() mock in tanstackRouter.test.ts does not include matchRoutes or __store, so the existing tests that pass __tanstackRouterDehydratedState will hit the validation throw at runtime rather than exercising the hydration path. New tests should:

  1. Add matchRoutes and __store.setState mocks to buildRouter()
  2. Verify applyDehydratedMatchData is called and route match data is injected correctly

Minor: applyDehydratedMatchData O(n×m) lookup

The Array.find inside Array.map is fine for typical route trees, but a Map keyed by rehydrated ID would be cleaner and O(n+m) if match counts grow. Not a blocker, just a note.

Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts Outdated
Comment thread packages/react-on-rails-pro/src/tanstack-router/types.ts Outdated
Copy link
Copy Markdown
Contributor

@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 (2)
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts (1)

62-115: Stop shipping the legacy ssrRouter payload if this is now the only hydrator.

This code no longer reads dehydratedState.ssrRouter, but packages/react-on-rails-pro/src/tanstack-router/serverRender.ts still serializes it into clientProps. That keeps sending the old match payload alongside dehydratedRouter, which can bloat SSR HTML for loader-heavy routes. If that wire field still matters for compatibility, mark it legacy; otherwise stop emitting it and trim it from DehydratedRouterState.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts` around
lines 62 - 115, The server still serializes the legacy ssrRouter payload
(dehydratedState.ssrRouter) into clientProps even though clientHydrate.ts now
reads only dehydratedRouter; update the serializer in serverRender.ts to stop
emitting ssrRouter (or mark it as legacy) and trim it from the
DehydratedRouterState type: remove or stop populating ssrRouter from the payload
construction in the function that builds clientProps, update/deprecate the
DehydratedRouterState interface/type to no longer include ssrRouter (or mark it
optional/legacy with a comment), and ensure any downstream consumers reference
dehydratedRouter instead of ssrRouter to avoid sending the extra match payload.
packages/react-on-rails-pro/src/tanstack-router/types.ts (1)

11-14: Remove hydration-specific members from the exported TanStackRouter interface.

TanStackRouterOptions.createRouter() requires returning a TanStackRouter that includes matchRoutes and __store, even though both are only used behind a runtime guard in clientHydrate.ts. This forces consumers to satisfy these internal API requirements at compile time. Since the validation already checks for these members at runtime, a local hydration-only type would keep the public contract focused on the core router shape.

♻️ Suggested direction
 export interface TanStackRouter {
   update: (opts: { history: TanStackHistory }) => void;
   load: () => Promise<void>;
-  matchRoutes: (location: unknown) => unknown[];
-  __store: {
-    setState: (updater: (s: Record<string, unknown>) => Record<string, unknown>) => void;
-  };
   state: {
type TanStackRouterHydrationInternals = TanStackRouter & {
  matchRoutes: (location: unknown) => unknown[];
  __store: {
    setState: (updater: (s: Record<string, unknown>) => Record<string, unknown>) => void;
  };
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/src/tanstack-router/types.ts` around lines 11 -
14, Export only the core TanStackRouter shape (remove matchRoutes and __store
from the exported TanStackRouter interface) and introduce a local hydration-only
type TanStackRouterHydrationInternals = TanStackRouter & { matchRoutes:
(location: unknown)=>unknown[]; __store: { setState: (updater: (s:
Record<string,unknown>)=>Record<string,unknown>)=>void } }; update usages that
currently require those internals (notably the createRouter return type and the
client hydration runtime guard in clientHydrate.ts) to assert/accept
TanStackRouterHydrationInternals at the call site, leaving the public
TanStackRouter interface focused on the core API.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts`:
- Around line 62-115: The server still serializes the legacy ssrRouter payload
(dehydratedState.ssrRouter) into clientProps even though clientHydrate.ts now
reads only dehydratedRouter; update the serializer in serverRender.ts to stop
emitting ssrRouter (or mark it as legacy) and trim it from the
DehydratedRouterState type: remove or stop populating ssrRouter from the payload
construction in the function that builds clientProps, update/deprecate the
DehydratedRouterState interface/type to no longer include ssrRouter (or mark it
optional/legacy with a comment), and ensure any downstream consumers reference
dehydratedRouter instead of ssrRouter to avoid sending the extra match payload.

In `@packages/react-on-rails-pro/src/tanstack-router/types.ts`:
- Around line 11-14: Export only the core TanStackRouter shape (remove
matchRoutes and __store from the exported TanStackRouter interface) and
introduce a local hydration-only type TanStackRouterHydrationInternals =
TanStackRouter & { matchRoutes: (location: unknown)=>unknown[]; __store: {
setState: (updater: (s: Record<string,unknown>)=>Record<string,unknown>)=>void }
}; update usages that currently require those internals (notably the
createRouter return type and the client hydration runtime guard in
clientHydrate.ts) to assert/accept TanStackRouterHydrationInternals at the call
site, leaving the public TanStackRouter interface focused on the core API.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2e8c9baf-cb99-4ec8-ae57-351ab09b6c77

📥 Commits

Reviewing files that changed from the base of the PR and between ea72c13 and ebbf2ba.

📒 Files selected for processing (3)
  • packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
  • packages/react-on-rails-pro/src/tanstack-router/index.ts
  • packages/react-on-rails-pro/src/tanstack-router/types.ts

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fa30ceb795

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts Outdated
@Seifeldin7 Seifeldin7 requested a review from justin808 April 3, 2026 20:51
@justin808
Copy link
Copy Markdown
Member

Address-review run for PR #2932.
Scan window: full PR history (no prior checkpoint marker), triaged feedback from 2026-04-03T16:37:31Z to 2026-04-03T16:51:40Z.
Mode: default cutoff behavior (no check all reviews override).

Mattered

  • [Addressed] Preserved user-provided router.ssr ownership during hydration cleanup. Implemented didSetSsrFlagRef tracking and conditional cleanup; added regression coverage for preconfigured router.ssr preservation after post-hydration load. (commit 24a6b93d3)
  • [Addressed] Added render-phase hydration error handling so temporary SSR flags created by this module are cleared before rethrow.
  • [Addressed] Avoided public type-surface break by making TanStackRouter.matchRoutes and __store optional in the exported interface; hydration internals are asserted locally after runtime checks.
  • [Addressed] Aligned runtime version guidance with peer dependency range: @tanstack/react-router >=1.139.0 <2.0.0.
  • [Addressed] Clarified ssrRouter as a legacy compatibility payload and documented why it is still serialized.
  • [Addressed] Added/updated focused TanStack Router tests for hydration behavior and router.ssr lifecycle.

Skipped

  • Direct-mutation warnings were skipped because current code already applies immutable match mapping before setState.
  • .finally() race/clarity nits were skipped as non-blocking; current code already guards with !cancelled and includes explicit intent comments.
  • _RouterClient typing warning was skipped as already addressed in the existing signature.
  • “Rehydrate from payload” warning was skipped as outdated; current hydration applies ssrRouter.matches via applyDehydratedMatchData.
  • Status-only bot posts (size-limit / run metadata) were skipped as non-actionable.

No follow-up issue was created.

Future full-PR scans should start after this comment unless check all reviews is explicitly requested.

Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 4, 2026

Review

The core fix is correct and well-explained: replacing RouterClient (which wraps RouterProvider in <Await>/defer()) with RouterProvider directly eliminates the structural server/client mismatch. The synchronous match injection via matchRoutes + __store.setState mirrors what TanStack Router's own hydrate() does internally, which is the right approach. The deprecation of RouterClient is handled cleanly.

Three items flagged inline. One broader concern worth discussing:

SSR payload size: double-serialization of loader data

serverRender.ts now emits both ssrRouter (short-key match payload built by buildSsrRouterState) and dehydratedRouter (from router.dehydrate()). clientHydrate.ts reads ssrRouter.matches[].l to restore loader data during hydration. In many apps these two payloads will contain the same loader data — serialized twice in the SSR HTML.

For apps with large loader payloads this doubles the wire weight. Since ssrRouter is now the authoritative source for match-data restoration on the client, consider whether dehydratedRouter still needs to be sent (or vice versa). If both are needed for different reasons (e.g. dehydratedRouter for user-defined router.hydrate() callbacks and ssrRouter for match injection), a comment in serverRender.ts explaining why both exist would prevent a future maintainer from "deduplicating" them incorrectly.

Minor: exported SSR wire-format types lack deprecation markers

types.ts line 108 now describes ssrRouter as a "legacy" field, but the exported TanStackSsrRouterState and TanStackSsrMatch interfaces themselves have no @deprecated marker. Since they're exported, marking them or adding a note that they represent an internal wire format would signal to library consumers not to build their own serializers against them.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 24a6b93d3e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts Outdated
Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Copy link
Copy Markdown
Contributor

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts (1)

207-224: ⚠️ Potential issue | 🟠 Major

Clear the temporary router.ssr flag from cleanup too.

If router.load() is still pending when this instance unmounts, cancelled = true suppresses the .finally() cleanup and leaves the synthetic router.ssr value on the router. Reusing that router later can keep Transitioner in “SSR” mode and skip its initial load indefinitely.

Suggested fix
     return () => {
       cancelled = true;
+      if (didSetSsrFlagRef.current) {
+        router.ssr = undefined;
+        didSetSsrFlagRef.current = false;
+      }
       const cancellableRouter = router as TanStackRouter & { cancelLoad?: () => void };
       if (typeof cancellableRouter.cancelLoad === 'function') {
         cancellableRouter.cancelLoad();
       }
     };

Based on learnings, repeated calls to the public ReactOnRails.render(...) API on the same DOM node are not deduplicated, so leaked router state can affect later mounts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts` around
lines 207 - 224, The temporary router.ssr flag set earlier can be left on the
router if the component unmounts while router.load() is pending because
cancelled prevents the .finally() from running; update the returned cleanup (the
function that sets cancelled = true and calls cancellableRouter.cancelLoad()) to
also clear the temporary SSR flag when this mount set it by checking
didSetSsrFlagRef.current and, if true, set router.ssr = undefined and
didSetSsrFlagRef.current = false (only clear when the flag was set by this
instance to avoid clobbering user-provided router.ssr).
🧹 Nitpick comments (1)
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts (1)

116-121: Refactor the error message to focus on missing capabilities, not version constraints.

The error currently hard-codes >=1.139.0 <2.0.0 inline. While this matches the declared peerDependency, the runtime check should validate that router.matchRoutes() and router.__store.setState exist—the actual requirements—rather than embedding a version range that may shift over time. The peerDependency declaration already enforces the version constraint at install time; the runtime error is clearer and more maintainable if it reports what's actually missing:

'react-on-rails-pro/tanstack-router: router.matchRoutes() and router.__store.setState are required but not available.'

This decouples the error message from version management and makes it resilient to future peerDependency adjustments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts` around
lines 116 - 121, The runtime error message hard-codes a version range; change it
to report the missing capabilities instead: update the thrown Error in the
validation block that checks typeof router.matchRoutes and
router.__store?.setState so the message reads something like
"react-on-rails-pro/tanstack-router: router.matchRoutes() and
router.__store.setState are required but not available." — locate the
conditional that throws the Error (references router.matchRoutes and
router.__store.setState) and replace the current string that includes ">=1.139.0
<2.0.0" with the capability-focused message only.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts`:
- Around line 207-224: The temporary router.ssr flag set earlier can be left on
the router if the component unmounts while router.load() is pending because
cancelled prevents the .finally() from running; update the returned cleanup (the
function that sets cancelled = true and calls cancellableRouter.cancelLoad()) to
also clear the temporary SSR flag when this mount set it by checking
didSetSsrFlagRef.current and, if true, set router.ssr = undefined and
didSetSsrFlagRef.current = false (only clear when the flag was set by this
instance to avoid clobbering user-provided router.ssr).

---

Nitpick comments:
In `@packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts`:
- Around line 116-121: The runtime error message hard-codes a version range;
change it to report the missing capabilities instead: update the thrown Error in
the validation block that checks typeof router.matchRoutes and
router.__store?.setState so the message reads something like
"react-on-rails-pro/tanstack-router: router.matchRoutes() and
router.__store.setState are required but not available." — locate the
conditional that throws the Error (references router.matchRoutes and
router.__store.setState) and replace the current string that includes ">=1.139.0
<2.0.0" with the capability-focused message only.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a5db6343-1cb1-4622-bd33-292a462f2a20

📥 Commits

Reviewing files that changed from the base of the PR and between fa30ceb and 24a6b93.

📒 Files selected for processing (4)
  • packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
  • packages/react-on-rails-pro/src/tanstack-router/serverRender.ts
  • packages/react-on-rails-pro/src/tanstack-router/types.ts
  • packages/react-on-rails-pro/tests/tanstackRouter.test.ts

@justin808
Copy link
Copy Markdown
Member

All review feedback has been addressed across commits fa30ceb and 24a6b93:

Addressed in code:

  • router.ssr cleanup now guarded with didSetSsrFlagRef ownership tracking — only clears when this module set it
  • Error message version constraint updated to match peerDependencies (>=1.139.0 <2.0.0)
  • matchRoutes and __store made optional in public TanStackRouter interface, with a separate TanStackRouterHydrationInternals intersection type for internal use
  • try/catch added around hydration block as safety net for render-phase exceptions
  • Cross-instance interference comment already present in code

Already correct (no change needed):

  • Match objects already use immutable spread patterns throughout — no in-place mutation
  • if (!router.ssr) guard preserves user-set values from createRouter()
  • applyDehydratedMatchData already hydrates from server payload (loaderData, beforeLoadContext, status, errors, SSR flags)
  • _RouterClient parameter already typed as ComponentType<{ router: TanStackRouter }> | undefined

All 12 review threads have been resolved.

Address review feedback: document the \0 separator convention with a
cross-reference to dehydrateSsrMatchId in serverRender.ts and cite
the upstream DehydrateRouter.tsx source. Add a test exercising nested
route ID rehydration (\0products\0$id → /products/$id) to pin the
encoding assumption.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 6, 2026

Review: Fix TanStack Router SSR hydration mismatch

The root cause analysis is correct and well-documented — replacing RouterClient (which suspends on first render via Await/defer()) with direct RouterProvider usage is the right fix. The test suite is thorough and the regression tests directly prove the failure mode. A few issues to address:

Required: Missing CHANGELOG entry

This is a user-visible bug fix affecting SSR behavior. Per project guidelines, a [Pro]-tagged entry under Fixed is needed.

Issue 1: O(n x m) linear search in applyDehydratedMatchData (clientHydrate.ts:142)

The ssrMatches.find() call inside matches.map() rehydrates every SSR match ID for each client match lookup. For a deep route tree this compounds. A pre-built Map keyed by rehydrated ID would be O(n+m) instead.

Issue 2: Async router.options.hydrate errors are silently swallowed (clientHydrate.ts:309-316)

hydrationCallbackPromiseRef is constructed with a .catch that only logs, so it always resolves. runPostHydrationLoad therefore proceeds to router.load() even when the user hydration callback failed. If the callback populates a query cache (TanStack Query etc.), silently continuing means the page renders with missing cache data and no error boundary fires.

Issue 3: routeId vs. id asymmetry between preload and match injection

preloadMatchedRouteChunks looks up routes via match.routeId -> looseRoutesById[routeId], while applyDehydratedMatchData matches by m.id. TanStack Router distinguishes id (fully-resolved match ID, e.g. /products/42) from routeId (route definition ID, e.g. /products/$id). If these diverge for dynamic segments, a route would preload correctly but silently miss SSR data injection. Worth adding a test for a dynamic route with ssrRouter match data to confirm both paths use the right key.

Minor

The dev-mode private internals warning fires inside the routerRef.current === null render-phase block. In concurrent rendering, React may run (and abandon) this render before committing, causing the warning to appear even if the component never mounts. A useEffect would tie it to commit.

): unknown[] {
return matches.map((match) => {
const m = match as Record<string, unknown>;
const ssrMatch = ssrMatches.find((sm) => rehydrateMatchId(sm.i) === m.id);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

O(n×m) — find rehydrates every SSR match ID for each client match. With many route segments this can be noticeable. Pre-building a Map before the map() call makes this O(n+m):

Suggested change
const ssrMatch = ssrMatches.find((sm) => rehydrateMatchId(sm.i) === m.id);
function applyDehydratedMatchData(
matches: unknown[],
ssrMatches: TanStackSsrMatch[],
onMissingSsrMatch?: (match: Record<string, unknown>) => void,
): unknown[] {
const ssrMatchMap = new Map(ssrMatches.map((sm) => [rehydrateMatchId(sm.i), sm]));
return matches.map((match) => {
const m = match as Record<string, unknown>;
const ssrMatch = ssrMatchMap.get(m.id as string);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks. I am leaving this as-is for now: this lookup runs once during hydration and matched route arrays are expected to stay small, so I am treating the Map refactor as a non-blocking perf and readability tweak rather than a merge blocker for this fix.

'react-on-rails-pro/tanstack-router: Error in router.options.hydrate callback:',
error,
);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The .catch here always resolves the promise, so runPostHydrationLoad will proceed to router.load() even when the user's hydration callback throws async. For callbacks that populate a query cache (e.g. TanStack Query's dehydrate/hydrate), this means the page continues to render with incomplete cache state and no error boundary fires.

Consider whether a failed hydration should abort the load sequence:

hydrationCallbackPromiseRef.current = Promise.resolve(hydrationResult).then(() => undefined);
// Let errors propagate — runPostHydrationLoad().catch() already logs them.

Or at minimum document that errors here are intentionally non-fatal.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Implemented in commit 16a781b. Async router.options.hydrate rejections now propagate through the post-hydration flow, so router.load() is not invoked after a rejected hydrate callback. Added regression coverage for the reject path and verified the temporary router.ssr flag is still cleared.


const routeChunkPromises: Array<Promise<unknown>> = [];
matches.forEach((match) => {
const { routeId } = match as { routeId?: unknown };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This uses match.routeId to resolve the route object from looseRoutesById, while applyDehydratedMatchData (line 142) uses m.id to match against SSR data. TanStack Router distinguishes these two fields for dynamic-segment routes: id is the fully-resolved match ID (e.g. /products/42) while routeId is the route definition ID (e.g. /products/$id).

If they diverge, a dynamic-segment route preloads its chunk correctly but the SSR match data lookup in applyDehydratedMatchData uses m.id which would need to equal the rehydrated sm.i. Worth verifying with a test that a route with id = '/products/42', routeId = '/products/$id', and an SSR match i = '\0products\0$id' injects loaderData correctly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks. I re-checked this against the installed TanStack Router sources and kept the current split intentionally: chunk preloading resolves routes via match.routeId, while SSR dehydration and hydration key match data by match.id. That mirrors upstream SSR client and server behavior, so I am not adding a separate dynamic-route test for the reviewer assumption here.

@justin808
Copy link
Copy Markdown
Member

Follow-up on issuecomment-4189911717:

Addressed in commit 16a781b:

  • Added the missing CHANGELOG.md entry for this user-visible Pro bug fix.
  • Changed the async router.options.hydrate path so rejected hydration callbacks stop the post-hydration flow instead of continuing into router.load() with partially hydrated client state.
  • Added a regression test covering the rejected async hydrate path and verifying the temporary router.ssr flag is still cleared.

Skipped for this PR:

  • The O(n×m) lookup in applyDehydratedMatchData remains unchanged because it runs once during hydration and expected match arrays stay small.
  • The routeId vs id concern does not apply here; TanStack Router resolves routes by routeId for chunk preloading and keys SSR match dehydration by match.id.
  • The dev-only private-internals warning remains in place; I am treating that as a non-blocking architectural preference rather than a correctness blocker for this hydration fix.

@justin808
Copy link
Copy Markdown
Member

Address-review follow-up completed for feedback after the previous summary cutoff at 2026-04-06T01:21:14Z through latest triaged review activity at 2026-04-06T01:24:49Z (default cutoff mode; no check all reviews override).

Mattered

  • Addressed: added the missing CHANGELOG.md entry for this user-visible Pro SSR hydration fix.
    • Commit: 16a781bd1
  • Addressed: async router.options.hydrate rejections now stop the post-hydration flow instead of continuing into router.load() with partially hydrated client state.
    • Commit: 16a781bd1
    • Regression test added: does not call router.load when async router.options.hydrate rejects
    • Thread replied: discussion_r3037613475
  • General review summary issuecomment-4189911717 received a follow-up reply covering the deduplicated must-fix and skipped items.

Skipped

  • O(n×m) lookup in applyDehydratedMatchData: left as-is as a non-blocking one-time hydration micro-optimization; rationale replied in discussion_r3037613302.
  • routeId vs id dynamic-route concern: reviewer assumption did not match TanStack Router SSR behavior; rationale replied in discussion_r3037613699.
  • Dev-only private-internals warning placement: left as-is as a non-blocking architectural preference; rationale included in the general follow-up comment.

No follow-up issue was created in this action chain.

Future full-PR scans should start after this comment unless you explicitly say check all reviews.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 6, 2026

Review: Fix TanStack Router SSR Hydration Mismatch

The root cause analysis is correct and well-documented. RouterClient wrapping RouterProvider in <Await>/defer() creates a component tree that differs structurally from the server-rendered tree, which always uses RouterProvider directly — a guaranteed hydration mismatch.

What's good

  • Fix is correct. Using RouterProvider directly with synchronous match injection mirrors TanStack Router's own hydrate() in ssr-client.js and eliminates the mismatch.
  • StrictMode correctness is carefully handled: didTriggerPostHydrationLoadRef prevents double-load(), didSetSsrFlagRef prevents clobbering user-provided router.ssr, and the finally unconditionally clears the temporary flag so StrictMode unmount/remount doesn't leave it stuck.
  • Async hydration callback sequencing (router.options.hydrate → chunk preload → router.load()) with cancellation on unmount is implemented correctly, including aborting router.load() on rejection.
  • Good regression tests that fail without the fix (confirmed in the PR description).

Concerns

Private API surface is wide and not version-pinned in package.json

The implementation depends on four internal TanStack Router APIs (matchRoutes, __store.setState, loadRouteChunk, looseRoutesById) that can change without a semver signal. The development warning documents the range (>=1.139.0 <2.0.0) but it is not enforced as a peerDependencies constraint. A minor TanStack Router release reshaping __store would silently break hydration in production (the guard only checks existence, not signature). Consider tightening the peer dep range to ~1.139 or adding a peerDependenciesMeta note.

Silent status override in production

When matchRoutes() returns a match with status: 'pending' that has no corresponding server match, applyDehydratedMatchData overrides it to 'success' silently in production (the warnMissingSsrMatch early-returns). This can mask real data mismatches in production deployments. A lower-severity console.warn (rather than a full dev-only guard) might be worth considering.

children as ReactElement in RouteChunkPreloadGate is a TypeScript lie

children is typed ReactElement | undefined, but the function is declared to return ReactElement and casts children as ReactElement. In practice children is always provided, but TypeScript won't catch a call site omitting it. A narrower return type or an explicit guard would be safer.

O(n·m) match lookup in applyDehydratedMatchData

ssrMatches.find() is called for every client match, resulting in O(n·m) complexity. For typical apps with <20 routes this is negligible, but for large route trees a Map<string, TanStackSsrMatch> keyed by the rehydrated ID would be cleaner and avoids repeated linear scans.

Minor observations

  • rehydrateMatchId could be simplified to dehydratedId.replaceAll('\0', '/') (ES2021, already in the TypeScript target range for this package).
  • The Suspense fallback: null during chunk preload suspension will render a blank page. A comment or a configurable fallback prop might help consumers who notice the blank flash.
  • The backward-compat RouterClient parameter is now a no-op with a one-time deprecation warning — the deprecation path is clear, but a timeline or major-version removal note in the JSDoc would help.

Summary

The fix is sound and the implementation is careful about StrictMode, async sequencing, and cancellation. The main risk is the dependency on TanStack Router internals that could change silently; the other items are relatively low-severity polish.

matches.forEach((match) => {
const { routeId } = match as { routeId?: unknown };
if (typeof routeId !== 'string') {
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

TypeScript lie: children is typed ReactElement | undefined but the function returns ReactElement and casts it unconditionally. If a call site ever omits children, the runtime returns undefined with no type error. A simple guard makes the contract explicit:

Suggested change
return;
return (children ?? null) as ReactElement;

Or widen the return type to ReactElement | null.

): unknown[] {
return matches.map((match) => {
const m = match as Record<string, unknown>;
const ssrMatch = ssrMatches.find((sm) => rehydrateMatchId(sm.i) === m.id);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

O(n·m) lookup — ssrMatches.find() is called for every client match. For apps with many routes, pre-building a Map keyed by the rehydrated ID avoids repeated linear scans:

Suggested change
const ssrMatch = ssrMatches.find((sm) => rehydrateMatchId(sm.i) === m.id);
function applyDehydratedMatchData(
matches: unknown[],
ssrMatches: TanStackSsrMatch[],
onMissingSsrMatch?: (match: Record<string, unknown>) => void,
): unknown[] {
const ssrMatchByRouteId = new Map(ssrMatches.map((sm) => [rehydrateMatchId(sm.i), sm]));
return matches.map((match) => {
const m = match as Record<string, unknown>;
const ssrMatch = ssrMatchByRouteId.get(m.id as string);

if (hasSsrPayload) {
if (process.env.NODE_ENV === 'development' && !didWarnPrivateInternalsRef.current) {
didWarnPrivateInternalsRef.current = true;
console.warn(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The development warning correctly documents the supported version range, but nothing in package.json enforces it. A consumer who upgrades @tanstack/react-router to an incompatible minor/patch version would get a silent failure (match injection still runs against a reshaped __store). Consider tightening the peerDependencies entry to a ~ or ^1.139 range, or at least adding a peerDependenciesMeta note so package managers can warn on mismatch.

const didWarnPrivateInternalsRef = useRef(false);
const warnedMissingSsrMatchIdsRef = useRef<Set<string>>(new Set());

const warnMissingSsrMatch = (match: Record<string, unknown>): void => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

warnMissingSsrMatch is a plain function defined in the component body on every render. It closes over warnedMissingSsrMatchIdsRef, which is stable, so there's no correctness issue — but it's a new function object every render. Since it's only called during the routerRef.current === null block (once per mount), this is harmless in practice; just noting it for clarity.

// hydration render, the gate is skipped to match the server-rendered tree
// and avoid a hydration mismatch. After the post-mount effect runs
// (didTriggerPostHydrationLoadRef becomes true), the gate activates normally.
let app: ReactElement = createElement(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

fallback: null means the page goes blank if the RouteChunkPreloadGate suspends (i.e., a lazy chunk hasn't settled before the first post-hydration re-render). In practice this suspension should be very brief, but a comment here explaining the choice would help future readers understand why a blank flash is acceptable rather than a missing feature.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 16a781bd14

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +316 to +317
if (hasDehydratedRouter && typeof router.hydrate === 'function') {
router.hydrate(dehydratedState.dehydratedRouter);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid double-running hydration callbacks

This branch manually invokes router.options.hydrate(...) and then still calls router.hydrate(...) when available; for TanStack routers that wire the option into normal router hydration, that executes user hydration logic twice on the same payload. In apps that restore external caches/state in hydrate, this can duplicate side effects (extra fetches, duplicate cache writes, or inconsistent first-render state).

Useful? React with 👍 / 👎.

@justin808 justin808 merged commit 7c4958c into main Apr 6, 2026
52 checks passed
@justin808 justin808 deleted the fix/tanstack-router-ssr-hydration-mismatch branch April 6, 2026 03:01
justin808 added a commit that referenced this pull request Apr 8, 2026
…e-dedup

* origin/main: (51 commits)
  Fix Content-Length mismatch and null renderingRequest errors in node renderer (#3069)
  Improve memory debugging docs with simpler heap snapshot approach (#3072)
  Enforce strict version matching in doctor and recommend doctor on errors (#3070)
  Remove immediate_hydration feature from codebase (#2834)
  Fix infinite fork loop when node renderer worker fails to bind port (#2881)
  Fix TanStack Router SSR hydration mismatch in Pro async path (#2932)
  Improve node renderer error messages for malformed render requests (#3068)
  Add interactive mode prompt to create-react-on-rails-app (#3063)
  docs: replace hardcoded version numbers with unversioned install commands (#2893)
  Remove Contributing section from docs sidebar (#3064)
  Consolidate docs comparison pages into single evaluation entry (#3065)
  Docs route cleanup: canonicalize worst verbose URL slugs (#3067)
  Add agent summary blocks to high-value docs pages (#3066)
  Fix brittle positional assertions in create-app tests (#2923)
  Auto-resolve renderer password from ENV in Rails Pro (#2921)
  Standardize bundle env vars from =yes to =true (#2925)
  Fix Pro generator multiline and template-literal rewrites (#2918)
  Fix spec/dummy Procfile.dev SERVER_BUNDLE_ONLY to match template convention (#2922)
  Add llms docs entry points (#2916)
  Bump version to 16.6.0.rc.0
  ...

# Conflicts:
#	packages/create-react-on-rails-app/src/create-app.ts
justin808 added a commit that referenced this pull request Apr 8, 2026
…lacement-2835-main

* origin/main: (44 commits)
  Fix Content-Length mismatch and null renderingRequest errors in node renderer (#3069)
  Improve memory debugging docs with simpler heap snapshot approach (#3072)
  Enforce strict version matching in doctor and recommend doctor on errors (#3070)
  Remove immediate_hydration feature from codebase (#2834)
  Fix infinite fork loop when node renderer worker fails to bind port (#2881)
  Fix TanStack Router SSR hydration mismatch in Pro async path (#2932)
  Improve node renderer error messages for malformed render requests (#3068)
  Add interactive mode prompt to create-react-on-rails-app (#3063)
  docs: replace hardcoded version numbers with unversioned install commands (#2893)
  Remove Contributing section from docs sidebar (#3064)
  Consolidate docs comparison pages into single evaluation entry (#3065)
  Docs route cleanup: canonicalize worst verbose URL slugs (#3067)
  Add agent summary blocks to high-value docs pages (#3066)
  Fix brittle positional assertions in create-app tests (#2923)
  Auto-resolve renderer password from ENV in Rails Pro (#2921)
  Standardize bundle env vars from =yes to =true (#2925)
  Fix Pro generator multiline and template-literal rewrites (#2918)
  Fix spec/dummy Procfile.dev SERVER_BUNDLE_ONLY to match template convention (#2922)
  Add llms docs entry points (#2916)
  Bump version to 16.6.0.rc.0
  ...

# Conflicts:
#	CHANGELOG.md
@justin808 justin808 mentioned this pull request Apr 8, 2026
2 tasks
justin808 added a commit that referenced this pull request Apr 8, 2026
### Summary

Adds missing changelog entries to the `[Unreleased]` section for PRs
merged since `v16.6.0.rc.0`. Analyzed 19 commits on `origin/main` since
the last tag, identified 1 missing user-visible PR (#3069), and skipped
10 non-user-visible PRs (docs, tests, internal refactors).

**New entries added:**
- **Added**: `[Pro] Configurable HTTP keep-alive timeout for node
renderer connections` (PR #3069)
- **Fixed**: `[Pro] Fixed SSR failures from stale persistent HTTP/2
connections` (PR #3069)

**Entries already present** (added in prior changelog updates): PRs
#2834, #2881, #2918, #2921, #2923, #2932, #3063, #3068, #3070.

**Skipped** (not user-visible): #2893 (docs), #2916 (docs), #2922 (test
fix), #2923 (test fix), #2925 (internal refactor), #3064 (docs), #3065
(docs), #3066 (docs), #3067 (docs), #3072 (docs).

### Pull Request checklist

- [x] ~Add/update test to cover these changes~
- [x] ~Update documentation~
- [x] Update CHANGELOG file

### Other Information

No code changes — CHANGELOG.md only.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk because this PR only changes documentation (`CHANGELOG.md`)
and does not modify runtime code.
> 
> **Overview**
> Updates `CHANGELOG.md` *[Unreleased]* to include missing Pro release
notes for PR `#3069`, documenting the new
`renderer_http_keep_alive_timeout` config and the associated fix for SSR
failures from stale persistent HTTP/2 connections to the node renderer.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8d1a480. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Interactive mode selection prompt for CLI tool when no explicit mode
is specified
* Configurable keep-alive timeout setting for Pro users (default 30
seconds)

* **Bug Fixes**
  * Enhanced validation and error handling for invalid request payloads
  * Improved template literal handling in code generation
  * Better HTTP connection stability with enhanced diagnostic messaging

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
justin808 added a commit that referenced this pull request Apr 12, 2026
## Summary

Fixes React hydration mismatch when using
`createTanStackRouterRenderFunction` from
`react-on-rails-pro/tanstack-router` with the async SSR path
(`serverRenderTanStackAppAsync`).

### Problem

`RouterClient` (from `@tanstack/react-router/ssr/client`) wraps
`RouterProvider` in `<Await>` which always suspends on first render —
`defer()` starts with status `pending` and resolves on the next
microtask. Since the server renders with `RouterProvider` directly (no
`<Await>` wrapper), the structural mismatch causes:

- `Hydration failed because the initial UI does not match what was
rendered on the server`
- `Failed to execute 'removeChild' on 'Node'`
- `DehydratedFragment` errors that crash ErrorBoundaries

React recovers by discarding the server HTML and re-rendering
client-side, defeating SSR performance benefits.

### Fix

Use `RouterProvider` directly on the client (matching the server tree)
with synchronous route match injection:

- `router.matchRoutes()` + `router.__store.setState()` to inject matches
(same pattern as TanStack Router's own `hydrate()` in
`@tanstack/router-core/ssr/ssr-client.js`)
- `router.ssr = { manifest: undefined }` to prevent Transitioner
auto-load during hydration
- Override match `status` from `pending` to `success` for routes with
loaders/beforeLoad (otherwise `MatchInner` throws `loadPromise` causing
Suspense suspension)
- Clear `router.ssr` in `.finally()` so it clears even on load failure

### Changes

- **`clientHydrate.ts`**: Replace RouterClient with RouterProvider +
synchronous match hydration. RouterClient param kept for backward
compat.
- **`types.ts`**: Add `matchRoutes` and `__store` to `TanStackRouter`
interface.
- **`index.ts`**: Mark `RouterClient` as `@deprecated` in
`TanStackRouterDeps`.
- **`tanstackRouter.test.ts`**: Add regression tests and fix
`buildRouter()` mock.

### Verification

Two regression tests were added to `tanstackRouter.test.ts` that
reproduce the hydration mismatch:

1. **"renders RouterProvider (not RouterClient) on client hydration with
SSR match data"** — Provides both `RouterProvider` and `RouterClient` as
distinct mock components with SSR payload containing `ssrRouter` data.
Asserts `RouterProvider` renders, not `RouterClient`.
2. **"injects SSR match data into router store to prevent Suspense
suspension during hydration"** — Verifies `matchRoutes()` +
`__store.setState()` are called synchronously, transforming pending
matches to success with server-provided loaderData.

Both tests were verified to **fail without the fix** (reverting
`clientHydrate.ts` to select `RouterClient` when `ssrRouter` exists
produces `Received string: "<div
data-testid=\"client\">RouterClient</div>"`) and **pass with the fix**
(all 14 tests green).

### Tested with

- Printivity production app (Rails + React on Rails Pro + TanStack
Router v1.139.0)
- Homepage, CMS pages, authenticated routes, guest-only routes
- 10+ page refreshes with zero hydration errors after fix

## Test plan

- [x] Regression tests fail without fix, pass with fix
- [ ] Existing `TanStackRouterAppAsync` system test passes
- [ ] No hydration errors in browser console on SSR pages
- [ ] Client-side navigation works after hydration
- [ ] Client-only renders (prerender: false) still work (router.ssr not
set without SSR payload)
- [ ] Post-hydration router.load() failure clears router.ssr (no broken
navigation)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* More reliable SSR hydration: client restores server-provided match
data (loader data, statuses), avoids unnecessary suspension/loading, and
prevents initial-render mismatches.

* **Documentation**
* RouterClient marked deprecated; RouterProvider is used consistently
for hydration.

* **Refactor**
* Simplified client hydration flow to apply server payload directly and
manage a temporary SSR flag during hydration.

* **Tests**
* Added regression and behavior tests for match restoration,
nested-route ID handling, and post-hydration state preservation.

* **Public API**
* Legacy optional client parameter retained for compatibility but no
longer used during hydration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Justin Gordon <[email protected]>
justin808 added a commit that referenced this pull request Apr 12, 2026
### Summary

Adds missing changelog entries to the `[Unreleased]` section for PRs
merged since `v16.6.0.rc.0`. Analyzed 19 commits on `origin/main` since
the last tag, identified 1 missing user-visible PR (#3069), and skipped
10 non-user-visible PRs (docs, tests, internal refactors).

**New entries added:**
- **Added**: `[Pro] Configurable HTTP keep-alive timeout for node
renderer connections` (PR #3069)
- **Fixed**: `[Pro] Fixed SSR failures from stale persistent HTTP/2
connections` (PR #3069)

**Entries already present** (added in prior changelog updates): PRs
#2834, #2881, #2918, #2921, #2923, #2932, #3063, #3068, #3070.

**Skipped** (not user-visible): #2893 (docs), #2916 (docs), #2922 (test
fix), #2923 (test fix), #2925 (internal refactor), #3064 (docs), #3065
(docs), #3066 (docs), #3067 (docs), #3072 (docs).

### Pull Request checklist

- [x] ~Add/update test to cover these changes~
- [x] ~Update documentation~
- [x] Update CHANGELOG file

### Other Information

No code changes — CHANGELOG.md only.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk because this PR only changes documentation (`CHANGELOG.md`)
and does not modify runtime code.
> 
> **Overview**
> Updates `CHANGELOG.md` *[Unreleased]* to include missing Pro release
notes for PR `#3069`, documenting the new
`renderer_http_keep_alive_timeout` config and the associated fix for SSR
failures from stale persistent HTTP/2 connections to the node renderer.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8d1a480. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Interactive mode selection prompt for CLI tool when no explicit mode
is specified
* Configurable keep-alive timeout setting for Pro users (default 30
seconds)

* **Bug Fixes**
  * Enhanced validation and error handling for invalid request payloads
  * Improved template literal handling in code generation
  * Better HTTP connection stability with enhanced diagnostic messaging

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
justin808 added a commit that referenced this pull request Apr 12, 2026
## Summary

Fixes React hydration mismatch when using
`createTanStackRouterRenderFunction` from
`react-on-rails-pro/tanstack-router` with the async SSR path
(`serverRenderTanStackAppAsync`).

### Problem

`RouterClient` (from `@tanstack/react-router/ssr/client`) wraps
`RouterProvider` in `<Await>` which always suspends on first render —
`defer()` starts with status `pending` and resolves on the next
microtask. Since the server renders with `RouterProvider` directly (no
`<Await>` wrapper), the structural mismatch causes:

- `Hydration failed because the initial UI does not match what was
rendered on the server`
- `Failed to execute 'removeChild' on 'Node'`
- `DehydratedFragment` errors that crash ErrorBoundaries

React recovers by discarding the server HTML and re-rendering
client-side, defeating SSR performance benefits.

### Fix

Use `RouterProvider` directly on the client (matching the server tree)
with synchronous route match injection:

- `router.matchRoutes()` + `router.__store.setState()` to inject matches
(same pattern as TanStack Router's own `hydrate()` in
`@tanstack/router-core/ssr/ssr-client.js`)
- `router.ssr = { manifest: undefined }` to prevent Transitioner
auto-load during hydration
- Override match `status` from `pending` to `success` for routes with
loaders/beforeLoad (otherwise `MatchInner` throws `loadPromise` causing
Suspense suspension)
- Clear `router.ssr` in `.finally()` so it clears even on load failure

### Changes

- **`clientHydrate.ts`**: Replace RouterClient with RouterProvider +
synchronous match hydration. RouterClient param kept for backward
compat.
- **`types.ts`**: Add `matchRoutes` and `__store` to `TanStackRouter`
interface.
- **`index.ts`**: Mark `RouterClient` as `@deprecated` in
`TanStackRouterDeps`.
- **`tanstackRouter.test.ts`**: Add regression tests and fix
`buildRouter()` mock.

### Verification

Two regression tests were added to `tanstackRouter.test.ts` that
reproduce the hydration mismatch:

1. **"renders RouterProvider (not RouterClient) on client hydration with
SSR match data"** — Provides both `RouterProvider` and `RouterClient` as
distinct mock components with SSR payload containing `ssrRouter` data.
Asserts `RouterProvider` renders, not `RouterClient`.
2. **"injects SSR match data into router store to prevent Suspense
suspension during hydration"** — Verifies `matchRoutes()` +
`__store.setState()` are called synchronously, transforming pending
matches to success with server-provided loaderData.

Both tests were verified to **fail without the fix** (reverting
`clientHydrate.ts` to select `RouterClient` when `ssrRouter` exists
produces `Received string: "<div
data-testid=\"client\">RouterClient</div>"`) and **pass with the fix**
(all 14 tests green).

### Tested with

- Printivity production app (Rails + React on Rails Pro + TanStack
Router v1.139.0)
- Homepage, CMS pages, authenticated routes, guest-only routes
- 10+ page refreshes with zero hydration errors after fix

## Test plan

- [x] Regression tests fail without fix, pass with fix
- [ ] Existing `TanStackRouterAppAsync` system test passes
- [ ] No hydration errors in browser console on SSR pages
- [ ] Client-side navigation works after hydration
- [ ] Client-only renders (prerender: false) still work (router.ssr not
set without SSR payload)
- [ ] Post-hydration router.load() failure clears router.ssr (no broken
navigation)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* More reliable SSR hydration: client restores server-provided match
data (loader data, statuses), avoids unnecessary suspension/loading, and
prevents initial-render mismatches.

* **Documentation**
* RouterClient marked deprecated; RouterProvider is used consistently
for hydration.

* **Refactor**
* Simplified client hydration flow to apply server payload directly and
manage a temporary SSR flag during hydration.

* **Tests**
* Added regression and behavior tests for match restoration,
nested-route ID handling, and post-hydration state preservation.

* **Public API**
* Legacy optional client parameter retained for compatibility but no
longer used during hydration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Co-authored-by: Justin Gordon <[email protected]>
justin808 added a commit that referenced this pull request Apr 12, 2026
### Summary

Adds missing changelog entries to the `[Unreleased]` section for PRs
merged since `v16.6.0.rc.0`. Analyzed 19 commits on `origin/main` since
the last tag, identified 1 missing user-visible PR (#3069), and skipped
10 non-user-visible PRs (docs, tests, internal refactors).

**New entries added:**
- **Added**: `[Pro] Configurable HTTP keep-alive timeout for node
renderer connections` (PR #3069)
- **Fixed**: `[Pro] Fixed SSR failures from stale persistent HTTP/2
connections` (PR #3069)

**Entries already present** (added in prior changelog updates): PRs
#2834, #2881, #2918, #2921, #2923, #2932, #3063, #3068, #3070.

**Skipped** (not user-visible): #2893 (docs), #2916 (docs), #2922 (test
fix), #2923 (test fix), #2925 (internal refactor), #3064 (docs), #3065
(docs), #3066 (docs), #3067 (docs), #3072 (docs).

### Pull Request checklist

- [x] ~Add/update test to cover these changes~
- [x] ~Update documentation~
- [x] Update CHANGELOG file

### Other Information

No code changes — CHANGELOG.md only.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk because this PR only changes documentation (`CHANGELOG.md`)
and does not modify runtime code.
> 
> **Overview**
> Updates `CHANGELOG.md` *[Unreleased]* to include missing Pro release
notes for PR `#3069`, documenting the new
`renderer_http_keep_alive_timeout` config and the associated fix for SSR
failures from stale persistent HTTP/2 connections to the node renderer.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
8d1a480. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Interactive mode selection prompt for CLI tool when no explicit mode
is specified
* Configurable keep-alive timeout setting for Pro users (default 30
seconds)

* **Bug Fixes**
  * Enhanced validation and error handling for invalid request payloads
  * Improved template literal handling in code generation
  * Better HTTP connection stability with enhanced diagnostic messaging

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
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.

2 participants