Skip to content

fix(tanstack-router): remove Suspense gate around RouterProvider during hydration#3213

Open
Seifeldin7 wants to merge 9 commits intomainfrom
claude/port-printivity-patch-DrhDk
Open

fix(tanstack-router): remove Suspense gate around RouterProvider during hydration#3213
Seifeldin7 wants to merge 9 commits intomainfrom
claude/port-printivity-patch-DrhDk

Conversation

@Seifeldin7
Copy link
Copy Markdown
Contributor

@Seifeldin7 Seifeldin7 commented Apr 27, 2026

Summary

TanStackHydrationApp was wrapping RouterProvider in Suspense + RouteChunkPreloadGate, but serverRender.ts's buildAppElement emits AppWrapper > RouterProvider directly 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 RouterProvider directly so the client tree mirrors the server output exactly.

What changed

  • packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
    • Render createElement(RouterProvider, { router }) directly (no Suspense/gate wrapper).
    • Remove now-unused Suspense import, RouteChunkPreloadGate component, RouteChunkPreloadGateProps interface, and routeChunkPreloadSettledRef (only the gate read it).
    • routeChunkPreloadPromiseRef is preserved — runPostHydrationLoad still awaits it before router.load(), so chunk-preload behavior for post-hydration navigation is unchanged.

Test plan

  • CI: unit tests in packages/react-on-rails-pro/tests/tanstackRouter.test.ts pass
  • Lint and type checks pass on the package
  • Manual: SSR a TanStack Router page with code-split routes and confirm no React hydration warnings in the console; the server-rendered HTML stays mounted (no full client re-render flash)
  • Manual: client-side <Link> navigation still works after hydration (verifies post-mount router.load() still runs)

Origin

Originally landed in printivity/printivity as a yarn patch on the published react-on-rails-pro 16.6.0 npm package (see .yarn/patches/react-on-rails-pro-npm-16.6.0-183d3c1ef3.patch in #2843). Porting it upstream so the patch can be removed.

https://claude.ai/code/session_01NzWXywhF3M8h8zU8n8u3qX


Generated by Claude Code

Summary by CodeRabbit

  • Refactor
    • Removed an intermediate Suspense wrapper during client hydration to simplify the render tree.
    • Post-hydration now awaits lazy route preloads before enabling navigation, reducing transient loading glitches and navigation races.
  • Tests
    • Added integration tests verifying deferred navigation until route preload settles and absence of a Suspense wrapper in the hydrated output.

…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.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 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

Removes the Suspense-based route-chunk preload gate and its settled tracking. Hydration now renders RouterProvider (optionally wrapped by options.AppWrapper) directly and awaits routeChunkPreloadPromiseRef in post-mount flow before calling router.load().

Changes

Cohort / File(s) Summary
Router Hydration Simplification
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Removes RouteChunkPreloadGate and surrounding Suspense; deletes routeChunkPreloadSettledRef and its finally() updates; keeps routeChunkPreloadPromiseRef and awaits it in runPostHydrationLoad before router.load(); render tree is RouterProvider (optionally options.AppWrapper) without Suspense.
Hydration Integration Tests
packages/react-on-rails-pro/tests/tanstackRouter.test.ts
Adds two regression tests: one ensures router.load is deferred until matched route chunk preload promise settles (mocks route matching and chunk load), the other verifies hydration output renders RouterProvider directly with no enclosing Suspense markup.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I found the gate unlatched and lightened up my pace,
No Suspense to startle me, just promises in place.
I waited on the quiet hum until the chunks were done,
The router woke and hopped away beneath the sun.
A carrot cheer — the hydrate race is run 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(tanstack-router): remove Suspense gate around RouterProvider during hydration' directly and accurately describes the main change: removing a Suspense-based gate layer (RouteChunkPreloadGate) that wrapped RouterProvider during hydration. It is concise, specific, and matches the primary objective of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/port-printivity-patch-DrhDk

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 27, 2026

Greptile Summary

This PR removes the Suspense + RouteChunkPreloadGate wrapper around RouterProvider in TanStackHydrationApp, making the client render tree match the server-emitted tree (AppWrapper > RouterProvider) and eliminating the React hydration mismatch that caused full client-side re-renders during SSR.

The removal is safe: routeChunkPreloadPromiseRef is preserved and still awaited in runPostHydrationLoad before router.load(), so chunk-preload sequencing for post-hydration navigation remains intact.

Confidence Score: 5/5

Safe 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

Filename Overview
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts Removes Suspense/RouteChunkPreloadGate wrapper around RouterProvider to match server render tree; preserves routeChunkPreloadPromiseRef for post-hydration chunk sequencing. Change is correct and well-justified.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (1): Last reviewed commit: "fix(tanstack-router): remove Suspense ga..." | Re-trigger Greptile

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 27, 2026

Code Review

Overview

This PR removes the Suspense/RouteChunkPreloadGate wrapper from the client-side hydration render path so that the client component tree exactly mirrors what serverRender.ts's buildAppElement() emits: AppWrapper > RouterProvider. The fix is correct and well-motivated — even a Suspense boundary that never suspends (because isHydrating skipped the throw) still produces a different React component tree shape than the server, causing React's hydration reconciler to bail out to a full client re-render.


What's correct

  • Root cause analysis is accurate. React hydration requires the component type tree to match exactly, not just the rendered DOM. A Suspense > RouteChunkPreloadGate > RouterProvider chain on the client vs. a bare RouterProvider on the server is a structural mismatch regardless of whether the gate ever throws.
  • Chunk preload behavior is preserved. routeChunkPreloadPromiseRef is still populated by preloadMatchedRouteChunks() and still awaited in runPostHydrationLoad before router.load() is called (lines 320–325). Lazy route chunks still finish loading before the router enables client-side navigation.
  • Dead code cleanly removed. routeChunkPreloadSettledRef, its finally tracking block, RouteChunkPreloadGate, RouteChunkPreloadGateProps, and the Suspense import are all gone without residue.

Concerns

1. No regression test for the Suspense-shape hydration mismatch (minor)

The existing test suite verifies that RouterProvider is rendered (not RouterClient), but there is no test that would catch reintroduction of a Suspense wrapper around RouterProvider. A hydrateRoot-based test that asserts no hydration-error console output would make this guarantee durable.

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 runPostHydrationLoad awaits chunks before router.load()), but it is a subtle behavioral difference worth acknowledging in a comment or the PR description.

