Fix TanStack Router SSR hydration mismatch in Pro async path#2932
Fix TanStack Router SSR hydration mismatch in Pro async path#2932
Conversation
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]>
ReviewThe fix is well-motivated and the approach — using Issues flagged inline
Not in the diff but worth a follow-up
Reliance on private APIsThe use of Overall the change is solid — the four inline items above are the main blockers. |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughClient hydration for TanStack Router now relies on a dehydrated payload ( Changes
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)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis PR fixes a React hydration mismatch caused by Confidence Score: 5/5Safe 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
Sequence DiagramsequenceDiagram
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
Reviews (1): Last reviewed commit: "Fix TanStack Router SSR hydration mismat..." | Re-trigger Greptile |
size-limit report 📦
|
There was a problem hiding this comment.
💡 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".
- 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]>
Review: Fix TanStack Router SSR hydration mismatchThe core approach here is sound and well-reasoned. Using A few issues worth addressing before merge: Bug:
|
There was a problem hiding this comment.
🧹 Nitpick comments (2)
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts (1)
62-115: Stop shipping the legacyssrRouterpayload if this is now the only hydrator.This code no longer reads
dehydratedState.ssrRouter, butpackages/react-on-rails-pro/src/tanstack-router/serverRender.tsstill serializes it intoclientProps. That keeps sending the old match payload alongsidedehydratedRouter, 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 fromDehydratedRouterState.🤖 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 exportedTanStackRouterinterface.
TanStackRouterOptions.createRouter()requires returning aTanStackRouterthat includesmatchRoutesand__store, even though both are only used behind a runtime guard inclientHydrate.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
📒 Files selected for processing (3)
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.tspackages/react-on-rails-pro/src/tanstack-router/index.tspackages/react-on-rails-pro/src/tanstack-router/types.ts
There was a problem hiding this comment.
💡 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".
|
Address-review run for PR #2932. Mattered
Skipped
No follow-up issue was created. Future full-PR scans should start after this comment unless |
ReviewThe core fix is correct and well-explained: replacing Three items flagged inline. One broader concern worth discussing: SSR payload size: double-serialization of loader data
For apps with large loader payloads this doubles the wire weight. Since Minor: exported SSR wire-format types lack deprecation markers
|
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
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 | 🟠 MajorClear the temporary
router.ssrflag from cleanup too.If
router.load()is still pending when this instance unmounts,cancelled = truesuppresses the.finally()cleanup and leaves the syntheticrouter.ssrvalue 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.0inline. While this matches the declared peerDependency, the runtime check should validate thatrouter.matchRoutes()androuter.__store.setStateexist—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
📒 Files selected for processing (4)
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.tspackages/react-on-rails-pro/src/tanstack-router/serverRender.tspackages/react-on-rails-pro/src/tanstack-router/types.tspackages/react-on-rails-pro/tests/tanstackRouter.test.ts
|
All review feedback has been addressed across commits fa30ceb and 24a6b93: Addressed in code:
Already correct (no change needed):
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]>
Review: Fix TanStack Router SSR hydration mismatchThe 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); |
There was a problem hiding this comment.
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):
| 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); |
There was a problem hiding this comment.
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, | ||
| ); | ||
| }); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 }; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Follow-up on issuecomment-4189911717: Addressed in commit 16a781b:
Skipped for this PR:
|
|
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 Mattered
Skipped
No follow-up issue was created in this action chain. Future full-PR scans should start after this comment unless you explicitly say |
Review: Fix TanStack Router SSR Hydration MismatchThe root cause analysis is correct and well-documented. What's good
ConcernsPrivate API surface is wide and not version-pinned in The implementation depends on four internal TanStack Router APIs ( Silent status override in production When
O(n·m) match lookup in
Minor observations
SummaryThe 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; |
There was a problem hiding this comment.
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:
| 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); |
There was a problem hiding this comment.
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:
| 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( |
There was a problem hiding this comment.
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 => { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
| if (hasDehydratedRouter && typeof router.hydrate === 'function') { | ||
| router.hydrate(dehydratedState.dehydratedRouter); |
There was a problem hiding this comment.
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 👍 / 👎.
…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
…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
### 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]>
## 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]>
### 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]>
## 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]>
### 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]>
Summary
Fixes React hydration mismatch when using
createTanStackRouterRenderFunctionfromreact-on-rails-pro/tanstack-routerwith the async SSR path (serverRenderTanStackAppAsync).Problem
RouterClient(from@tanstack/react-router/ssr/client) wrapsRouterProviderin<Await>which always suspends on first render —defer()starts with statuspendingand resolves on the next microtask. Since the server renders withRouterProviderdirectly (no<Await>wrapper), the structural mismatch causes:Hydration failed because the initial UI does not match what was rendered on the serverFailed to execute 'removeChild' on 'Node'DehydratedFragmenterrors that crash ErrorBoundariesReact recovers by discarding the server HTML and re-rendering client-side, defeating SSR performance benefits.
Fix
Use
RouterProviderdirectly 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 ownhydrate()in@tanstack/router-core/ssr/ssr-client.js)router.ssr = { manifest: undefined }to prevent Transitioner auto-load during hydrationstatusfrompendingtosuccessfor routes with loaders/beforeLoad (otherwiseMatchInnerthrowsloadPromisecausing Suspense suspension)router.ssrin.finally()so it clears even on load failureChanges
clientHydrate.ts: Replace RouterClient with RouterProvider + synchronous match hydration. RouterClient param kept for backward compat.types.ts: AddmatchRoutesand__storetoTanStackRouterinterface.index.ts: MarkRouterClientas@deprecatedinTanStackRouterDeps.tanstackRouter.test.ts: Add regression tests and fixbuildRouter()mock.Verification
Two regression tests were added to
tanstackRouter.test.tsthat reproduce the hydration mismatch:RouterProviderandRouterClientas distinct mock components with SSR payload containingssrRouterdata. AssertsRouterProviderrenders, notRouterClient.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.tsto selectRouterClientwhenssrRouterexists producesReceived string: "<div data-testid=\"client\">RouterClient</div>") and pass with the fix (all 14 tests green).Tested with
Test plan
TanStackRouterAppAsyncsystem test passesSummary by CodeRabbit
Bug Fixes
Documentation
Refactor
Tests
Public API