Skip to content

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

Draft
Sheraff wants to merge 3 commits intomainfrom
opencode/shiny-orchid
Draft

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

Conversation

@Sheraff
Copy link
Contributor

@Sheraff Sheraff commented Mar 21, 2026

Summary

  • harden loadRouteMatch so preload cleanup does not crash when an in-flight match has already been evicted from cache
  • add regression coverage for both direct cache GC and router.invalidate() clearing an in-flight preload
  • keep the current behavior where expired preloads may be removed, while preventing the _nonReactive console error reported after the recent preload changes

Testing

  • CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:unit --outputStyle=stream --skipRemoteCache -- tests/load.test.ts
  • CI=1 NX_DAEMON=false pnpm nx run @tanstack/router-core:test:types --outputStyle=stream --skipRemoteCache

Summary by CodeRabbit

  • Bug Fixes

    • Improved route loader cleanup so background preloads clean up reliably and tolerate missing or evicted matches without errors.
    • Enhanced behavior during cache eviction/invalidation to avoid spurious errors when preloads are still in flight.
  • Tests

    • Added concurrency tests covering cache eviction and invalidation interacting with in‑flight preloads.
  • Chores

    • Added a changeset to publish a patch release.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 21, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8214f406-4ff8-4b0b-b75d-83a8a5b9c469

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Capture the match used for stale-while-revalidate preloads, handle cases where the match is removed during in-flight preload by cleaning up loader promises and related state, and add tests and a changeset ensuring cache eviction during preload does not error.

Changes

Cohort / File(s) Summary
Background loader cleanup
packages/router-core/src/load-matches.ts
Store the prior match when inner.preload is true; derive final match from inner.router.getMatch(matchId) (nullable) and, if the match was removed during preload, resolve/clear controlled preload loaderPromise/loadPromise, clear pendingTimeout, reset _nonReactive.loaderPromise when appropriate, clear _nonReactive.dehydrated, and return the captured match to avoid accessing removed state.
Concurrency edge-case tests
packages/router-core/tests/load.test.ts
Add two tests under "loader skip or exec" verifying behavior when clearExpiredCache() or invalidate() run while a preloadRoute loader is unresolved: ensure cached match is removed, sibling loaders behave correctly, and completion of the in-flight preload does not trigger console errors.
Release changeset
.changeset/moody-kings-travel.md
Add patch changeset noting that evicting cache during preload should not error and implementation returns early.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰
I caught the match before it hopped away,
held promises snug through a stormy day.
When routes vanished mid-preload race,
I cleared the crumbs and left no trace.
Quiet cache, happy rabbit — safe and gay! 🥕🎉

🚥 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 'fix(router-core): avoid preload cleanup crashes after invalidation' accurately and specifically describes the main change: preventing crashes during preload cleanup when matches are evicted from cache after invalidation.

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

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 d86b13d

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

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

@github-actions
Copy link
Contributor

github-actions bot commented Mar 21, 2026

🚀 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

github-actions bot commented Mar 21, 2026

Bundle Size Benchmarks

  • Commit: d7445e048d7d
  • Measured at: 2026-03-21T17:27:15.775Z
  • Baseline source: history:0d11d5e494bb
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 88.69 KiB +141 B (+0.16%) 279.67 KiB 76.92 KiB ▁▁▁▁▁▇▇▇▇▇▇█
react-router.full 91.84 KiB +114 B (+0.12%) 290.40 KiB 79.64 KiB ▁▁▁▁▁▇▇▇▇▇▇█
solid-router.minimal 36.30 KiB +175 B (+0.47%) 109.42 KiB 32.52 KiB █████▁▁▁▁▁▁▂
solid-router.full 40.65 KiB +169 B (+0.41%) 122.55 KiB 36.37 KiB █████▁▁▁▁▁▁▂
vue-router.minimal 54.28 KiB +177 B (+0.32%) 155.71 KiB 48.59 KiB ▁▁▁▁▁▇▇▇▇▇▇█
vue-router.full 59.05 KiB +171 B (+0.28%) 170.86 KiB 52.77 KiB ▁▁▁▁▁▇▇▇▇▇▇█
react-start.minimal 103.16 KiB +165 B (+0.16%) 327.74 KiB 89.08 KiB ▁▁▁▁▁▇▇▇▇▇▇█
react-start.full 106.52 KiB +157 B (+0.14%) 338.05 KiB 91.93 KiB ▁▁▁▁▁▇▇▇▇▇▇█
solid-start.minimal 50.45 KiB +137 B (+0.27%) 155.91 KiB 44.42 KiB █████▁▁▁▁▁▁▂
solid-start.full 55.88 KiB +153 B (+0.27%) 171.74 KiB 49.12 KiB █████▁▁▁▁▁▁▂

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

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.

🧹 Nitpick comments (1)
packages/router-core/tests/load.test.ts (1)

