Skip to content

fix(router-core): avoid preload cleanup crashes after invalidation 2#7006

Open
Sheraff wants to merge 4 commits intomainfrom
opencode/shiny-orchid-2
Open

fix(router-core): avoid preload cleanup crashes after invalidation 2#7006
Sheraff wants to merge 4 commits intomainfrom
opencode/shiny-orchid-2

Conversation

@Sheraff
Copy link
Contributor

@Sheraff Sheraff commented Mar 21, 2026

Alternative solution to #7003

Summary by CodeRabbit

  • Bug Fixes

    • Resolved preload cache eviction errors by improving early return logic
    • Enhanced robustness when cache operations occur during active preloads
    • Improved match availability checks throughout loader execution
  • Tests

    • Added tests verifying preload stability during cache operations like clearExpiredCache and invalidate

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 21, 2026

📝 Walkthrough

Walkthrough

This PR enhances route preload robustness by preventing cache eviction errors. Changes include adding match availability guards, improving promise handling centralization, updating cancellation semantics to resolve pending promises, validating preload results correspond to router state, and verifying cache lifecycle operations don't break in-flight preloads.

Changes

Cohort / File(s) Summary
Changeset Entry
.changeset/moody-kings-travel.md
Patch release documentation for @tanstack/router-core noting cache eviction error prevention during preload.
Promise Handling
packages/react-router/src/Match.tsx
Introduced getMatchNonReactivePromise() helper to centralize suspense promise selection from router.getMatch() or fallback to match._nonReactive, replacing direct promise throws across multiple status checks.
Match Loading Logic
packages/router-core/src/load-matches.ts
Added matchIsAvailable() type guard to gate operations on missing/aborted matches. Replaced non-null assertions with early returns in preload setup, loader execution, and abort-aware cleanup. Updated getLoaderContext to accept concrete match instead of matchId. Added availability/abort checks before loader logic, redirect/notFound handling, state commits, and in catch flows.
Router State Management
packages/router-core/src/router.ts
Enhanced match reset logic to preserve loadPromise when aborted. Updated cancellation semantics to resolve (not just abort) pending promises before clearing fields. Refactored clearCache to precompute removable matches and call cancelMatch for pending/loading matches. Added result validation in preloadRoute to ensure loaded matches still exist in router state.
Test Coverage
packages/router-core/tests/load.test.ts
Added two tests verifying preload robustness: one confirming clearExpiredCache() during unresolved preload doesn't reject or error, another confirming invalidate() during loader-driven preload succeeds without errors and properly removes cached matches.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Hops through cache with careful grace,
No errors hiding in this place,
Preloads dance while caches sway,
Promises resolved in every way,
Safe and sound, the routes all play!

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main fix: preventing crashes during preload cleanup after cache invalidation operations.

✏️ 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 opencode/shiny-orchid-2

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link

nx-cloud bot commented Mar 21, 2026

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit 4bf77c5

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ❌ Failed 14m 51s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 48s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-21 17:43:42 UTC

@github-actions
Copy link
Contributor

🚀 Changeset Version Preview

1 package(s) bumped directly, 21 bumped as dependents.

🟩 Patch bumps