3. Test plan gap

The test plan verifies that existing tests pass but does not include a test for "chunk preloads are awaited before router.load()". Adding a test that holds the preload promise pending and asserts router.load has not been called until it resolves would close this gap.


Verdict

Approve with minor suggestions. The fix is correct, the removed code was doing more harm than good (the isHydrating escape hatch was always a fragile workaround for the structural mismatch), and the simplification is a net positive. The concerns above are documentation and test gaps rather than correctness issues.

// 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 });
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 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);
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 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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.76 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.76 KB (0%)
react-on-rails/client bundled (brotli) 53.86 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.86 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.71 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.71 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.63 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.63 KB (0%)
registerServerComponent/client bundled (gzip) 127.53 KB (0%)
registerServerComponent/client bundled (gzip) (time) 127.53 KB (0%)
registerServerComponent/client bundled (brotli) 61.7 KB (0%)
registerServerComponent/client bundled (brotli) (time) 61.7 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 122.02 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.02 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.73 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.73 KB (0%)

…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.
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 27, 2026

Code Review

Overview

Solid fix for a genuine hydration bug. Removing the Suspense/RouteChunkPreloadGate wrapper is the correct solution — it directly addresses the root cause (client tree diverging from the server tree) rather than trying to paper over it with the isHydrating guard that was already in RouteChunkPreloadGate. The change is minimal, well-commented, and preserves the chunk-preload timing for post-hydration navigation via routeChunkPreloadPromiseRef.


Issues

Missing CHANGELOG entry

Per the repo's changelog guidelines, user-visible bug fixes must be recorded in /CHANGELOG.md. This fixes SSR hydration failures (full client re-render instead of hydration) for any TanStack Router app with code-split routes — clearly user-visible and important enough to call out. Please add an entry under the current unreleased section, e.g.:

