Skip to content

fix: don't re-execute stale loaders on unrelated location changes#6867

Merged
schiller-manuel merged 9 commits intomainfrom
fix-stale-loader-unrelated
Mar 9, 2026
Merged

fix: don't re-execute stale loaders on unrelated location changes#6867
schiller-manuel merged 9 commits intomainfrom
fix-stale-loader-unrelated

Conversation

@schiller-manuel
Copy link
Contributor

@schiller-manuel schiller-manuel commented Mar 8, 2026

Summary by CodeRabbit

  • New Features

    • Added ability to forcefully reload stale matches via explicit router.load() calls.
  • Bug Fixes

    • Stale-match revalidation now occurs when routes are re-entered, loader dependencies change, or router.load() is called; background reload behavior made more consistent.
  • Documentation

    • Clarified Router.load() semantics and updated staleTime guidance.
  • Tests

    • Updated unit and end-to-end tests and expectations (including separating loader vs. context checks) to reflect the revised reload behavior.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 8, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3ccefb00-0709-4c95-902f-4aa29ea68cd1

📥 Commits

Reviewing files that changed from the base of the PR and between f4140b5 and 79eec14.

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

📝 Walkthrough

Walkthrough

Adds a forceStaleReload option to the load flow to enable explicit revalidation of stale matches, switches several framework route helpers to delegate to useRouteContext, updates docs about staleTime semantics, adjusts unit/e2e test expectations, and adds tests for stale-reload triggers.

Changes

Cohort / File(s) Summary
Docs
docs/router/api/router/RouterType.md, docs/router/guide/data-loading.md
Clarify staleTime/revalidation semantics: fresh matches remain fresh; stale matches are revalidated on route re-entry, loader key changes (path params/loaderDeps), or explicit router.load(); note router.invalidate() forces reload of active matches.
Router core — load logic
packages/router-core/src/load-matches.ts, packages/router-core/src/router.ts
Add forceStaleReload?: boolean to public/internal load APIs; introduce syncMatchContext, propagate previousRouteMatchId, update stale-reload decision logic and call sites; pass forceStaleReload from router when loading same-location navigations.
Framework route helpers
packages/react-router/src/fileRoute.ts, packages/react-router/src/route.tsx, packages/solid-router/src/fileRoute.ts, packages/solid-router/src/route.tsx, packages/vue-router/src/fileRoute.ts, packages/vue-router/src/route.ts
Replace internal useMatch-based implementations with direct useRouteContext({... from: this.id, ...opts}) delegation for LazyRoute/Route/RouteApi/RootRoute helpers. Public APIs unchanged.
Core tests
packages/router-core/tests/load.test.ts
Add "stale loader reload triggers" suite exercising loader caching, loaderDeps, dynamic segments, ancestor/child interactions, preloaded matches, and explicit router.load() revalidation (duplicate suite appears in diff).
Store-update tests
packages/react-router/tests/store-updates-during-navigation.test.tsx, packages/solid-router/tests/store-updates-during-navigation.test.tsx, packages/vue-router/tests/store-updates-during-navigation.test.tsx
Adjust numeric expectations for update counts during navigation across frameworks (±1–3).
E2E tests — SPA mode
e2e/react-start/spa-mode/tests/app.spec.ts, e2e/solid-start/spa-mode/tests/app.spec.ts, e2e/vue-start/spa-mode/tests/app.spec.ts
Refactor expectedData from flat per-route values to nested { loader, context } shape; split assertions into separate loader and context validation passes and update expected values for prerendered vs client navigation scenarios.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Client as Client
  participant Router as Router\n(+router.load)
  participant LoaderEngine as loadMatches\n(+forceStaleReload)
  participant Match as RouteMatch
  participant LoaderFn as Loader

  Client->>Router: navigation or explicit router.load()
  Router->>LoaderEngine: loadMatches({ forceStaleReload? })
  Note right of LoaderEngine: determine per-match\nstaleness, sync context
  LoaderEngine->>Match: syncMatchContext(match)
  alt match needs loader
    LoaderEngine->>LoaderFn: run loader (pass previousRouteMatchId)
    LoaderFn-->>LoaderEngine: loader result
    LoaderEngine->>Router: update match & context
  else skip loader
    LoaderEngine->>Router: reuse existing match/context
  end
  Router-->>Client: updated state
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • nlynzaad

Poem

🐰 I hop through routes and sniff the thread,
Fresh stays fresh, stale rechecks ahead,
Contexts sync, loaders run anew,
Tests adjusted, and docs made true.
A rabbit's cheer for routing glue! 🥕

🚥 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 stale loaders from re-executing when location changes are unrelated to the loader's dependencies.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix-stale-loader-unrelated

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

@nx-cloud
Copy link

nx-cloud bot commented Mar 8, 2026

View your CI Pipeline Execution ↗ for commit 79eec14

Command Status Duration Result
nx affected --targets=test:eslint,test:unit,tes... ✅ Succeeded 11m 56s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 2m 2s View ↗

☁️ Nx Cloud last updated this comment at 2026-03-08 23:13:41 UTC

@github-actions
Copy link

github-actions bot commented Mar 8, 2026

Bundle Size Benchmarks

  • Commit: 88fcbec9865c
  • Measured at: 2026-03-08T23:02:34.668Z
  • Baseline source: history:7003ccb50f7e
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 87.01 KiB +170 B (+0.19%) 273.70 KiB 75.55 KiB ▁▁▅▅▅▅▅▅▅▅▅█
react-router.full 90.00 KiB +124 B (+0.13%) 283.96 KiB 78.37 KiB ▁▁▆▆▆▆▆▆▆▆▆█
solid-router.minimal 36.32 KiB +146 B (+0.39%) 108.81 KiB 32.63 KiB ▁▁▆▆▆▆▆▆▆▆▆█
solid-router.full 40.61 KiB +110 B (+0.27%) 121.79 KiB 36.46 KiB ▁▁▆▆▆▆▆▆▆▆▆█
vue-router.minimal 52.17 KiB +135 B (+0.25%) 148.83 KiB 46.92 KiB ▁▁▆▆▆▆▆▆▆▆▆█
vue-router.full 56.96 KiB +119 B (+0.20%) 164.34 KiB 51.11 KiB ▁▁▆▆▆▆▆▆▆▆▆█
react-start.minimal 99.54 KiB +143 B (+0.14%) 312.85 KiB 86.11 KiB ▁▁▆▆▆▆▆▆▆▆▆█
react-start.full 102.90 KiB +116 B (+0.11%) 322.58 KiB 88.98 KiB ▁▁▆▆▆▆▆▆▆▆▆█
solid-start.minimal 48.63 KiB +141 B (+0.28%) 146.40 KiB 43.07 KiB ▁▁▆▆▆▆▆▆▆▆▆█
solid-start.full 54.09 KiB +133 B (+0.24%) 162.27 KiB 47.67 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 8, 2026

More templates

@tanstack/arktype-adapter

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

@tanstack/eslint-plugin-router

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

@tanstack/history

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

@tanstack/nitro-v2-vite-plugin

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

@tanstack/react-router

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

@tanstack/react-router-devtools

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

@tanstack/react-router-ssr-query

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

@tanstack/react-start

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

@tanstack/react-start-client

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

@tanstack/react-start-server

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

@tanstack/router-cli

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

@tanstack/router-core

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

@tanstack/router-devtools

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

@tanstack/router-devtools-core

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

@tanstack/router-generator

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

@tanstack/router-plugin

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

@tanstack/router-ssr-query-core

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

@tanstack/router-utils

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

@tanstack/router-vite-plugin

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

@tanstack/solid-router

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

@tanstack/solid-router-devtools

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

@tanstack/solid-router-ssr-query

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

@tanstack/solid-start

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

@tanstack/solid-start-client

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

@tanstack/solid-start-server

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

@tanstack/start-client-core

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

@tanstack/start-fn-stubs

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

@tanstack/start-plugin-core

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

@tanstack/start-server-core

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

@tanstack/start-static-server-functions

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

@tanstack/start-storage-context

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

@tanstack/valibot-adapter

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

@tanstack/virtual-file-routes

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

@tanstack/vue-router

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

@tanstack/vue-router-devtools

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

@tanstack/vue-router-ssr-query

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

@tanstack/vue-start

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