Package Version Reason
@tanstack/router-core 1.168.1 → 1.168.2 Changeset
@tanstack/react-router 1.168.1 → 1.168.2 Dependent
@tanstack/react-start 1.167.2 → 1.167.3 Dependent
@tanstack/react-start-client 1.166.16 → 1.166.17 Dependent
@tanstack/react-start-server 1.166.16 → 1.166.17 Dependent
@tanstack/router-cli 1.166.16 → 1.166.17 Dependent
@tanstack/router-generator 1.166.15 → 1.166.16 Dependent
@tanstack/router-plugin 1.167.2 → 1.167.3 Dependent
@tanstack/router-vite-plugin 1.166.17 → 1.166.18 Dependent
@tanstack/solid-router 1.168.1 → 1.168.2 Dependent
@tanstack/solid-start 1.167.2 → 1.167.3 Dependent
@tanstack/solid-start-client 1.166.15 → 1.166.16 Dependent
@tanstack/solid-start-server 1.166.15 → 1.166.16 Dependent
@tanstack/start-client-core 1.167.1 → 1.167.2 Dependent
@tanstack/start-plugin-core 1.167.5 → 1.167.6 Dependent
@tanstack/start-server-core 1.167.1 → 1.167.2 Dependent
@tanstack/start-static-server-functions 1.166.17 → 1.166.18 Dependent
@tanstack/start-storage-context 1.166.15 → 1.166.16 Dependent
@tanstack/vue-router 1.168.1 → 1.168.2 Dependent
@tanstack/vue-start 1.167.2 → 1.167.3 Dependent
@tanstack/vue-start-client 1.166.15 → 1.166.16 Dependent
@tanstack/vue-start-server 1.166.15 → 1.166.16 Dependent

@github-actions
Copy link
Contributor

Bundle Size Benchmarks

  • Commit: d7445e048d7d
  • Measured at: 2026-03-21T17:29:48.955Z
  • Baseline source: history:d7445e048d7d
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 88.89 KiB +353 B (+0.39%) 280.60 KiB 77.18 KiB ▁▁▁▁▁▆▆▆▆▆▆█
react-router.full 92.05 KiB +348 B (+0.37%) 291.33 KiB 79.81 KiB ▁▁▁▁▁▆▆▆▆▆▆█
solid-router.minimal 36.42 KiB +296 B (+0.80%) 110.28 KiB 32.68 KiB █████▁▁▁▁▁▁▃
solid-router.full 40.79 KiB +309 B (+0.75%) 123.42 KiB 36.47 KiB █████▁▁▁▁▁▁▄
vue-router.minimal 54.40 KiB +299 B (+0.54%) 156.58 KiB 48.73 KiB ▁▁▁▁▁▇▇▇▇▇▇█
vue-router.full 59.18 KiB +298 B (+0.49%) 171.73 KiB 52.93 KiB ▁▁▁▁▁▇▇▇▇▇▇█
react-start.minimal 103.33 KiB +359 B (+0.34%) 328.67 KiB 89.29 KiB ▁▁▁▁▁▆▆▆▆▆▆█
react-start.full 106.68 KiB +340 B (+0.31%) 338.98 KiB 92.13 KiB ▁▁▁▁▁▆▆▆▆▆▆█
solid-start.minimal 50.60 KiB +290 B (+0.56%) 156.78 KiB 44.57 KiB █████▁▁▁▁▁▁▃
solid-start.full 56.02 KiB +291 B (+0.51%) 172.61 KiB 49.16 KiB █████▁▁▁▁▁▁▄

Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 21, 2026

More templates

@tanstack/arktype-adapter

npm i https://pkg.pr.new/@tanstack/arktype-adapter@7006

@tanstack/eslint-plugin-router

npm i https://pkg.pr.new/@tanstack/eslint-plugin-router@7006

@tanstack/history

npm i https://pkg.pr.new/@tanstack/history@7006

@tanstack/nitro-v2-vite-plugin

npm i https://pkg.pr.new/@tanstack/nitro-v2-vite-plugin@7006

@tanstack/react-router

npm i https://pkg.pr.new/@tanstack/react-router@7006

@tanstack/react-router-devtools

npm i https://pkg.pr.new/@tanstack/react-router-devtools@7006

@tanstack/react-router-ssr-query

npm i https://pkg.pr.new/@tanstack/react-router-ssr-query@7006

@tanstack/react-start

npm i https://pkg.pr.new/@tanstack/react-start@7006

@tanstack/react-start-client

npm i https://pkg.pr.new/@tanstack/react-start-client@7006

@tanstack/react-start-server

npm i https://pkg.pr.new/@tanstack/react-start-server@7006

@tanstack/router-cli

npm i https://pkg.pr.new/@tanstack/router-cli@7006