442-502: Good regression test for invalidate during in-flight preload.

The test properly exercises the second scenario from the PR objectives, verifying that router.invalidate() doesn't cause errors when a preload is still in-flight.

Minor suggestion: Consider wrapping the spy cleanup in a try/finally or using afterEach to ensure mockRestore() runs even if assertions fail. This prevents spy leakage affecting subsequent tests.

+  test('does not error when invalidate clears an in-flight preload', async () => {
+    const consoleErrorSpy = vi
+      .spyOn(console, 'error')
+      .mockImplementation(() => {})
+
+    try {
       // ... test body ...
+
+      expect(consoleErrorSpy).not.toHaveBeenCalled()
+      // ... remaining assertions ...
+    } finally {
+      consoleErrorSpy.mockRestore()
+    }
+  })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/tests/load.test.ts` around lines 442 - 502, Wrap the
console.error spy setup/cleanup in a safe teardown to guarantee restore even on
test failures: move the vi.spyOn(...).mockImplementation to the start of the
test and ensure mockRestore() is executed in a finally block (or register the
spy with an afterEach cleanup) so consoleErrorSpy.mockRestore() always runs;
reference the existing consoleErrorSpy variable and its mockRestore call in the
"does not error when invalidate clears an in-flight preload" test in
packages/router-core/tests/load.test.ts.
🤖 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/router-core/tests/load.test.ts`:
- Around line 442-502: Wrap the console.error spy setup/cleanup in a safe
teardown to guarantee restore even on test failures: move the
vi.spyOn(...).mockImplementation to the start of the test and ensure
mockRestore() is executed in a finally block (or register the spy with an
afterEach cleanup) so consoleErrorSpy.mockRestore() always runs; reference the
existing consoleErrorSpy variable and its mockRestore call in the "does not
error when invalidate clears an in-flight preload" test in
packages/router-core/tests/load.test.ts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f2b1be56-69c4-4523-a55d-6a979300cb85

📥 Commits

Reviewing files that changed from the base of the PR and between 0d11d5e and ce450ad.

📒 Files selected for processing (2)
  • packages/router-core/src/load-matches.ts
  • packages/router-core/tests/load.test.ts

@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@7003

@tanstack/eslint-plugin-router

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

@tanstack/history

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

@tanstack/nitro-v2-vite-plugin

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

@tanstack/react-router

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

@tanstack/react-router-devtools

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

@tanstack/react-router-ssr-query

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

@tanstack/react-start

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

@tanstack/react-start-client

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

@tanstack/react-start-server

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

@tanstack/router-cli

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

@tanstack/router-core

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

@tanstack/router-devtools

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

@tanstack/router-devtools-core

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

@tanstack/router-generator

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

@tanstack/router-plugin

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

@tanstack/router-ssr-query-core

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

@tanstack/router-utils

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

@tanstack/router-vite-plugin

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

@tanstack/solid-router

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

@tanstack/solid-router-devtools

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

@tanstack/solid-router-ssr-query

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

@tanstack/solid-start

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

@tanstack/solid-start-client

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

@tanstack/solid-start-server

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

@tanstack/start-client-core

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

@tanstack/start-fn-stubs

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

@tanstack/start-plugin-core

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

@tanstack/start-server-core

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

@tanstack/start-static-server-functions

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

@tanstack/start-storage-context

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

@tanstack/valibot-adapter

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

@tanstack/virtual-file-routes

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

@tanstack/vue-router

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

@tanstack/vue-router-devtools

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

@tanstack/vue-router-ssr-query

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

@tanstack/vue-start

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

@tanstack/vue-start-client

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

@tanstack/vue-start-server

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

@tanstack/zod-adapter

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

commit: d86b13d

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

ℹ️ 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 +954 to +955
if (!match) {
return inner.matches[index]!

Choose a reason for hiding this comment

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

P1 Badge Don't mark a canceled navigation as fully loaded

If a loader ignores abortController and the user navigates away before it settles, beforeLoad() for the newer navigation replaces the old pending match (router.ts:2362-2369). Hitting this fallback turns the canceled loadRouteMatch() into a success, so loadMatches() still reaches triggerOnReady(), and the onReady callback in router.ts:2409-2490 commits whatever newer pendingMatchesSnapshot exists at that moment. That can mount the second navigation before its own loaders have finished.

Useful? React with 👍 / 👎.

Comment on lines +954 to +955
if (!match) {
return inner.matches[index]!

Choose a reason for hiding this comment

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

P2 Badge Don't return stale pending matches from evicted preloads

For preloads, preloadRoute()'s custom updateMatch writes loader results into the router store rather than mutating inner.matches (router.ts:2843-2849). If cache GC or invalidate() evicts the match before the loader finishes, this fallback returns the original inner.matches[index]!, whose status and loaderData are still stale (pending with no data). Any caller that awaits router.preloadRoute() and consumes the returned matches will get an incorrect result instead of a missing/failed preload.

Useful? React with 👍 / 👎.

@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 (d86b13d) with main (d7445e0)

Open in CodSpeed

@Sheraff Sheraff force-pushed the opencode/shiny-orchid branch from 3e988de to 911a46e Compare March 21, 2026 11:37
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

🤖 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/load-matches.ts`:
- Around line 946-961: The eviction cleanup must run even when loadRouteMatch
takes the loaderShouldRunAsync/loaderIsRunningAsync early-return path; attach a
background cleanup that executes after the detached loader finishes and checks
inner.router.getMatch(matchId) and then performs the same steps currently inside
the if-block (resolve preloadCleanupMatch._nonReactive.loaderPromise and
.loadPromise, clear pendingTimeout, unset loaderPromise/loadPromise/dehydrated).
Concretely, in the loaderShouldRunAsync branch of loadRouteMatch, before
returning, register a finally/then handler on
prevMatch._nonReactive.loaderPromise (or create a small async detached task)
that re-checks inner.router.getMatch(matchId) and, if missing, runs the existing
cleanup logic for preloadCleanupMatch (resolving promises and clearing
nonReactive fields) so a subsequent preload won't hang waiting on an orphaned
loaderPromise.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b2242935-144c-4829-bd58-15bb69b780c0

📥 Commits

Reviewing files that changed from the base of the PR and between 911a46e and 3e988de.

📒 Files selected for processing (1)
  • packages/router-core/src/load-matches.ts

Comment on lines +946 to +961
const currentMatch = inner.router.getMatch(matchId)
if (!currentMatch && inner.preload && preloadCleanupMatch) {
if (!loaderIsRunningAsync) {
preloadCleanupMatch._nonReactive.loaderPromise?.resolve()
preloadCleanupMatch._nonReactive.loadPromise?.resolve()
preloadCleanupMatch._nonReactive.loadPromise = undefined
}

clearTimeout(preloadCleanupMatch._nonReactive.pendingTimeout)
preloadCleanupMatch._nonReactive.pendingTimeout = undefined
if (!loaderIsRunningAsync) {
preloadCleanupMatch._nonReactive.loaderPromise = undefined
}
preloadCleanupMatch._nonReactive.dehydrated = undefined

return preloadCleanupMatch
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

Cleanup still misses evictions after the background reload has already detached.

This branch only runs while the current loadRouteMatch() call is still executing. In the loaderShouldRunAsync path, the function returns first, so an eviction that happens afterwards never reaches this cleanup. If another preload starts in that window, it will block on the existing prevMatch._nonReactive.loaderPromise, and the detached task still resolves that promise only via inner.router.getMatch(matchId). Once the store entry is gone, that promise is orphaned and the second preload can hang indefinitely.

🔧 Suggested direction
     } else if (
       loaderShouldRunAsync &&
       !inner.sync &&
       shouldReloadInBackground
     ) {
       loaderIsRunningAsync = true
+      const matchForCleanup = prevMatch
       ;(async () => {
         try {
           await runLoader(inner, matchPromises, matchId, index, route)
-          const match = inner.router.getMatch(matchId)!
-          match._nonReactive.loaderPromise?.resolve()
-          match._nonReactive.loadPromise?.resolve()
-          match._nonReactive.loaderPromise = undefined
-          match._nonReactive.loadPromise = undefined
         } catch (err) {
           if (isRedirect(err)) {
             await inner.router.navigate(err.options)
           }
+        } finally {
+          const match = inner.router.getMatch(matchId) ?? matchForCleanup
+          match._nonReactive.loaderPromise?.resolve()
+          match._nonReactive.loadPromise?.resolve()
+          match._nonReactive.loaderPromise = undefined
+          match._nonReactive.loadPromise = undefined
         }
       })()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/src/load-matches.ts` around lines 946 - 961, The
eviction cleanup must run even when loadRouteMatch takes the
loaderShouldRunAsync/loaderIsRunningAsync early-return path; attach a background
cleanup that executes after the detached loader finishes and checks
inner.router.getMatch(matchId) and then performs the same steps currently inside
the if-block (resolve preloadCleanupMatch._nonReactive.loaderPromise and
.loadPromise, clear pendingTimeout, unset loaderPromise/loadPromise/dehydrated).
Concretely, in the loaderShouldRunAsync branch of loadRouteMatch, before
returning, register a finally/then handler on
prevMatch._nonReactive.loaderPromise (or create a small async detached task)
that re-checks inner.router.getMatch(matchId) and, if missing, runs the existing
cleanup logic for preloadCleanupMatch (resolving promises and clearing
nonReactive fields) so a subsequent preload won't hang waiting on an orphaned
loaderPromise.

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