@tanstack/vue-start-client

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

@tanstack/vue-start-server

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

@tanstack/zod-adapter

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

commit: 03cf375

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

Caution

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

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

2375-2407: ⚠️ Potential issue | 🟠 Major

href equality is too broad for forced stale reloads.

previousLocation.href === next.href is also true for same-URL state/history transitions, so those paths will still force-revalidate stale loaders even when the loader key is unchanged. That reintroduces the same class of unrelated-change reloads this PR is trying to remove. Please thread an explicit opt-in from the call sites that intentionally revalidate instead of deriving it from location equality here. A regression test for same-href state-only navigation would lock this down.

🤖 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 2375 - 2407, The current
forced stale reload decision uses previousLocation.href === next.href which
incorrectly treats same-URL state/history navigations as a forced reload; change
loadMatches call to rely on an explicit opt-in flag (e.g.,
opts?.forceStaleReload) instead of deriving it from href equality, update the
loadMatches parameter name if needed to match the explicit flag
(forceStaleReload) and update all call sites that intentionally want to force
revalidation to pass that flag, and add a regression test for a same-href
state-only navigation (no loader-key change) to assert loaders are not
revalidated.
packages/router-core/src/load-matches.ts (1)

4-4: ⚠️ Potential issue | 🟠 Major

Avoid unconditional store writes in syncMatchContext.

buildMatchContext() always allocates, so this helper currently emits a match update even when the merged context is unchanged. That turns every stale-skip path into extra store churn/re-renders, which is why the navigation update expectations had to move up in this PR.

Suggested fix
-import { createControlledPromise, isPromise } from './utils'
+import { createControlledPromise, isPromise, replaceEqualDeep } from './utils'
 const syncMatchContext = (
   inner: InnerLoadContext,
   matchId: string,
   index: number,
 ): void => {
   const nextContext = buildMatchContext(inner, index)
 
   inner.updateMatch(matchId, (prev) => {
+    const context = replaceEqualDeep(prev.context, nextContext)
+    if (context === prev.context) {
+      return prev
+    }
+
     return {
       ...prev,
-      context: nextContext,
+      context,
     }
   })
 }

Also applies to: 201-214

🤖 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` at line 4, syncMatchContext
currently always writes the merged context created by buildMatchContext, causing
unnecessary store updates; change it to first compute the merged context (call
buildMatchContext as now), then compare the merged context to the existing
match.context (use the same equality semantics the store expects—shallow/deep as
appropriate), and only perform the store write/update when they differ; update
the code paths in syncMatchContext (and the similar block at lines ~201-214) to
early-return when contexts are equal to avoid unconditional store writes.
🤖 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/react-router/src/fileRoute.ts`:
- Around line 232-233: The helper LazyRoute.useRouteContext currently lets
callers override the bound route by spreading opts after from; change the call
so the route ID wins by applying opts first and then setting from to
this.options.id (i.e., ensure the object passed to useRouteContext has from:
this.options.id after spreading opts), so the bound route cannot be overridden;
update the useRouteContext invocation in LazyRoute.useRouteContext accordingly.

In `@packages/react-router/src/route.tsx`:
- Around line 117-119: The bound helper useRouteContext currently spreads opts
after specifying from which lets callers override this.id at runtime; change the
call in the useRouteContext: UseRouteContextRoute<TId> implementation so that
from: this.id is placed before ...opts is spread (i.e., ensure from is first and
opts merged after it is not allowed to override it), and make the identical
ordering change for the RouteApi and RootRoute bound variants referenced in the
comment (the other wrappers at the same pattern) so all route-bound hooks keep
from pinned to this.id.

In `@packages/solid-router/src/fileRoute.ts`:
- Around line 220-221: The helper currently spreads opts after setting from,
which allows callers to override the bound route id; change the call inside
LazyRoute.useRouteContext to ensure the route id is pinned by making from take
precedence (e.g., pass { ...(opts as any), from: this.options.id } to
useRouteContext) so useRouteContext always uses this.options.id and cannot be
overridden by opts.

In `@packages/solid-router/src/route.tsx`:
- Around line 107-109: The route-bound wrapper useRouteContext:
UseRouteContextRoute<TId> currently spreads opts after from so callers can
override the bound route id; change the call to ensure from is fixed by placing
from last (e.g. call useRouteContext({ ...(opts as any), from: this.id as any })
) or otherwise prevent opts from overwriting from; apply the same fix to the
other two route-bound wrappers that follow the same pattern so the bound route
id cannot be replaced by callers.

In `@packages/vue-router/src/fileRoute.ts`:
- Around line 220-221: The helper currently allows callers to override the bound
route because it spreads opts after setting from; change the merge so the bound
route id wins. In LazyRoute.useRouteContext (the useRouteContext property) call
useRouteContext({ ...(opts as any), from: this.options.id }) or otherwise ensure
opts cannot override from (e.g. remove opts.from before merging) so the returned
context is always pinned to this.options.id.

In `@packages/vue-router/src/route.ts`:
- Around line 108-110: The bound helper currently spreads opts after setting
from, allowing a stray opts.from to override the route id; change the object
ordering so opts are spread first and from is set last (e.g., call
useRouteContext({ ...(opts as any), from: this.id as any })), and apply the same
fix to the RouteApi and RootRoute bound variants referenced elsewhere so their
from property cannot be overridden by opts.

---

Outside diff comments:
In `@packages/router-core/src/load-matches.ts`:
- Line 4: syncMatchContext currently always writes the merged context created by
buildMatchContext, causing unnecessary store updates; change it to first compute
the merged context (call buildMatchContext as now), then compare the merged
context to the existing match.context (use the same equality semantics the store
expects—shallow/deep as appropriate), and only perform the store write/update
when they differ; update the code paths in syncMatchContext (and the similar
block at lines ~201-214) to early-return when contexts are equal to avoid
unconditional store writes.

In `@packages/router-core/src/router.ts`:
- Around line 2375-2407: The current forced stale reload decision uses
previousLocation.href === next.href which incorrectly treats same-URL
state/history navigations as a forced reload; change loadMatches call to rely on
an explicit opt-in flag (e.g., opts?.forceStaleReload) instead of deriving it
from href equality, update the loadMatches parameter name if needed to match the
explicit flag (forceStaleReload) and update all call sites that intentionally
want to force revalidation to pass that flag, and add a regression test for a
same-href state-only navigation (no loader-key change) to assert loaders are not
revalidated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1eeae827-f075-41a8-8c79-112ff22bddac

📥 Commits

Reviewing files that changed from the base of the PR and between 88fcbec and 68f4530.

📒 Files selected for processing (15)
  • docs/router/api/router/RouterType.md
  • docs/router/guide/data-loading.md
  • packages/react-router/src/fileRoute.ts
  • packages/react-router/src/route.tsx
  • packages/react-router/tests/store-updates-during-navigation.test.tsx
  • packages/router-core/src/Matches.ts
  • packages/router-core/src/load-matches.ts
  • packages/router-core/src/router.ts
  • packages/router-core/tests/load.test.ts
  • packages/solid-router/src/fileRoute.ts
  • packages/solid-router/src/route.tsx
  • packages/solid-router/tests/store-updates-during-navigation.test.tsx
  • packages/vue-router/src/fileRoute.ts
  • packages/vue-router/src/route.ts
  • packages/vue-router/tests/store-updates-during-navigation.test.tsx

@codspeed-hq
Copy link

codspeed-hq bot commented Mar 8, 2026

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing fix-stale-loader-unrelated (79eec14) with main (7003ccb)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (88fcbec) during the generation of this report, so 7003ccb was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

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.

♻️ Duplicate comments (2)
packages/react-router/src/fileRoute.ts (1)

232-234: ⚠️ Potential issue | 🟡 Minor

Keep LazyRoute.useRouteContext() pinned to its route ID.

Line 233 spreads opts after from, so callers can still override the bound route at runtime. Please flip the merge order so this.options.id always wins.

Suggested fix
   useRouteContext: UseRouteContextRoute<TRoute['id']> = (opts) => {
-    return useRouteContext({ from: this.options.id, ...(opts as any) })
+    return useRouteContext({ ...(opts as any), from: this.options.id })
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-router/src/fileRoute.ts` around lines 232 - 234, The current
UseRouteContextRoute implementation spreads caller opts after setting from, so
callers can override the pinned route ID; update UseRouteContextRoute
(LazyRoute.useRouteContext) to spread opts first and then set from:
this.options.id (e.g., call useRouteContext({ ...(opts as any), from:
this.options.id })) so the route ID from this.options.id always takes
precedence.
packages/react-router/src/route.tsx (1)

117-119: ⚠️ Potential issue | 🟡 Minor

Keep the bound useRouteContext helpers route-scoped.

Lines 118, 273, and 545 spread opts after from, so RouteApi, Route, and RootRoute callers can override the bound ID and read another route's context. These wrappers should mirror the other bound helpers and make this.id win.

Suggested fix
   useRouteContext: UseRouteContextRoute<TId> = (opts) => {
-    return useRouteContext({ from: this.id as any, ...(opts as any) })
+    return useRouteContext({ ...(opts as any), from: this.id as any })
   }

   useRouteContext: UseRouteContextRoute<TId> = (opts?) => {
-    return useRouteContext({ from: this.id, ...(opts as any) })
+    return useRouteContext({ ...(opts as any), from: this.id })
   }

   useRouteContext: UseRouteContextRoute<RootRouteId> = (opts) => {
-    return useRouteContext({ from: this.id, ...(opts as any) })
+    return useRouteContext({ ...(opts as any), from: this.id })
   }

Also applies to: 272-274, 544-546

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

In `@packages/react-router/src/route.tsx` around lines 117 - 119, The bound
useRouteContext helper currently spreads opts after setting from, allowing
callers to override the bound ID; update the wrappers (the
UseRouteContextRoute<TId> implementations used in RouteApi, Route, and
RootRoute) to make this.id win by spreading opts first and then setting from:
this.id (i.e. return useRouteContext({ ...(opts as any), from: this.id as any
})) so the bound route ID cannot be overridden.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/react-router/src/fileRoute.ts`:
- Around line 232-234: The current UseRouteContextRoute implementation spreads
caller opts after setting from, so callers can override the pinned route ID;
update UseRouteContextRoute (LazyRoute.useRouteContext) to spread opts first and
then set from: this.options.id (e.g., call useRouteContext({ ...(opts as any),
from: this.options.id })) so the route ID from this.options.id always takes
precedence.

In `@packages/react-router/src/route.tsx`:
- Around line 117-119: The bound useRouteContext helper currently spreads opts
after setting from, allowing callers to override the bound ID; update the
wrappers (the UseRouteContextRoute<TId> implementations used in RouteApi, Route,
and RootRoute) to make this.id win by spreading opts first and then setting
from: this.id (i.e. return useRouteContext({ ...(opts as any), from: this.id as
any })) so the bound route ID cannot be overridden.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 387f50b4-f462-4c59-91f1-d241aa656000

📥 Commits

Reviewing files that changed from the base of the PR and between 68f4530 and d1f477b.

📒 Files selected for processing (2)
  • packages/react-router/src/fileRoute.ts
  • packages/react-router/src/route.tsx

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/load-matches.ts (1)

817-831: ⚠️ Potential issue | 🟠 Major

Sync context before returning stale UI during async revalidation.

This branch starts runLoader in the background, but match.context is left stale until that loader finishes. Since beforeLoad has already updated __beforeLoadContext, consumers can observe the previous route context while stale data is being rendered. Calling syncMatchContext(inner, matchId, index) before launching the async reload keeps context aligned with the new navigation.

Proposed fix
     } else if (loaderShouldRunAsync && !inner.sync) {
+      syncMatchContext(inner, matchId, index)
       loaderIsRunningAsync = true
       ;(async () => {
         try {
           await runLoader(inner, matchPromises, matchId, index, route)
           const match = inner.router.getMatch(matchId)!
🤖 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 817 - 831, When
launching the background async loader in the branch guarded by
loaderShouldRunAsync && !inner.sync, ensure the match context is synced before
returning stale UI: call syncMatchContext(inner, matchId, index) right before
starting the async IIFE so match.context and any __beforeLoadContext updates are
applied immediately; keep the existing async runLoader(inner, matchPromises,
matchId, index, route) invocation and its try/catch/redirect handling unchanged,
but remove the window where match.context remains stale by invoking
syncMatchContext first.
🧹 Nitpick comments (1)
e2e/react-start/spa-mode/tests/app.spec.ts (1)

44-48: Minor: Explicit type annotations are redundant but acceptable.

The { page: Page } type annotations are technically redundant since Playwright's test fixtures already provide proper typing through @tanstack/router-e2e-utils. However, this doesn't cause any issues and can improve IDE support in some configurations.

Also applies to: 64-68

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

In `@e2e/react-start/spa-mode/tests/app.spec.ts` around lines 44 - 48, Remove the
redundant explicit TypeScript fixture annotations in the test callbacks —
replace the parameter destructuring `async ({ page }: { page: Page }) =>` with
`async ({ page }) =>` for the tests named "directly visiting prerendered
/posts/1" and the similar test at the second occurrence (the other test using
the same `{ page: Page }` annotation); this keeps Playwright's built-in typings
via `@tanstack/router-e2e-utils` while avoiding duplicate type declarations and
IDE noise.
🤖 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 807-815: The fallback check comparing prevMatch.id !== match.id
never fires because both vars come from router.getMatch(matchId); change the
comparison to use the previous match id captured from the prior navigation/state
instead of the current store entry. Locate where prevMatch and match are
obtained (calls to router.getMatch(matchId)) and replace the identity comparison
with something like prevMatchId !== match.id where prevMatchId is read from the
previous router state/navigation snapshot (the value that was stored before
updating the current matches). Keep the rest of the logic
(staleMatchShouldReload, inner.forceStaleReload, match.cause,
loaderShouldRunAsync) unchanged so the reload decision correctly detects a
changed route instance even when the match entry stays mounted.

---

Outside diff comments:
In `@packages/router-core/src/load-matches.ts`:
- Around line 817-831: When launching the background async loader in the branch
guarded by loaderShouldRunAsync && !inner.sync, ensure the match context is
synced before returning stale UI: call syncMatchContext(inner, matchId, index)
right before starting the async IIFE so match.context and any
__beforeLoadContext updates are applied immediately; keep the existing async
runLoader(inner, matchPromises, matchId, index, route) invocation and its
try/catch/redirect handling unchanged, but remove the window where match.context
remains stale by invoking syncMatchContext first.

---

Nitpick comments:
In `@e2e/react-start/spa-mode/tests/app.spec.ts`:
- Around line 44-48: Remove the redundant explicit TypeScript fixture
annotations in the test callbacks — replace the parameter destructuring `async
({ page }: { page: Page }) =>` with `async ({ page }) =>` for the tests named
"directly visiting prerendered /posts/1" and the similar test at the second
occurrence (the other test using the same `{ page: Page }` annotation); this
keeps Playwright's built-in typings via `@tanstack/router-e2e-utils` while
avoiding duplicate type declarations and IDE noise.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1774b631-d1d0-4ba0-a7b5-20ba9b64592a

📥 Commits

Reviewing files that changed from the base of the PR and between d1f477b and f4140b5.

📒 Files selected for processing (11)
  • e2e/react-start/spa-mode/tests/app.spec.ts
  • e2e/solid-start/spa-mode/tests/app.spec.ts
  • e2e/vue-start/spa-mode/tests/app.spec.ts
  • packages/react-router/src/fileRoute.ts
  • packages/react-router/src/route.tsx
  • packages/router-core/src/load-matches.ts
  • packages/router-core/src/router.ts
  • packages/solid-router/src/fileRoute.ts
  • packages/solid-router/src/route.tsx
  • packages/vue-router/src/fileRoute.ts
  • packages/vue-router/src/route.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/solid-router/src/route.tsx
  • packages/router-core/src/router.ts
  • packages/react-router/src/fileRoute.ts
  • packages/solid-router/src/fileRoute.ts
  • packages/react-router/src/route.tsx
  • packages/vue-router/src/fileRoute.ts

@schiller-manuel schiller-manuel merged commit 31ed0a9 into main Mar 9, 2026
15 checks passed
@schiller-manuel schiller-manuel deleted the fix-stale-loader-unrelated branch March 9, 2026 19:02
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.

2 participants