@tanstack/router-core

npm i https://pkg.pr.new/@tanstack/router-core@7006

@tanstack/router-devtools

npm i https://pkg.pr.new/@tanstack/router-devtools@7006

@tanstack/router-devtools-core

npm i https://pkg.pr.new/@tanstack/router-devtools-core@7006

@tanstack/router-generator

npm i https://pkg.pr.new/@tanstack/router-generator@7006

@tanstack/router-plugin

npm i https://pkg.pr.new/@tanstack/router-plugin@7006

@tanstack/router-ssr-query-core

npm i https://pkg.pr.new/@tanstack/router-ssr-query-core@7006

@tanstack/router-utils

npm i https://pkg.pr.new/@tanstack/router-utils@7006

@tanstack/router-vite-plugin

npm i https://pkg.pr.new/@tanstack/router-vite-plugin@7006

@tanstack/solid-router

npm i https://pkg.pr.new/@tanstack/solid-router@7006

@tanstack/solid-router-devtools

npm i https://pkg.pr.new/@tanstack/solid-router-devtools@7006

@tanstack/solid-router-ssr-query

npm i https://pkg.pr.new/@tanstack/solid-router-ssr-query@7006

@tanstack/solid-start

npm i https://pkg.pr.new/@tanstack/solid-start@7006

@tanstack/solid-start-client

npm i https://pkg.pr.new/@tanstack/solid-start-client@7006

@tanstack/solid-start-server

npm i https://pkg.pr.new/@tanstack/solid-start-server@7006

@tanstack/start-client-core

npm i https://pkg.pr.new/@tanstack/start-client-core@7006

@tanstack/start-fn-stubs

npm i https://pkg.pr.new/@tanstack/start-fn-stubs@7006

@tanstack/start-plugin-core

npm i https://pkg.pr.new/@tanstack/start-plugin-core@7006

@tanstack/start-server-core

npm i https://pkg.pr.new/@tanstack/start-server-core@7006

@tanstack/start-static-server-functions

npm i https://pkg.pr.new/@tanstack/start-static-server-functions@7006

@tanstack/start-storage-context

npm i https://pkg.pr.new/@tanstack/start-storage-context@7006

@tanstack/valibot-adapter

npm i https://pkg.pr.new/@tanstack/valibot-adapter@7006

@tanstack/virtual-file-routes

npm i https://pkg.pr.new/@tanstack/virtual-file-routes@7006

@tanstack/vue-router

npm i https://pkg.pr.new/@tanstack/vue-router@7006

@tanstack/vue-router-devtools

npm i https://pkg.pr.new/@tanstack/vue-router-devtools@7006

@tanstack/vue-router-ssr-query

npm i https://pkg.pr.new/@tanstack/vue-router-ssr-query@7006

@tanstack/vue-start

npm i https://pkg.pr.new/@tanstack/vue-start@7006

@tanstack/vue-start-client

npm i https://pkg.pr.new/@tanstack/vue-start-client@7006

@tanstack/vue-start-server

npm i https://pkg.pr.new/@tanstack/vue-start-server@7006

@tanstack/zod-adapter

npm i https://pkg.pr.new/@tanstack/zod-adapter@7006

commit: 4bf77c5

Copy link

@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: 4bf77c51a6

ℹ️ 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 +406 to +407
const match = inner.router.getMatch(matchId)
if (!match) return

Choose a reason for hiding this comment

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

P1 Badge Skip beforeLoad when the match was already canceled

cancelMatch() now resolves _nonReactive.beforeLoadPromise/loaderPromise before aborting the match (packages/router-core/src/router.ts:1790-1794). That wakes any concurrent load that was blocked in preBeforeLoadSetup(), but this entry point only checks for a missing match, not an aborted one. If the canceled match is still present in the active/pending pool (for example during an invalidate() or same-URL reload while an async beforeLoad is in flight), the stale load continues into route.options.beforeLoad and can still redirect or mutate __beforeLoadContext after the navigation was canceled.

