Skip to content

fix(router): patch router-core so evicting an in-flight preload doesn't TypeError#1436

Open
dawsontoth wants to merge 2 commits into
stagefrom
claude/nonreactive-errors-logs-7dda46
Open

fix(router): patch router-core so evicting an in-flight preload doesn't TypeError#1436
dawsontoth wants to merge 2 commits into
stagefrom
claude/nonreactive-errors-logs-7dda46

Conversation

@dawsontoth

Copy link
Copy Markdown
Contributor

Fixes #1387.

What

Datadog RUM has been recording a handled TypeError: Cannot read properties of undefined (reading '_nonReactive') from router.preloadRoute — escalated to 10 occurrences across 8 sessions in a day, across many routes.

Root cause (upstream, TanStack/router#7759): several code paths in @tanstack/router-core's load-matches.js re-look up the match with getMatch(matchId) after an await and dereference ._nonReactive without a guard. A preload's match only lives in cachedMatches, so if the user navigates (or router.invalidate() / cache GC runs) while a hover-intent preload is in flight, the match is evicted, getMatch() returns undefined, and preloadRoute console.errors the TypeError — which RUM picks up on every hover-then-navigate race.

We're already on the latest published versions (react-router@1.170.17 / router-core@1.171.14); no released version contains a fix. Upstream has two open fix PRs (#7003, #7006).

How

  • patches/@tanstack__router-core@1.171.14.patch (pnpm patchedDependencies): ports upstream PR #7003. A missing match during load now throws an internal MatchLoadCancelledError that resolves the evicted match's controlled promises and aborts it; preloadRoute swallows it (the preload becomes a quiet no-op). The background stale-reload cleanup also no longer assumes the match still exists. Patched in both dist/esm and dist/cjs.
  • src/router/__tests__/preloadEvictionRepro.test.ts: regression tests that reproduce the exact production error against the unpatched package (cache GC and invalidate() during an in-flight preload) and pass with the patch.
  • CLAUDE.md: documents the patch and what to do on the next router bump (check whether upstream shipped the fix; otherwise re-create the patch — pnpm will hard-error on the version mismatch, so it can't be silently lost).

Verification

  • New regression tests fail on unpatched router-core with the exact production TypeError, pass with the patch.
  • Full suite: 192 test files / 1266 tests pass; lint + tsc clean.
  • Live app (worktree dev server on 5173): confirmed the patched code is what Vite serves, then stress-tested hover-preload → navigate → back races across links — zero console errors/warnings.

Drop the patch once upstream ships the fix.

🤖 Generated with Claude Code

…'t TypeError

Fixes #1387. Datadog RUM has been recording (10x/day at peak, 8 sessions)
a handled 'TypeError: Cannot read properties of undefined (reading
"_nonReactive")' from router.preloadRoute. Root cause: several paths in
@tanstack/router-core's load-matches.js re-look the match up with
getMatch() after an await and dereference ._nonReactive without a guard.
A preload's match only lives in cachedMatches, so navigating (or
router.invalidate()/cache GC) while a hover-intent preload is in flight
evicts it, getMatch() returns undefined, and preloadRoute console.error()s
the TypeError — which RUM picks up on every hover-then-navigate race.

Upstream report is TanStack/router#7759 with open fix PRs #7003/#7006; no
released version contains a fix (we are on the latest 1.170.17/1.171.14).
This ports PR #7003 as a pnpm patch: a missing match during load now
throws an internal MatchLoadCancelledError that resolves the evicted
match's controlled promises, aborts it, and is swallowed by preloadRoute
(preload becomes a quiet no-op), and the background-reload cleanup no
longer assumes the match still exists.

The new regression tests reproduce the exact production error against the
unpatched package (cache GC and invalidate() during an in-flight preload)
and pass with the patch. Drop the patch once upstream ships the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dawsontoth dawsontoth requested a review from a team as a code owner July 9, 2026 15:27
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@dawsontoth

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@kriszyp

kriszyp commented Jul 9, 2026

Copy link
Copy Markdown
Member

Good instinct putting a version guard on this — patching router-core internals is exactly the kind of thing that should fail loudly on a dependency bump rather than silently stop applying.

One thing worth fixing before merge: in the "else" branch of the patch (patches/@tanstack__router-core@1.171.14.patch:76, mirrored at the esm build :236), cleanupMatch isn't reassigned after the second getMatchOrThrowCancelled call. Trigger: a second concurrent load of the same match is already in flight (prevMatch._nonReactive.loaderPromise truthy) when the cache evicts that match during the subsequent await — the eviction's own cleanup step gets skipped in that branch.

Smaller one: the err instanceof MatchLoadCancelledError checks are per-module-format (cjs/esm each define and check their own class). Not a live bug given the current install topology, but if pnpm/Vite ever resolve two separate instances of router-core (a dual-package hazard), the check silently falls through to generic error handling instead of the cancellation path. A err?.name === 'MatchCancel' fallback would be cheap insurance — low priority, not blocking.

— KrAIs

…anch

Review feedback on #1436 (kriszyp): when a second load of the same match
joins an in-flight loaderPromise and the cache evicts that match during
the await, cleanupMatch was still undefined, so the eviction throw
skipped resolving the evicted match's controlled promises. Assign
cleanupMatch as soon as prevMatch is fetched so that branch cleans up
too. Also make isMatchLoadCancelledError fall back to err.name ===
'MatchCancel' so the cancellation path survives a dual-package hazard
(two router-core instances), and add regression coverage for eviction
during concurrent preloads of the same route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

@kriszyp Both addressed in 5a19ed4:

  • cleanupMatch gap in the concurrent-load branch: confirmed — with prevMatch._nonReactive.loaderPromise in flight, cleanupMatch was still undefined at the post-await getMatchOrThrowCancelled, so an eviction there threw without resolving the evicted match's controlled promises. Now cleanupMatch = prevMatch is assigned as soon as prevMatch is fetched (ESM + CJS), so that branch cleans up like the others. Added a regression test that drives exactly that branch (second preload of the same route joining the first's in-flight loader, then clearExpiredCache() mid-await) and asserts both preloads settle with no console errors. Worth noting this deviates from upstream PR #7003, which has the same gap — I'll flag it there.

  • instanceof under a dual-package hazard: isMatchLoadCancelledError now falls back to err instanceof Error && err.name === 'MatchCancel' in both module formats.

🤖 Addressed by Claude Code

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.

[RUM] TypeError: Cannot read properties of undefined (reading '_nonReactive') in TanStack Router

2 participants