fix(tanstack-router): remove Suspense gate around RouterProvider during hydration#3213
fix(tanstack-router): remove Suspense gate around RouterProvider during hydration#3213Seifeldin7 wants to merge 9 commits intomainfrom
Conversation
…ng hydration The RouteChunkPreloadGate wrapped RouterProvider in a Suspense boundary that the server render does not emit. serverRender's buildAppElement returns `AppWrapper > RouterProvider` directly, so the extra boundary on the client produces a Suspense-shape mismatch and React bails to a full client-side re-render. Render RouterProvider directly so the client tree mirrors the server output. The route-chunk preload promise is still awaited in the post-mount effect before router.load(), so navigation behavior is unchanged. Ported from printivity/printivity#2843.
|
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:
WalkthroughRemoves the Suspense-based route-chunk preload gate and its settled tracking. Hydration now renders Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant AppWrapper as AppWrapper/Render
participant RouterProvider as Router
participant ChunkPreloader as RouteChunkPreload
Client->>AppWrapper: render (if provided)
AppWrapper->>Router: render RouterProvider
Note right of Router: no Suspense/gate present
Client->>ChunkPreloader: ensure routeChunkPreloadPromiseRef
Client->>Client: runPostHydrationLoad()
Client->>ChunkPreloader: await routeChunkPreloadPromiseRef
ChunkPreloader-->>Client: preload promise settles
Client->>Router: call router.load()
Router-->>Client: navigation proceeds
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 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 removes the The removal is safe: Confidence Score: 5/5Safe to merge — the fix correctly aligns client and server render trees with no regressions to chunk preloading logic. Single-file change with a clear, well-documented rationale. The server tree shape is confirmed in serverRender.ts (line 22). The removed gate code was the source of the mismatch; the only ref it depended on (routeChunkPreloadSettledRef) is cleanly removed. The remaining routeChunkPreloadPromiseRef is still awaited in the post-hydration effect, preserving the chunk preload sequencing contract. No logic errors or regressions found. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant Server as serverRender.ts
participant Client as clientHydrate.ts
participant React as React Hydration
Note over Server: SSR render
Server->>React: AppWrapper > RouterProvider
Note over Client: Before fix (mismatch)
Client-->>React: AppWrapper > Suspense > RouteChunkPreloadGate > RouterProvider
React--xReact: Tree shape mismatch → full client re-render
Note over Client: After fix (matches server)
Client->>React: AppWrapper > RouterProvider
React->>React: Hydration succeeds
Note over Client: Post-hydration effect
Client->>Client: await routeChunkPreloadPromiseRef (chunks)
Client->>Client: router.load() → client nav enabled
Reviews (1): Last reviewed commit: "fix(tanstack-router): remove Suspense ga..." | Re-trigger Greptile |
Code ReviewOverviewThis PR removes the What's correct
Concerns1. No regression test for the Suspense-shape hydration mismatch (minor) The existing test suite verifies that 2. Lost Suspense-gate semantics for post-hydration re-renders (acknowledged trade-off) The old gate blocked React from committing any re-render triggered between mount and chunk-preload completion. With the gate gone, if something triggers a synchronous re-render in that window, route components could render before their chunks are fully loaded. In practice this window is negligible (the effect fires immediately after mount, and 3. Test plan gap The test plan verifies that existing tests pass but does not include a test for "chunk preloads are awaited before VerdictApprove with minor suggestions. The fix is correct, the removed code was doing more harm than good (the |
| // client tree mirrors what serverRender.ts emits (AppWrapper > RouterProvider). | ||
| // Any extra boundary here produces a Suspense-shape mismatch during hydration | ||
| // and React bails to full client-side rendering. | ||
| let app: ReactElement = createElement(RouterProvider, { router }); |
There was a problem hiding this comment.
This is the right fix. Even with the old isHydrating guard skipping the throw, the Suspense boundary itself was still present in the component tree, so React's reconciler saw Suspense > RouteChunkPreloadGate > RouterProvider on the client vs. RouterProvider on the server — a structural type-mismatch that causes a hydration bail-out regardless of whether the boundary ever suspends.
One thing worth adding to the comment: routeChunkPreloadPromiseRef is still awaited in runPostHydrationLoad (line ~321) before router.load(), so chunk-preload timing for post-hydration navigation is unchanged. The only semantic difference is that React can no longer suspend intermediate re-renders during the preload window — which is acceptable given how brief that window is in practice.
| @@ -184,7 +158,6 @@ function TanStackHydrationApp({ | |||
| const didTriggerPostHydrationLoadRef = useRef(false); | |||
| const didSetSsrFlagRef = useRef(false); | |||
| const routeChunkPreloadPromiseRef = useRef<Promise<void> | null>(null); | |||
There was a problem hiding this comment.
This ref is still used correctly — it is populated at line ~231 and awaited in runPostHydrationLoad before router.load(). Consider a short inline comment here (or a JSDoc on runPostHydrationLoad) clarifying that this is the only remaining consumer, so a future reader doesn't assume it is dead code after the gate removal.
size-limit report 📦
|
…loadPromiseRef Per review feedback, document that the preload promise ref is still consumed by runPostHydrationLoad after the Suspense gate removal, so a future reader does not assume it is dead code.
Code ReviewOverviewSolid fix for a genuine hydration bug. Removing the IssuesMissing CHANGELOG entryPer the repo's changelog guidelines, user-visible bug fixes must be recorded in Behavioral change lacks dedicated test coverageThe comment at lines 376–380 correctly documents the semantic difference: React can no longer suspend intermediate re-renders during the brief window between when A straightforward test would: mock Positive notes
|
| // router.load(), so chunk-preload timing for post-hydration navigation is | ||
| // unchanged. The only semantic difference is that React can no longer suspend | ||
| // intermediate re-renders during the brief preload window. | ||
| let app: ReactElement = createElement(RouterProvider, { router }); |
There was a problem hiding this comment.
This is the correct fix. One follow-up worth considering: there is no test that asserts this path never suspends. If a future change re-adds a Suspense wrapper here (e.g. thinking it only affects post-hydration), the hydration regression will be silent in tests but loud in production. A renderToString-based test that sets loadRouteChunk to a pending promise and verifies the component renders without suspending would lock this in.
| let app: ReactElement = createElement(RouterProvider, { router }); | |
| let app: ReactElement = createElement(RouterProvider, { router }); |
| // Awaited in runPostHydrationLoad before router.load() so post-hydration | ||
| // navigation waits for matched lazy chunks. Since the Suspense/gate wrapper | ||
| // around RouterProvider was removed, runPostHydrationLoad is the only | ||
| // remaining consumer of this ref. |
There was a problem hiding this comment.
The comment is accurate, but it might help to make the write-site vs. read-site split explicit for future readers: this ref is written here (render phase, line ~235) and read only in the runPostHydrationLoad effect (~line 327). Calling it "the only remaining consumer" is precise, but "consumer" could be read as "writer". Something like "Read only in runPostHydrationLoad..." would remove the ambiguity.
…PreloadPromiseRef Per review feedback, distinguish where the ref is written (render-phase SSR block) from where it is read (runPostHydrationLoad effect) so future readers do not conflate the two.
| // waits for matched lazy chunks to settle. Since the Suspense/gate wrapper | ||
| // around RouterProvider was removed, runPostHydrationLoad is the sole | ||
| // read-site for this ref. | ||
| const routeChunkPreloadPromiseRef = useRef<Promise<void> | null>(null); |
There was a problem hiding this comment.
The comment explains the history of what was removed rather than explaining the current invariant. Since future readers won't know what was removed, the backward-looking framing ("since the Suspense/gate wrapper was removed") is confusing. Consider simplifying to just the forward-looking contract:
| const routeChunkPreloadPromiseRef = useRef<Promise<void> | null>(null); | |
| // Set during render-phase SSR init; awaited in runPostHydrationLoad before | |
| // router.load() so post-hydration navigation waits for matched lazy chunks. | |
| const routeChunkPreloadPromiseRef = useRef<Promise<void> | null>(null); |
| // The SSR flag prevented auto-loading, so we do it manually here. | ||
| // This is the sole read-site for routeChunkPreloadPromiseRef: it awaits the | ||
| // preload promise before router.load() so navigation waits for matched | ||
| // lazy chunks to settle. |
There was a problem hiding this comment.
Same backward-looking framing. The "sole read-site" comment is better placed on the ref declaration (above) where a reader naturally looks when tracing where something is read. Suggest dropping these lines — the ref comment and the await call on line 330 already tell the full story without redundancy.
| // Render RouterProvider directly without any Suspense/gate wrapper so the | ||
| // client tree mirrors what serverRender.ts emits (AppWrapper > RouterProvider). | ||
| // Any extra boundary here produces a Suspense-shape mismatch during hydration | ||
| // and React bails to full client-side rendering. | ||
| // | ||
| // RouteChunkPreloadGate blocks re-renders until matched lazy chunks finish | ||
| // preloading (when preload support is available). During the initial SSR | ||
| // 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( | ||
| Suspense, | ||
| { fallback: null }, | ||
| createElement( | ||
| RouteChunkPreloadGate, | ||
| { | ||
| preloadPromise: routeChunkPreloadPromiseRef.current, | ||
| preloadSettledRef: routeChunkPreloadSettledRef, | ||
| isHydrating: hasSsrPayload && !didTriggerPostHydrationLoadRef.current, | ||
| }, | ||
| createElement(RouterProvider, { router }), | ||
| ), | ||
| ); | ||
| // routeChunkPreloadPromiseRef is still awaited in runPostHydrationLoad before | ||
| // router.load(), so chunk-preload timing for post-hydration navigation is | ||
| // unchanged. The only semantic difference is that React can no longer suspend | ||
| // intermediate re-renders during the brief preload window. | ||
| let app: ReactElement = createElement(RouterProvider, { router }); |
There was a problem hiding this comment.
The last two sentences of this block explain the old behavior that was removed, which is fine for the PR description but adds noise to the code. The first two sentences are the essential invariant readers need. The routeChunkPreloadPromiseRef paragraph is worth keeping only if the comment is colocated with the await on line 330 — here it's two screens away. Suggest trimming to:
| // Render RouterProvider directly without any Suspense/gate wrapper so the | |
| // client tree mirrors what serverRender.ts emits (AppWrapper > RouterProvider). | |
| // Any extra boundary here produces a Suspense-shape mismatch during hydration | |
| // and React bails to full client-side rendering. | |
| // | |
| // RouteChunkPreloadGate blocks re-renders until matched lazy chunks finish | |
| // preloading (when preload support is available). During the initial SSR | |
| // 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( | |
| Suspense, | |
| { fallback: null }, | |
| createElement( | |
| RouteChunkPreloadGate, | |
| { | |
| preloadPromise: routeChunkPreloadPromiseRef.current, | |
| preloadSettledRef: routeChunkPreloadSettledRef, | |
| isHydrating: hasSsrPayload && !didTriggerPostHydrationLoadRef.current, | |
| }, | |
| createElement(RouterProvider, { router }), | |
| ), | |
| ); | |
| // routeChunkPreloadPromiseRef is still awaited in runPostHydrationLoad before | |
| // router.load(), so chunk-preload timing for post-hydration navigation is | |
| // unchanged. The only semantic difference is that React can no longer suspend | |
| // intermediate re-renders during the brief preload window. | |
| let app: ReactElement = createElement(RouterProvider, { router }); | |
| // Render RouterProvider directly — matching the server-rendered tree (AppWrapper > RouterProvider). | |
| // Any extra Suspense boundary here produces a shape mismatch during hydration | |
| // and React bails to full client-side rendering. | |
| let app: ReactElement = createElement(RouterProvider, { router }); |
Code ReviewOverviewThis PR correctly fixes a React hydration mismatch in The core logic is sound:
IssuesMissing test: chunk preload awaited before There is a test verifying that it('waits for chunk preload promise before triggering post-hydration router.load', async () => {
const router = buildRouter();
let resolveChunkLoad: (() => void) | undefined;
router.loadRouteChunk = jest.fn().mockReturnValue(
new Promise<void>((resolve) => { resolveChunkLoad = resolve; }),
);
router.looseRoutesById = { '/products': { id: '/products' } };
router.load = jest.fn().mockResolvedValue(undefined);
// ... render, assert router.load not called, resolve, assert router.load called once
});Inline commentsThree inline notes on the new comments added — they're written in a backward-looking style (explaining what was removed) that reads awkwardly once the PR context is gone. Suggestions in the thread to trim them to the forward-looking contract only. SummaryThe fix is correct and addresses a real SSR regression. The two items above are non-blocking but worth addressing before merge: adding the missing async test and tidying the comments. |
…ariants Per review feedback, drop backward-looking phrasing about the removed Suspense wrapper. Comments should explain current behavior, not historical context that PR descriptions cover. - routeChunkPreloadPromiseRef: state the contract concisely (set during init, awaited before router.load()). - useEffect comment: drop the redundant "sole read-site" sentence; the ref declaration covers it. - JSX comment: trim to the essential invariant — RouterProvider must render directly to match the server tree.
Code ReviewOverviewThis PR fixes a React hydration mismatch in Correctness ✅The fix is correct. Even though Rendering Behavior change: Suspense fallback → deferred
|
| // (AppWrapper > RouterProvider). Any extra Suspense boundary here produces | ||
| // a shape mismatch during hydration and React bails to full client-side | ||
| // rendering. | ||
| let app: ReactElement = createElement(RouterProvider, { router }); |
There was a problem hiding this comment.
This is the right fix. Worth noting the behavioral shift: the old RouteChunkPreloadGate would re-render-block via Suspense (null fallback) after hydration if lazy chunks were still loading. With that gone, the deferred await routeChunkPreloadPromiseRef.current inside runPostHydrationLoad is now the only thing preventing router.load() from firing early. The test suite covers that loadRouteChunk is called, but there's no async test verifying the ordering (router.load is not called until the preload promise settles). A test similar to waits for async router.options.hydrate before triggering post-hydration router.load but for the chunk-preload path would lock in that invariant against future refactors.
…tion Two regression tests for the Suspense-gate removal: - "renders RouterProvider directly without an enclosing Suspense boundary during hydration" fails against the pre-fix code with <!--$-->/<!--/$--> markers in the rendered HTML, locking in that TanStackHydrationApp emits RouterProvider with no wrapping boundary (matching serverRender.ts's buildAppElement output). - "waits for the matched route chunk preload promise before triggering post-hydration router.load" pins the explicitly preserved invariant from the PR description: routeChunkPreloadPromiseRef is awaited in runPostHydrationLoad before router.load(), so post-hydration navigation does not race ahead of matched lazy chunks.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c835ce72ea
ℹ️ 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".
| lastMatchId: ' |
Summary
TanStackHydrationAppwas wrappingRouterProviderinSuspense+RouteChunkPreloadGate, butserverRender.ts'sbuildAppElementemitsAppWrapper > RouterProviderdirectly with no Suspense boundary. The extra client-side boundary produces a Suspense-shape mismatch during hydration and React bails to a full client-side re-render — defeating SSR.This change renders
RouterProviderdirectly so the client tree mirrors the server output exactly.What changed
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.tscreateElement(RouterProvider, { router })directly (noSuspense/gate wrapper).Suspenseimport,RouteChunkPreloadGatecomponent,RouteChunkPreloadGatePropsinterface, androuteChunkPreloadSettledRef(only the gate read it).routeChunkPreloadPromiseRefis preserved —runPostHydrationLoadstill awaits it beforerouter.load(), so chunk-preload behavior for post-hydration navigation is unchanged.Test plan
packages/react-on-rails-pro/tests/tanstackRouter.test.tspass<Link>navigation still works after hydration (verifies post-mountrouter.load()still runs)Origin
Originally landed in
printivity/printivityas a yarn patch on the publishedreact-on-rails-pro16.6.0 npm package (see.yarn/patches/react-on-rails-pro-npm-16.6.0-183d3c1ef3.patchin #2843). Porting it upstream so the patch can be removed.https://claude.ai/code/session_01NzWXywhF3M8h8zU8n8u3qX
Generated by Claude Code
Summary by CodeRabbit