Skip to content

fix(tanstack-router): dedupe loadRouteChunk under React 18 StrictMode (#3405)#3410

Merged
justin808 merged 2 commits into
mainfrom
jg/3405-tanstack-strictmode-dedup
May 26, 2026
Merged

fix(tanstack-router): dedupe loadRouteChunk under React 18 StrictMode (#3405)#3410
justin808 merged 2 commits into
mainfrom
jg/3405-tanstack-strictmode-dedup

Conversation

@justin808

@justin808 justin808 commented May 25, 2026

Copy link
Copy Markdown
Member

Summary

  • Fixes the tanstack-router integration (Pro) › does not double-call loadRouteChunk when StrictMode replays hydration effects test that has been failing on every push to main since PR #3213.
  • Adds a module-level WeakMap<TanStackRouter, SharedHydrationInitState> in clientHydrate.ts that memoizes the render-phase init per router instance, dedup'ing loadRouteChunk, __store.setState, and the user-defined hydrate callback across React 18 StrictMode's double-render-with-fresh-hooks behavior.
  • Production is unaffected: each mount calls options.createRouter() and gets a fresh router, so the WeakMap always misses and the existing init runs.

Fixes #3405.

Root cause

React 18's StrictMode renders components twice in dev with separate hook state per pass (verified empirically against react@18.3.1):

React 18:                              React 19:
Render 1, ref.current === null: true   Render 1, ref.current === null: true
Render 2, ref.current === null: true   Render 2, ref.current === null: false
refSetCount: 2                          refSetCount: 1

The routerRef.current === null guard relied on React 19's behavior of preserving useRef across the two passes, so on React 18 it fired twice when the user (or test) returned the same router from options.createRouter.

The previous "Safety invariant" comment at clientHydrate.ts:186-196 accounted for discarded renders, where the discarded router instance is dropped. That's still correct — but it didn't cover the case where the same router instance survives across passes (cached by the user's factory) and the discarded render's side effects on it leak.

Fix

sharedHydrationInitStates: WeakMap<router, {routeChunkPreloadPromise, hydrationCallbackPromise, didSetSsrFlag}> is checked at the top of the init block. On a cache hit:

  • Reattach the cached promises to this mount's routeChunkPreloadPromiseRef / hydrationCallbackPromiseRef so the post-hydration effect still awaits the original work before calling router.load() (otherwise router.load would race the in-flight preload).
  • Restore didSetSsrFlagRef so the cleanup path correctly clears router.ssr.
  • Skip router.update({ history }), __store.setState, router.ssr writes, and the user-defined hydrate callback.

Per-router-instance memoization means:

  • Production: every mount creates a new router → WeakMap miss → full init. Identical to today.
  • Tests / cached routers / StrictMode: same router across mounts → WeakMap hit → init runs exactly once.

Test plan

  • Reproduced the bug on React 18.0.0 locally via script/convert + pnpm install --no-frozen-lockfile, confirmed Expected: 1, Received: 2 before the fix.
  • All 29 tanstackRouter.test.ts tests pass on React 18.0.0 (the minimum-supported Node 20 CI matrix).
  • All 70 Pro JS tests (63 non-RSC + 7 RSC) pass on React 19.2.6 (default).
  • pnpm --filter react-on-rails-pro type-check clean.
  • pnpm lint clean.
  • No changes outside packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts and CHANGELOG.md.

What's still in scope to watch

  • The existing initializes hydration store injection once per created router in StrictMode test creates a new router per createRouter call — that's still a WeakMap miss for each, so each router's setState fires once. Verified passing on both React 18 and 19.
  • The waits for the matched route chunk preload promise before triggering post-hydration router.load test (single mount, no StrictMode) still fires the preload exactly once. Verified.

Note

Low Risk
Scoped to Pro client hydration in dev/StrictMode and cached-router scenarios; production paths with fresh routers per mount are unchanged.

Overview
Fixes Pro TanStack Router client hydration under React 18 StrictMode when createRouter returns a reused router instance (common in tests or user caching).

Render-phase hydration in clientHydrate.ts still runs only when routerRef.current === null, but StrictMode resets refs between passes, so that guard could run twice and repeat loadRouteChunk, __store.setState, and options.hydrate. A module-level WeakMap keyed by the router instance now runs that init once per router; cache hits only reattach preload/hydrate promises and didSetSsrFlag for the post-hydration effect and cleanup. router.ssr clearing updates the WeakMap so a full remount of the same router does not trigger a false dev warning.

Production behavior is unchanged when each mount gets a new router. CHANGELOG documents the fix for issue #3405; a regression test covers remount of a cached router.

Reviewed by Cursor Bugbot for commit 67a22f8. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed TanStack Router hydration under React 18 StrictMode to stop duplicate data loads and repeated user hydrate callbacks [Pro].
    • Prevented spurious developer warnings when fully unmounting and remounting the same router instance in dev StrictMode.
  • Tests

    • Added a regression test ensuring unmount/remount of the same router instance does not emit the dev warning.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ba83920e-dea3-48fe-a0f6-2321232b7da0

📥 Commits

Reviewing files that changed from the base of the PR and between cd0577a and 67a22f8.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
  • packages/react-on-rails-pro/tests/tanstackRouter.test.ts
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md

Walkthrough

This PR prevents React 18 StrictMode remounts from re-running TanStack Router render-phase hydration work by caching per-router preload and hydration promises plus SSR-flag ownership in a module-level WeakMap and reusing them on subsequent mounts.

Changes

StrictMode Hydration Deduplication

Layer / File(s) Summary
Shared hydration state structure
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
Introduces SharedHydrationInitState and a module-level WeakMap keyed by router instances to cache the matched route preload promise, optional user hydrate promise, and whether this module set router.ssr.
Render-phase deduplication logic
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts, CHANGELOG.md
Render-phase initialization checks the cache: if present, it reattaches cached promises and flag; otherwise it performs SSR match injection, optional options.hydrate/router.hydrate, sets router.ssr when needed, and stores the produced artifacts. Changelog documents the fix.
Cache sync when SSR flag is cleared
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
When post-hydration effect or unmount cleanup clears router.ssr, the cached didSetSsrFlag is also set to false to avoid restoring a stale latch on later mounts.
Regression test: cached router remount
packages/react-on-rails-pro/tests/tanstackRouter.test.ts
Adds a test that mounts, unmounts, and remounts the same router in development and asserts no dev warning about router.ssr being unexpectedly cleared on remount.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 When StrictMode hops and hops again,
I tuck the preload in a tiny den;
No double calls, no noisy fright,
One promise kept through mount and plight.
A little hop, the fix takes flight.

🚥 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 clearly summarizes the main change: fixing double-call of loadRouteChunk under React 18 StrictMode.
Linked Issues check ✅ Passed The PR successfully addresses all objectives from issue #3405: dedupes loadRouteChunk calls via WeakMap caching, prevents duplicate side effects on remount, preserves production behavior, adds regression test, and includes CHANGELOG entry.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the StrictMode double-call issue: clientHydrate.ts hydration deduplication logic, regression test, and CHANGELOG entry. No unrelated modifications detected.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jg/3405-tanstack-strictmode-dedup

Warning

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

🔧 ESLint

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

packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

packages/react-on-rails-pro/tests/tanstackRouter.test.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.


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.

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

Copy link
Copy Markdown

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: 8b588414b2

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
@justin808

Copy link
Copy Markdown
Member Author

/run-skipped-ci

@greptile-apps

greptile-apps Bot commented May 25, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a React 18 StrictMode bug where loadRouteChunk, __store.setState, and the user-defined hydrate callback were called twice when options.createRouter returned the same router instance, because React 18 resets useRef state on each render pass — making the routerRef.current === null guard fire on both passes.

  • Adds a module-level WeakMap<TanStackRouter, SharedHydrationInitState> that memoizes the render-phase init per router instance; a WeakMap hit reattaches cached promises to the new mount's refs and restores didSetSsrFlag without re-running any side effects.
  • Production behavior is unchanged: each mount creates a fresh router via options.createRouter(), so the WeakMap always misses and the full init path runs as before.
  • The fix is scoped to clientHydrate.ts; a CHANGELOG entry is included.

Confidence Score: 4/5

Safe to merge; the WeakMap deduplication correctly targets the React 18 StrictMode double-render and leaves the production (fresh-router) path untouched.

The core fix is well-reasoned and the test matrix covers both React 18 and 19. The one edge case worth watching is that the WeakMap entry stores didSetSsrFlag: true at init time and is never updated when router.ssr is later cleared by the post-hydration effect. A developer who caches a router and fully unmounts then remounts the component will see a spurious dev warning claiming router.ssr was unexpectedly cleared, even though the prior mount cleared it correctly. This does not affect runtime correctness but could create confusing noise in tests that reuse a router across separate renders.

Only clientHydrate.ts is substantively changed; the WeakMap entry lifetime and the didSetSsrFlag staleness edge case are worth a second read, specifically around lines 219–334 and the dev sanity check at line 361.

Important Files Changed

Filename Overview
packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts Adds a module-level WeakMap to deduplicate render-phase SSR init across React 18 StrictMode's fresh-hook double-render; logic is sound for the StrictMode target but cached didSetSsrFlag: true can produce a spurious dev warning on full-remount with the same router instance.
CHANGELOG.md Changelog entry added for the StrictMode loadRouteChunk deduplication fix; entry is accurate and follows existing format.

Sequence Diagram

sequenceDiagram
    participant R18 as React 18 StrictMode
    participant Comp as TanStackHydrationApp
    participant WM as WeakMap (sharedHydrationInitStates)
    participant Router as TanStackRouter

    Note over R18,Router: Render pass 1 (fresh hooks, routerRef.current = null)
    R18->>Comp: render (pass 1)
    Comp->>Router: options.createRouter()
    Comp->>WM: get(router) → miss
    Comp->>Router: "router.update({ history })"
    Comp->>Router: __store.setState(matches)
    Comp->>Router: "router.ssr = { manifest: undefined }"
    Comp->>WM: "set(router, { preloadPromise, hydrationPromise, didSetSsrFlag: true })"

    Note over R18,Router: Render pass 2 (fresh hooks, routerRef.current = null again)
    R18->>Comp: render (pass 2)
    Comp->>Router: options.createRouter() → same instance
    Comp->>WM: get(router) → HIT
    Comp-->>Comp: restore refs from cache (skip re-init)

    Note over R18,Router: Mount — effects fire once
    R18->>Comp: useEffect fires
    Comp->>Router: await routeChunkPreloadPromise
    Comp->>Router: router.load()
    Comp->>Router: "router.ssr = undefined (clear flag)"

    Note over R18,Router: StrictMode simulated unmount/remount
    R18->>Comp: "cleanup (cancelled=true, didTriggerPostHydrationLoad=false)"
    R18->>Comp: re-render (routerRef.current preserved → render-phase init skipped)
    R18->>Comp: useEffect fires again
    Comp->>Router: router.load() (second call)
Loading

Reviews (1): Last reviewed commit: "fix(tanstack-router): dedupe render-phas..." | Re-trigger Greptile

Comment on lines +329 to +333
sharedHydrationInitStates.set(router, {
routeChunkPreloadPromise: routeChunkPreloadPromiseRef.current,
hydrationCallbackPromise: hydrationCallbackPromiseRef.current,
didSetSsrFlag: didSetSsrFlagRef.current,
});

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 Stale didSetSsrFlag in WeakMap after first successful mount

The WeakMap entry persists with didSetSsrFlag: true after the first mount's finally block clears router.ssr and sets didSetSsrFlagRef.current = false. In a "cached router, full unmount → remount" scenario (e.g. test suites that cache a router and re-render after cleanup), a second mount hits the WeakMap, restores didSetSsrFlagRef.current = true, then fires the dev sanity check at line 361: didSetSsrFlagRef.current && router.ssr == null — triggering the "unexpectedly cleared" warning even though router.ssr was correctly cleared by the previous mount's effect, not by a TanStack Router version change.

A minimal mitigation would be to delete the WeakMap entry in the effect's cleanup when the mount fully tears down, or to cap the cached didSetSsrFlag at the actual current value of router.ssr at hit time rather than at write time.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts`:
- Around line 217-333: The cache reuse logic on sharedHydrationInitStates is
only keyed by router and can return stale init state when the SSR payload
changes; compute a small payload discriminator from the current hydration
payload (e.g., from dehydratedState or dehydratedState.ssrRouter identifiers)
and compare it against a discriminator stored on the cached value in
sharedHydrationInitStates; if they differ (or if hasSsrPayload changed), treat
it as a cache miss (do not reuse cachedInit), proceed with router.update(),
match injection, __store.setState and user hydrate() as normal, and when storing
the init into sharedHydrationInitStates include the payload discriminator so
future mounts only reuse the cached init for the same (router, payload) pair.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7ecc7634-2532-455a-9ca6-ae848a563eb9

📥 Commits

Reviewing files that changed from the base of the PR and between 23b0c46 and 8b58841.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts

Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
@justin808

Copy link
Copy Markdown
Member Author

Thanks @greptile-apps — good catch on the stale didSetSsrFlag. Addressed in cd0577a:

  • Mutate cached.didSetSsrFlag = false alongside both router.ssr clear paths (post-hydration effect's finally and the cleanup's deferred microtask), so the shared cache stays in sync with the ref.
  • Added regression test does not warn about cleared router.ssr after full remount of a cached router. Verified it correctly fails on the previous commit (stale-cache path triggers the unexpected-cleared warning) and passes with the sync fix. All 30 tanstackRouter.test.ts tests pass on both React 18.0.0 and React 19.2.6.

@justin808

Copy link
Copy Markdown
Member Author

/run-skipped-ci

@greptile-apps

greptile-apps Bot commented May 25, 2026

Copy link
Copy Markdown

The fix looks correct. The two cached.didSetSsrFlag = false mutations are in exactly the right places:

  1. finally block (post-hydration load settled)
  2. Cleanup deferred microtask (unmount before settle)

Both paths already set didSetSsrFlagRef.current = false and now also mutate the shared cache, keeping the WeakMap entry in sync. The if (cached) guard is appropriate since client-only renders (no SSR payload) never call sharedHydrationInitStates.set.

One minor observation worth confirming: after cached.didSetSsrFlag = false executes, any subsequent run of the same finally/cleanup path will hit didSetSsrFlagRef.current === false and skip the block entirely — so there's no double-clear risk. The logic is sound.

The regression test (mount → unmount → remount of the same cached router, asserting no unexpected-cleared warning) directly exercises the previously broken path. LGTM.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Full CI Mode Enabled

Triggered all workflows for full CI coverage

Note: Added the full-ci label to this PR. All future commits will run the full CI suite (including minimum dependency tests) until the label is removed.

To disable full CI mode, use the /stop-run-skipped-ci command.

View progress in the Actions tab.

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

Copy link
Copy Markdown

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: cd0577ac98

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
justin808 added 2 commits May 25, 2026 14:12
…Mode mounts

React 18's StrictMode double-renders components with fresh hook state on each
pass, so the `routerRef.current === null` guard in `clientHydrate.ts` fired
twice when `options.createRouter` returned the same router instance, re-running
`loadRouteChunk`, `__store.setState`, and the user-defined `hydrate` callback.

Add a module-level `WeakMap<TanStackRouter, SharedHydrationInitState>` that
memoizes the render-phase init per router instance. On the second render the
cached preload/hydrate promises and `didSetSsrFlag` are reattached to the
current mount's refs so the post-hydration effect still awaits the original
work and cleanup correctly clears `router.ssr`. Production behavior is
unchanged because each mount creates a fresh router (WeakMap miss → full init).

Fixes #3405.
Addresses Greptile's review: after the post-hydration effect cleared
`router.ssr` and set `didSetSsrFlagRef.current = false`, the cached
`didSetSsrFlag: true` in `sharedHydrationInitStates` went stale. A
subsequent full mount/remount of the same router would restore the stale
true and trigger the dev sanity-check warning at clientHydrate.ts:361
on a router whose ssr flag had been cleared correctly.

Mutate `cached.didSetSsrFlag = false` alongside both clear paths (effect
finally + cleanup deferred microtask) so the shared cache always
reflects the current `didSetSsrFlag` state.

Adds a regression test ("does not warn about cleared router.ssr after
full remount of a cached router") that mounts → unmounts → remounts the
same router and asserts the unexpected-cleared warning is not logged.
@justin808 justin808 force-pushed the jg/3405-tanstack-strictmode-dedup branch from cd0577a to 67a22f8 Compare May 26, 2026 00:12

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

Copy link
Copy Markdown

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: 67a22f807f

ℹ️ About Codex in GitHub

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

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

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

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

Comment on lines +219 to +226
if (cachedInit) {
// Same router instance was already initialized by a discarded render
// (or prior mount). Reattach the pending preload/hydrate promises to
// this mount's refs so the post-hydration effect awaits the original
// work; restore didSetSsrFlag so cleanup correctly clears router.ssr.
routeChunkPreloadPromiseRef.current = cachedInit.routeChunkPreloadPromise;
hydrationCallbackPromiseRef.current = cachedInit.hydrationCallbackPromise;
didSetSsrFlagRef.current = cachedInit.didSetSsrFlag;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reapply temporary router.ssr flag on cached remounts

When sharedHydrationInitStates hits, this branch only restores cached refs and skips the SSR-flag setup path, so a real unmount/remount that reuses the same router can start a hydration mount with router.ssr already cleared. In this file’s own contract, that flag is what prevents Transitioner from auto-calling router.load() before post-hydration sequencing; skipping it on remount allows an early load (and then another from runPostHydrationLoad), which can race hydration state/chunk preloads and reintroduce hydration mismatch behavior for cached-router setups.

Useful? React with 👍 / 👎.

@claude

claude Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

Code Review

Overview

This PR fixes a React 18 StrictMode bug where loadRouteChunk, __store.setState, and the user-defined hydrate callback were being called twice when options.createRouter returned a cached/shared router instance. The root cause is React 18's fresh-hooks-per-pass behavior (unlike React 19, which preserves refs across passes), making the existing routerRef.current === null guard insufficient on its own.

The fix adds a module-level WeakMap<TanStackRouter, SharedHydrationInitState> to memoize render-phase init per router instance.


Correctness ✅

The fix is correct. Key aspects that hold up under scrutiny:

  • Production path unchanged: options.createRouter() returns a fresh instance on each mount in production → WeakMap miss → full init. No behavioral change.
  • Error handling: The sharedHydrationInitStates.set() call sits outside the try/catch block. If options.hydrate throws, the WeakMap is never populated, so the next render gets a clean retry with the catch block already having cleared router.ssr. Intentional and correct.
  • WeakMap mutation pattern: cached.didSetSsrFlag = false correctly mutates the stored object in-place (no need for .set() again). This keeps the cache consistent across both cleanup paths (the post-hydration finally and the unmount deferred continuation).
  • Memory: WeakMap keys are weak — when the router instance is GCed, the entry disappears. No memory leak.

Design Trade-off Worth Documenting

The WeakMap entry is never deleted after a full unmount/remount cycle. This means that on a genuine remount of the same router (non-StrictMode), the else branch is skipped entirely:

  • createBrowserHistory() is not called again
  • hydrationRouter.__store.setState() is not called again
  • options.hydrate callback is not called again

This is safe in practice because the router already carries the browser history and injected match state from the first mount. But it is an implicit behavior: a developer who expects a full remount to re-run hydration init against the same router instance will be surprised. The safety invariant comment at line 205 could call this out explicitly as an intentional consequence of the WeakMap approach.


Test Coverage ✅

The new regression test (does not warn about cleared router.ssr after full remount of a cached router) correctly exercises the stale-cache scenario and validates the exact dev warning path it guards against. The previously failing StrictMode double-render test is also confirmed passing.

Minor style note: the await new Promise((r) => { setTimeout(r, 0); }) pattern appears twice in the new test — a named helper like flushMacrotask() would reduce repetition, but this is a cosmetic point.


CHANGELOG

The entry is accurate but noticeably longer than surrounding entries. A shorter version would be more consistent with the file's style:

[Pro] Fixed TanStack Router client hydration under React 18 StrictMode: when options.createRouter returned the same router instance, the render-phase guard fired twice and double-called loadRouteChunk, __store.setState, and options.hydrate. Render-phase init is now memoized per router instance via a module-level WeakMap; production behavior (fresh router per mount) is unchanged. Fixes [Issue 3405]. [PR 3410] by [justin808].


Summary

Approve with minor notes. The fix is minimal, targeted, and correct. The WeakMap approach is the right tool — it naturally avoids memory leaks and leaves production paths untouched. The test coverage adequately guards the specific regression. The full-remount implicit behavior and the verbose changelog entry are worth a quick look, but neither blocks merging.

// work; restore didSetSsrFlag so cleanup correctly clears router.ssr.
routeChunkPreloadPromiseRef.current = cachedInit.routeChunkPreloadPromise;
hydrationCallbackPromiseRef.current = cachedInit.hydrationCallbackPromise;
didSetSsrFlagRef.current = cachedInit.didSetSsrFlag;

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 says "discarded render (or prior mount)" which covers StrictMode, but it's worth making explicit that this path also fires on a genuine full unmount → remount of the same router instance (non-StrictMode). On that path createBrowserHistory(), __store.setState(), and options.hydrate are all skipped because they already ran during the first mount. That's safe — the router already carries the correct state — but it's a non-obvious invariant. A short note like:

// Also fires on full unmount → remount of the same router (non-StrictMode).
// Safe to skip re-init because the router already has the browser history
// and injected match state from the first mount.

would help future readers who may only see the StrictMode framing.

routeChunkPreloadPromise: routeChunkPreloadPromiseRef.current,
hydrationCallbackPromise: hydrationCallbackPromiseRef.current,
didSetSsrFlag: didSetSsrFlagRef.current,
});

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 set() call being outside (after) the try/catch block is intentionally correct: if options.hydrate or router.hydrate() throws, the catch block clears router.ssr, re-throws, and the WeakMap is never populated. The next render (StrictMode's second pass or a React retry) therefore gets a WeakMap miss and runs a full, clean init rather than reattaching state from a partially-failed mount. Worth a one-liner to preserve this intent:

// Outside the try/catch intentionally: a throw leaves the WeakMap unpopulated
// so the next render gets a full retry rather than reusing partial state.
sharedHydrationInitStates.set(router, {

@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.83 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.83 KB (0%)
react-on-rails/client bundled (brotli) 53.9 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.9 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.77 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.77 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.81 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.81 KB (0%)
registerServerComponent/client bundled (gzip) 127.95 KB (-0.01% 🔽)
registerServerComponent/client bundled (gzip) (time) 127.95 KB (0%)
registerServerComponent/client bundled (brotli) 62.06 KB (0%)
registerServerComponent/client bundled (brotli) (time) 62.06 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 122.37 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 122.37 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 57.09 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 57.09 KB (0%)

@justin808 justin808 merged commit f68d2f6 into main May 26, 2026
56 checks passed
@justin808 justin808 deleted the jg/3405-tanstack-strictmode-dedup branch May 26, 2026 00:43
justin808 added a commit that referenced this pull request May 26, 2026
Brings in #3408 (Pro Gemfile.lock sync, fixes benchmark CI),
#3410 (tanstack-router StrictMode dedupe), and #3395 (changelog docs).

Conflict in packages/react-on-rails-pro/src/tanstack-router/clientHydrate.ts
resolved by taking origin/main's version: #3410 is a more recent
reimplementation by the same author of the StrictMode-replay dedup
done on this branch (commits 4eee58e, 1b6ef61, 9309155). The
branch's per-route preloadRouteChunk cache is superseded by main's
unified sharedHydrationInitStates per-router state.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(tanstack-router): loadRouteChunk called 2× under StrictMode hydration replay

1 participant