- **[Pro]** **TanStack Router hydration fix**: Remove Suspense/RouteChunkPreloadGate wrapper around RouterProvider to prevent React hydration mismatch when using code-split routes. [PR 3213](https://github.com/shakacode/react_on_rails/pull/3213) by [Seifeldin7](https://github.com/Seifeldin7)

Behavioral change lacks dedicated test coverage

The comment at lines 376–380 correctly documents the semantic difference: React can no longer suspend intermediate re-renders during the brief window between when routeChunkPreloadPromiseRef.current is set and when runPostHydrationLoad awaits it. In practice this window is milliseconds and almost certainly harmless, but there is no test locking in "no Suspense thrown from this component during chunk preload". Without one, a future contributor could re-introduce a Suspense wrapper believing it is safe, hit the same hydration bug in production, and have no test failure to guide them.

A straightforward test would: mock loadRouteChunk to return a never-resolving promise, render TanStackHydrationApp via renderToString, and assert the output does not contain a Suspense fallback node (or simply that it does not throw).


Positive notes

  • routeChunkPreloadSettledRef removal is clean: The ref was only ever read by RouteChunkPreloadGate; removing both together is correct.
  • Backward compatibility: The deprecated RouterClient parameter is retained with a one-time warning. Good.
  • The existing test suite is thorough: StrictMode double-invoke, async hydrate rejection, cancellation-after-unmount, user-provided router.ssr preservation — these are exactly the scenarios that matter here.
  • Comment quality: The inline comments explaining why RouterProvider is used directly (and what would break if a Suspense boundary were added) are genuinely useful for future maintainers.

// 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 });
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 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.

Suggested change
let app: ReactElement = createElement(RouterProvider, { router });
let app: ReactElement = createElement(RouterProvider, { router });

Comment on lines +160 to +163
// 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.
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 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);
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 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:

Suggested change
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);

Comment on lines +307 to +310
// 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.
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.

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.

Comment on lines +374 to +383
// 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 });
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 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:

Suggested change
// 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 });

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 27, 2026

Code Review

Overview

This PR correctly fixes a React hydration mismatch in TanStackHydrationApp. The server renders AppWrapper > RouterProvider directly; the client was wrapping that in Suspense > RouteChunkPreloadGate > RouterProvider, producing a Suspense-shape mismatch that caused React to abandon SSR and fall back to a full client render. Removing the extra boundary makes the client tree match the server output exactly.

The core logic is sound:

  • routeChunkPreloadSettledRef is correctly removed — its only consumer was RouteChunkPreloadGate.
  • routeChunkPreloadPromiseRef is correctly preserved and awaited in runPostHydrationLoad before router.load(), so post-hydration navigation still waits for matched lazy chunks to settle.
  • The old RouteChunkPreloadGate suspension only applied to re-renders after hydration and before chunks settled (it was bypassed during the hydration render itself via the isHydrating guard). Since the fallback was null and the window is brief, removing it is a safe trade-off.

Issues

Missing test: chunk preload awaited before router.load()

There is a test verifying that router.options.hydrate is awaited before router.load() (the async hydrate test), but there is no equivalent test for routeChunkPreloadPromiseRef. Given that this is now the sole mechanism ensuring lazy chunks settle before navigation starts, a parallel async test would close the gap:

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 comments

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

Summary

The 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.
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 27, 2026

Code Review

Overview

This PR fixes a React hydration mismatch in TanStackHydrationApp by removing the Suspense + RouteChunkPreloadGate wrapper around RouterProvider. The root cause is exactly right: the server renders AppWrapper > RouterProvider (see serverRender.ts:buildAppElement), but the client was producing AppWrapper > Suspense > RouteChunkPreloadGate > RouterProvider — an extra Suspense node that React can't reconcile against the server HTML, forcing a full client re-render.

Correctness ✅

The fix is correct. Even though RouteChunkPreloadGate was engineered to not throw during the initial hydration render (isHydrating=true short-circuits the throw), the Suspense boundary itself was still present as an extra tree node with no server counterpart. React sees the structural mismatch regardless of whether the boundary activates.

Rendering RouterProvider directly mirrors the server tree exactly.

Behavior change: Suspense fallback → deferred router.load()

The old RouteChunkPreloadGate had a second job beyond hydration: after the post-mount effect set didTriggerPostHydrationLoadRef.current = true, it would re-render-block via Suspense (showing a null fallback) until matched lazy chunks finished loading. That behavior is now replaced by awaiting routeChunkPreloadPromiseRef inside runPostHydrationLoad before calling router.load().

The new approach is strictly better from a UX perspective: instead of flashing a blank screen (null fallback) during the chunk-load window, the server-rendered HTML stays mounted. Navigation is still deferred until chunks are ready because router.load() hasn't been called yet and router.ssr blocks the Transitioner's auto-load.

Removed code

  • RouteChunkPreloadGate + RouteChunkPreloadGateProps — no longer needed.
  • routeChunkPreloadSettledRef — was only read by the gate; gone with it.
  • The finally block that updated routeChunkPreloadSettledRef — note this was also an uncancelled background microtask; the new runPostHydrationLoad path handles cancellation properly via the cancelled flag.

Test coverage

Existing tests cover all the moved pieces well:

  • "renders RouterProvider (not RouterClient) on client hydration with SSR match data" — directly validates the fix.
  • "preloads matched lazy route chunks before the first hydration render" — confirms loadRouteChunk is called for all matched routes.
  • Async lifecycle tests (hydrate callback ordering, cancellation on unmount, StrictMode) remain unchanged and still pass.

One minor gap (see inline comment): there is no async test verifying that router.load() waits for the chunk preload promise to settle before being called. This is the post-hydration analogue of the existing waits for async router.options.hydrate before triggering post-hydration router.load test. Without it, a future accidental reordering in runPostHydrationLoad would go undetected.

Summary

Clean, focused fix with a clear root cause explanation and solid test coverage. The behavioral trade-off (Suspense null-flash → deferred load) is an improvement. The one suggested addition is a test for the chunk-preload await ordering. Approve with that suggestion.

// (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 });
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 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.
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: 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".

Comment on lines +1481 to +1482
lastMatchId: 'products',
matches: [{ i: 'products', l: { products: ['hammer'] }, s: 'success', ssr: true, u: 123 }],
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 Replace embedded NUL byte with escaped match ID

This line embeds a raw NUL byte in the TypeScript source ('\u0000products' was expected), which makes the file behave like a binary file for common text tooling and can cause search/lint/review workflows to skip or mis-handle it. The runtime value is the same if written as an escape sequence, so keeping a literal 0x00 byte here adds avoidable maintenance risk.

Useful? React with 👍 / 👎.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 27, 2026

Code Review

Overview

This PR removes the Suspense + RouteChunkPreloadGate wrapper around RouterProvider in TanStackHydrationApp. The root cause is correctly diagnosed: serverRender.ts's buildAppElement emits AppWrapper > RouterProvider with no Suspense boundary, but the client was rendering Suspense > RouteChunkPreloadGate > RouterProvider. React detects the tree-shape mismatch during hydration and bails to a full client re-render. The fix is exactly right — mirror the server tree by rendering RouterProvider directly.


What's Good

  • Correct diagnosis and minimal fix. The patch removes exactly the code that caused the mismatch and nothing else. The isHydrating guard in the old RouteChunkPreloadGate was already an attempt to paper over this, but having a Suspense node in the tree at all is enough to trigger the mismatch.
  • Chunk-preload behavior preserved. routeChunkPreloadPromiseRef is still set in the render phase and awaited in runPostHydrationLoad before router.load(), so post-hydration navigation still waits for matched lazy chunks.
  • routeChunkPreloadSettledRef correctly removed. That ref only ever served the gate's suspension condition. Nothing else read it.
  • Two focused regression tests. One verifies the timing invariant (chunk preload awaited before router.load()), the other verifies no Suspense wrapper in the rendered HTML.

Issues

Missing CHANGELOG entry

Per the project's changelog-guidelines.md, user-visible bug fixes need an entry under [Unreleased] > #### Fixed in /CHANGELOG.md. This is a Pro-specific fix, so it should be tagged **[Pro]**. Example:

- **[Pro]** **TanStack Router hydration no longer bails to full client re-render**: Removed the `Suspense`/`RouteChunkPreloadGate` wrapper around `RouterProvider` in `TanStackHydrationApp`. The extra boundary produced a tree-shape mismatch against the server-rendered output, causing React to discard SSR HTML and re-render from scratch. [PR 3213](https://github.com/shakacode/react_on_rails/pull/3213) by [Seifeldin7](https://github.com/Seifeldin7).

Fragile microtask-counting in the chunk-preload timing test

await Promise.resolve(); await Promise.resolve(); advances through the async chain by counting microtask hops. If the implementation ever adds or removes a single await (e.g. adding error handling, changing Promise.all shape), the test silently breaks — router.load would not yet have been called when the assertion runs. See inline comment.

Test data typo: leading space in dehydrated match ID

In the renderToString regression test, i: ' products' has a leading space (line 1482). The correct dehydrated form of /products is \0products (per dehydrateSsrMatchId in serverRender.ts, which replaces / with \0). Because ' products' never matches the client match '/products', the SSR data path is never exercised — applyDehydratedMatchData takes the "no server match found" fallback for every match. The test still validates the Suspense-wrapper assertion correctly, but the ssrRouter block is effectively inert, which is misleading. See inline comment.


Minor Observations

  • The renderToString approach for detecting Suspense wrappers is clever and correct for React 18 (<!--$--> / <!--/$--> markers are emitted even for non-suspending Suspense boundaries). Worth noting this may differ in React 19's renderToString behaviour if the supported React range ever expands.
  • The routeChunkPreloadPromiseRef comment added at line 160–162 is appropriately terse and explains the non-obvious invariant.

Comment on lines +1439 to +1442
// One tick to settle the preload promise's .then(() => undefined),
// a second to advance through the awaits in runPostHydrationLoad.
await Promise.resolve();
await Promise.resolve();
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.

Counting microtask hops like this is fragile — if the implementation ever adds or removes one await the assertion below fires before router.load() has been called, silently breaking the test.

A safer pattern is to flush all pending microtasks generically (e.g. await new Promise(setImmediate) or a small jest.runAllTimersAsync() call if you switch to fake timers), or — even better — spy on the resolved promise directly:

Suggested change
// One tick to settle the preload promise's .then(() => undefined),
// a second to advance through the awaits in runPostHydrationLoad.
await Promise.resolve();
await Promise.resolve();
resolveChunk?.();
// Flush all pending microtasks so runPostHydrationLoad can fully drain.
await new Promise((r) => setTimeout(r, 0));

ssrRouter: {
manifest: undefined,
lastMatchId: 'products',
matches: [{ i: 'products', l: { products: ['hammer'] }, s: 'success', ssr: true, u: 123 }],
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.

Looks like a typo — ' products' (leading space) is neither the raw route ID ('/products') nor its dehydrated form ('\0products', which is what dehydrateSsrMatchId produces by replacing / with \0).

Because rehydrateMatchId(' products') returns ' products' unchanged, it never matches the client match id '/products', so applyDehydratedMatchData always takes the "no server match found" → override-to-success fallback. The ssrRouter block in this test is therefore effectively inert and the SSR-data hydration code path is never exercised.

The test still validates the Suspense-wrapper assertion correctly, but it would be clearer to either use the correct dehydrated ID so the SSR data path is exercised, or remove the ssrRouter block entirely to make the fallback path explicit.

Suggested change
matches: [{ i: '
matches: [{ i: '\0products', l: { products: ['hammer'] }, s: 'success', ssr: true, u: 123 }],

- Use escaped Unicode (backslash u 0000) instead of a raw NUL byte in
  the ssrRouter.matches dehydrated match id. Raw NUL bytes make the
  file behave like binary for text tooling and rendered as a leading
  space in GitHub's diff UI, which led one reviewer to mis-read the
  value as a typo. The runtime value is unchanged.

- Replace microtask-counting (await Promise.resolve() x2) in the
  chunk-preload timing test with a setTimeout(0) macrotask boundary
  so all queued microtasks drain before we assert. Counting hops is
  brittle: adding or removing a single await in runPostHydrationLoad
  would silently break the assertion.
@Seifeldin7 Seifeldin7 requested a review from justin808 April 27, 2026 10:28
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 (1)
packages/react-on-rails-pro/tests/tanstackRouter.test.ts (1)

1376-1382: Optional: dangling pending promise on early-failure paths.

loadRouteChunk returns a Promise whose resolve is captured into resolveChunk; if an assertion fails before line 1438, the promise never resolves and runPostHydrationLoad stays pending until the test finishes. Jest will still pass/fail correctly, but you may want a small afterEach that calls resolveChunk?.() (or wrap the whole body in try/finally that resolves it) to avoid noisy unhandled-promise warnings in CI on regressions. Non-blocking nit.

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

In `@packages/react-on-rails-pro/tests/tanstackRouter.test.ts` around lines 1376 -
1382, The test creates a Promise whose resolver is captured in resolveChunk by
the mocked loadRouteChunk and may remain unresolved on early assertion failures,
leaving runPostHydrationLoad pending; ensure the resolver is always invoked on
tear-down by adding an afterEach that calls resolveChunk?.() (or wrap the test
body in try/finally and call resolveChunk?.() in finally) so the pending promise
is resolved and avoids unhandled-promise warnings; refer to resolveChunk,
loadRouteChunk and runPostHydrationLoad when making this change.
🤖 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/tests/tanstackRouter.test.ts`:
- Around line 1376-1382: The test creates a Promise whose resolver is captured
in resolveChunk by the mocked loadRouteChunk and may remain unresolved on early
assertion failures, leaving runPostHydrationLoad pending; ensure the resolver is
always invoked on tear-down by adding an afterEach that calls resolveChunk?.()
(or wrap the test body in try/finally and call resolveChunk?.() in finally) so
the pending promise is resolved and avoids unhandled-promise warnings; refer to
resolveChunk, loadRouteChunk and runPostHydrationLoad when making this change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1490e1ed-6ccc-4f62-9415-47af2780aa47

📥 Commits

Reviewing files that changed from the base of the PR and between 2ee58c9 and 77b1845.

📒 Files selected for processing (1)
  • packages/react-on-rails-pro/tests/tanstackRouter.test.ts

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 27, 2026

Code Review

Overview

This PR fixes a React hydration mismatch in TanStackHydrationApp by removing the Suspense > RouteChunkPreloadGate > RouterProvider render tree in favour of rendering RouterProvider directly. The root cause is correctly identified: serverRender.ts emits AppWrapper > RouterProvider with no Suspense boundary, so any extra boundary on the client produces a shape mismatch and React bails to a full client-side re-render, defeating SSR.

The key insight is also preserved: the route-chunk preload behavior is not lost — runPostHydrationLoad already awaits routeChunkPreloadPromiseRef before calling router.load(), so post-hydration navigation still blocks until matched lazy chunks are ready.

✅ What the PR gets right

  • Correct root-cause fix. The Suspense shape mismatch is eliminated cleanly, and the comment at line 367–370 explains why this is the right approach.
  • No behavior regression on chunk preload. routeChunkPreloadPromiseRef is retained and runPostHydrationLoad awaits it; the gate's only meaningful behavior (blocking router.load() until preload settles) is replicated without the Suspense machinery.
  • Net UX improvement. With the old gate, any external re-render in the window between didTriggerPostHydrationLoadRef.current = true and preload settling would flash a null Suspense fallback. The new code keeps the UI stable in that window.
  • Two targeted regression tests. The structural test (no Suspense markers in renderToString output) and the async sequencing test (gate of router.load() on preload) are exactly the right bets to lock in this fix.

⚠️ Issues / suggestions

1. Missing CHANGELOG entry

There is no entry in CHANGELOG.md under [Unreleased] for this fix. Per project convention, bug fixes in the Pro package should appear there tagged with [Pro]. For example:

#### Fixed
- **[Pro]** Fixed TanStack Router SSR hydration mismatch caused by a stray `Suspense` boundary around `RouterProvider` in `TanStackHydrationApp`. React now hydrates without bailing to a full client-side re-render. [PR 3213](https://github.com/shakacode/react_on_rails/pull/3213) by [Seifeldin7](https://github.com/Seifeldin7)

2. routeChunkPreloadSettledRef tracking block removed but its .finally() was redundant anyway

The removed block:

if (routeChunkPreloadPromiseRef.current) {
  routeChunkPreloadSettledRef.current = false;
  void routeChunkPreloadPromiseRef.current.finally(() => {
    routeChunkPreloadSettledRef.current = true;
  });
}

…was the only writer to routeChunkPreloadSettledRef, and that ref was only ever read inside RouteChunkPreloadGate (which is also gone). The removal is correct — noting this for reviewers who might wonder if the finally() served another purpose.

3. Minor: redundant router.load mock reassignment in new test

buildRouter() already sets load: jest.fn().mockResolvedValue(undefined) at line 33. The reassignment at test line 1388 is a no-op and can be removed for clarity (see inline comment).

4. Minor: redundant optional chaining after throw guard

resolveChunk?.() at test line 1438 uses optional chaining, but the if (!resolveChunk) { throw … } guard at line 1433 already narrows the type to () => void at that point. Either the guard or the ?. is unnecessary (see inline comment).

Test coverage assessment

The two new tests cover the most important invariants:

  • Structural: renderToString output equals exactly <div data-testid="provider"></div> — any Suspense wrapper injects <!--$--> / <!--/$--> markers that break equality.
  • Behavioral: router.load() is not called until after resolveChunk() settles and a macrotask drains — confirms the await ordering.

One gap worth noting but not blocking: neither test uses hydrateRoot (they use createRoot / renderToString). A true hydration test would give stronger confidence that React doesn't emit a reconciliation warning in the real scenario. This is appropriate for a unit-test suite; the manual test plan items cover it.

Verdict

The fix is correct, well-motivated, and well-tested. The two minor test nits are cleanup-level and don't affect correctness. The only meaningful ask is the CHANGELOG entry.

]);
router.looseRoutesById = { '/products': productsRoute };
router.loadRouteChunk = loadRouteChunk;
router.load = jest.fn().mockResolvedValue(undefined);
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.

buildRouter() already sets load: jest.fn().mockResolvedValue(undefined) (line 33 of the same file), so this reassignment is a no-op. You can remove it to reduce noise — or, if the intent is to make the mock explicitly visible at the call site for readability, a brief comment saying so would help.

Suggested change
router.load = jest.fn().mockResolvedValue(undefined);
router.options = {};

}

await compatAct(async () => {
resolveChunk?.();
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 ?. optional chain is redundant here — the if (!resolveChunk) { throw … } guard at line 1433 already narrows the type to () => void, so TypeScript (and any reader) knows it's defined at this point. Using plain call is cleaner and avoids the false impression that it might be undefined.

Suggested change
resolveChunk?.();
resolveChunk();

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: 77b1845944

ℹ️ 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
Add an [Unreleased] > Fixed entry for PR 3213 explaining the
Suspense-shape mismatch that caused full client re-renders during
hydration and noting that chunk-preload sequencing is preserved.
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 27, 2026

Code Review - see inline comments for details. Overall verdict: Approve. The Suspense removal is the correct fix for the hydration mismatch. Chunk-preload sequencing is preserved via routeChunkPreloadPromiseRef awaited in runPostHydrationLoad. Two focused regression tests cover both behaviors. One concern worth a comment: RouteChunkPreloadGate also protected subsequent re-renders (after hydration, before router.load()) from rendering while chunks are in-flight. That protection is now gone. The router.ssr flag mitigates this for navigation, but a parent-triggered re-render in that window could reach RouterProvider before chunks settle. The window is short and risk is low, but worth documenting inline so a future developer does not re-introduce the gate for the wrong reason.

// 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.
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 old RouteChunkPreloadGate also suspended post-hydration re-renders while chunk promises were in-flight (the gate only skipped suspension on the initial hydration render). That protection is intentionally dropped here — the router.ssr flag prevents Transitioner-initiated navigation during this window, and the window itself is very short.

Worth leaving a note in the comment so a future reader does not assume the gate needs to be re-introduced:

Suggested change
// rendering.
// 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.
//
// Note: the old RouteChunkPreloadGate also suspended post-hydration
// re-renders while chunk promises were in-flight. That protection is
// intentionally absent here: router.ssr prevents Transitioner navigation
// during the window, and the window is short (mount effect → router.load()
// completion). Do not re-introduce a Suspense wrapper to restore it.
let app: ReactElement = createElement(RouterProvider, { router });

claude added 2 commits April 27, 2026 10:52
- Remove router.load reassignment in the chunk-preload timing test;
  buildRouter() already sets load to a resolved jest.fn at line 33.
- Replace resolveChunk?.() with a narrowed local const after the
  guard, removing the stale optional-chain that read as 'this might
  be undefined' even though the throw above proves it isn't.
The markdown-link-check workflow currently fails on three pre-existing
links that return 403 due to bot protection:
- https://invite.reactrails.com (Slack invite endpoint, three uses
  in README.md and docs/oss/)
- https://www.110grill.com (Popmenu-powered restaurant referenced as
  a production example in docs/oss/migrating/migrating-from-vite-rails.md)

Both fit the established 'sites that block bots' exclusion pattern
alongside reactrails.slack.com, careersatdoordash.com, etc.
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Apr 27, 2026

Code Review

Overview

This PR fixes a real and significant bug: TanStackHydrationApp was wrapping RouterProvider in Suspense > RouteChunkPreloadGate, but the server-render path (serverRender.ts's buildAppElement) emits AppWrapper > RouterProvider with no Suspense boundary. The shape mismatch caused React to discard the server HTML and re-render entirely on the client, defeating SSR. The fix is correct and minimal.


What works well

  • Root cause is accurate. Confirmed against serverRender.ts: buildAppElement renders createElement(RouterProvider, { router }) directly, optionally wrapped by AppWrapper. The removed Suspense boundary was the only source of mismatch.
  • Chunk-preload sequencing is preserved. routeChunkPreloadPromiseRef is still set during render-phase init and is awaited in runPostHydrationLoad before router.load(). Post-hydration navigation cannot race ahead of matched lazy chunks.
  • Dead code is fully cleaned up. RouteChunkPreloadGate, RouteChunkPreloadGateProps, routeChunkPreloadSettledRef, and the unused Suspense import are all gone with no orphaned references.
  • Error handling is intact. preloadMatchedRouteChunks already catches chunk-load errors internally, so a chunk failure will log and still allow router.load() to proceed rather than hanging the post-hydration flow.
  • Regression tests are well-designed. The renderToString HTML equality assertion is a tight guarantee — Suspense boundaries emit <!--$--> / <!--/$--> markers even when they don't suspend, so this check would catch any accidental re-introduction of a wrapping Suspense.

Points to address

Subtle behavior change worth documenting — With RouteChunkPreloadGate in place, any re-render triggered during the chunk-preload window (e.g. a context value update from a parent) would be suspended and show the null fallback until preloads settled. That gate is now gone: re-renders proceed freely through RouterProvider during that window. In practice this is actually better UX (no spurious loading flash), but it is a behavior change. The existing comment at the render call explains the hydration-mismatch side; a note about the re-render behavior change would help future readers understand why the gate was deliberately not moved to a post-hydration position.

router.options = {} in the new preload test — The assignment silently replaces buildRouter()'s options.hydrate: jest.fn() mock to isolate the chunk-preload await path. The intent is clear from the adjacent comment, but this form of wholesale mutation is easy to misread. A minor improvement: router.options = { hydrate: undefined } makes the isolation explicit rather than replacing the entire options object.

setTimeout(r, 0) macrotask drain — The comment in the test correctly explains why a macrotask boundary is used rather than counting await hops. The approach is reasonable. If runPostHydrationLoad ever gains a genuine async I/O step (not just microtask-depth awaits), setTimeout(0) would still pass spuriously. The current approach is acceptable; just worth watching if the test becomes flaky.


Lychee / CHANGELOG changes

The two new lychee.toml exclusions (invite.reactrails.com, 110grill.com) are unrelated housekeeping; no issues. The CHANGELOG entry is accurate and well-written.


Summary

The fix is correct, the dead-code removal is complete, and the regression tests cover both the ordering invariant and the no-Suspense structural guarantee. The points above are minor. Approving with a suggestion to add a short note about the re-render behavior change alongside the existing comment block near the RouterProvider render call.

// (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 });
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 comment explains the hydration-mismatch side of this decision well. Worth adding one sentence about the re-render behavior change:

Suggested change
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. Note: the former RouteChunkPreloadGate that suspended re-renders
// during chunk preload is intentionally not replicated here — chunk-preload
// sequencing is now enforced by awaiting routeChunkPreloadPromiseRef in
// runPostHydrationLoad before router.load(), so free re-renders during that
// window are safe and avoid the null-fallback flash the gate caused.

router.looseRoutesById = { '/products': productsRoute };
router.loadRouteChunk = loadRouteChunk;
// No user-defined hydrate callback — isolate the chunk-preload await path.
router.options = {};
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 silently replaces the entire options object (including the hydrate: jest.fn() set by buildRouter()). The intent is clear from the comment, but being explicit about what's being suppressed reads better:

Suggested change
router.options = {};
// No user-defined hydrate callback — isolate the chunk-preload await path.
router.options = { hydrate: undefined };

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