Useful? React with 👍 / 👎.

Copy link
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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/router-core/src/router.ts (1)

1541-1577: ⚠️ Potential issue | 🟠 Major

Preserve the previous loadPromise when reviving an aborted match.

load-matches.ts chains the next suspense promise onto the current loadPromise so older waiters are released when the retry completes. This reset branch always replaces loadPromise, which severs that chain and can leave an already-suspended match waiting on an orphaned promise after an invalidate/cancel+retry cycle.

💡 Suggested fix
+        const preservedLoadPromise =
+          existingMatch._nonReactive.loadPromise ??
+          createControlledPromise<void>()
+
         match = {
           ...existingMatch,
           cause,
@@
           _nonReactive: {
             ...existingMatch._nonReactive,
-            loadPromise:
-              existingMatch._nonReactive.loadPromise ??
-              createControlledPromise(),
+            loadPromise: preservedLoadPromise,
           },
           ...(shouldResetExistingMatch
             ? {
@@
                 _nonReactive: {
                   ...existingMatch._nonReactive,
                   beforeLoadPromise: undefined,
                   loaderPromise: undefined,
                   pendingTimeout: undefined,
-                  loadPromise: createControlledPromise(),
+                  loadPromise: preservedLoadPromise,
                   displayPendingPromise: undefined,
                   minPendingPromise: undefined,
                   dehydrated: undefined,
                   error: undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/src/router.ts` around lines 1541 - 1577, When reviving
an aborted match we currently always replace
existingMatch._nonReactive.loadPromise with createControlledPromise(), which
breaks the chaining used by load-matches.ts and can orphan older waiters; update
the reset branch that runs when shouldResetExistingMatch is true to preserve
existingMatch._nonReactive.loadPromise if it exists (i.e., leave loadPromise as
existingMatch._nonReactive.loadPromise rather than unconditionally creating a
new one), while still resetting other nonReactive fields like beforeLoadPromise,
loaderPromise, pendingTimeout, displayPendingPromise, minPendingPromise,
dehydrated, and error; reference the match construction that spreads
existingMatch and the nested _nonReactive block to locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/router-core/src/router.ts`:
- Around line 2798-2820: The code re-filters cachedMatches after calling
cancelMatch, but cancelMatch mutates match objects so the second filter can
fail; instead, collect the ids of matchesToClear (e.g., const idsToClear = new
Set(matchesToClear.map(m => m.id))), call this.cancelMatch for each match, and
then call this.stores.setCachedMatches(cachedMatches.filter(m =>
!idsToClear.has(m.id))); update references to matchesToClear, cachedMatches,
cancelMatch, and this.stores.setCachedMatches accordingly so removal is done by
id rather than re-running the predicate that may observe mutated _nonReactive
fields.

---

Outside diff comments:
In `@packages/router-core/src/router.ts`:
- Around line 1541-1577: When reviving an aborted match we currently always
replace existingMatch._nonReactive.loadPromise with createControlledPromise(),
which breaks the chaining used by load-matches.ts and can orphan older waiters;
update the reset branch that runs when shouldResetExistingMatch is true to
preserve existingMatch._nonReactive.loadPromise if it exists (i.e., leave
loadPromise as existingMatch._nonReactive.loadPromise rather than
unconditionally creating a new one), while still resetting other nonReactive
fields like beforeLoadPromise, loaderPromise, pendingTimeout,
displayPendingPromise, minPendingPromise, dehydrated, and error; reference the
match construction that spreads existingMatch and the nested _nonReactive block
to locate the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 93789982-c782-477e-8e9b-f8ea9eb6d6c9

📥 Commits

Reviewing files that changed from the base of the PR and between d7445e0 and 4bf77c5.

📒 Files selected for processing (5)
  • .changeset/moody-kings-travel.md
  • packages/react-router/src/Match.tsx
  • packages/router-core/src/load-matches.ts
  • packages/router-core/src/router.ts
  • packages/router-core/tests/load.test.ts

Comment on lines +2798 to 2820
const matchesToClear =
filter !== undefined
? cachedMatches.filter((match) =>
filter(match as MakeRouteMatchUnion<this>),
)
: cachedMatches

matchesToClear.forEach((match) => {
if (
match.status === 'pending' ||
match.isFetching ||
match._nonReactive.beforeLoadPromise ||
match._nonReactive.loaderPromise ||
match._nonReactive.loadPromise
) {
this.cancelMatch(match.id)
}
})

if (filter !== undefined) {
this.stores.setCachedMatches(
this.stores.cachedMatchesSnapshot.state.filter(
(m) => !filter(m as MakeRouteMatchUnion<this>),
),
cachedMatches.filter((m) => !filter(m as MakeRouteMatchUnion<this>)),
)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Remove cleared matches by id instead of re-running the filter.

cancelMatch() mutates the same match objects before the second filter(...) runs. If a caller's filter depends on _nonReactive promise fields or abort state, the entry can stop matching and remain in cachedMatches even though it was selected for clearing.

💡 Suggested fix
     const matchesToClear =
       filter !== undefined
         ? cachedMatches.filter((match) =>
             filter(match as MakeRouteMatchUnion<this>),
           )
         : cachedMatches
+    const matchIdsToClear = new Set(matchesToClear.map((match) => match.id))
@@
     if (filter !== undefined) {
       this.stores.setCachedMatches(
-        cachedMatches.filter((m) => !filter(m as MakeRouteMatchUnion<this>)),
+        cachedMatches.filter((m) => !matchIdsToClear.has(m.id)),
       )
     } else {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const matchesToClear =
filter !== undefined
? cachedMatches.filter((match) =>
filter(match as MakeRouteMatchUnion<this>),
)
: cachedMatches
matchesToClear.forEach((match) => {
if (
match.status === 'pending' ||
match.isFetching ||
match._nonReactive.beforeLoadPromise ||
match._nonReactive.loaderPromise ||
match._nonReactive.loadPromise
) {
this.cancelMatch(match.id)
}
})
if (filter !== undefined) {
this.stores.setCachedMatches(
this.stores.cachedMatchesSnapshot.state.filter(
(m) => !filter(m as MakeRouteMatchUnion<this>),
),
cachedMatches.filter((m) => !filter(m as MakeRouteMatchUnion<this>)),
)
const matchesToClear =
filter !== undefined
? cachedMatches.filter((match) =>
filter(match as MakeRouteMatchUnion<this>),
)
: cachedMatches
const matchIdsToClear = new Set(matchesToClear.map((match) => match.id))
matchesToClear.forEach((match) => {
if (
match.status === 'pending' ||
match.isFetching ||
match._nonReactive.beforeLoadPromise ||
match._nonReactive.loaderPromise ||
match._nonReactive.loadPromise
) {
this.cancelMatch(match.id)
}
})
if (filter !== undefined) {
this.stores.setCachedMatches(
cachedMatches.filter((m) => !matchIdsToClear.has(m.id)),
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/src/router.ts` around lines 2798 - 2820, The code
re-filters cachedMatches after calling cancelMatch, but cancelMatch mutates
match objects so the second filter can fail; instead, collect the ids of
matchesToClear (e.g., const idsToClear = new Set(matchesToClear.map(m =>
m.id))), call this.cancelMatch for each match, and then call
this.stores.setCachedMatches(cachedMatches.filter(m => !idsToClear.has(m.id)));
update references to matchesToClear, cachedMatches, cancelMatch, and
this.stores.setCachedMatches accordingly so removal is done by id rather than
re-running the predicate that may observe mutated _nonReactive fields.

@codspeed-hq
Copy link

codspeed-hq bot commented Mar 21, 2026

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing opencode/shiny-orchid-2 (4bf77c5) with main (d7445e0)

Open in CodSpeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant