From c1d7217093208babff7ba1e6a09455f650d32184 Mon Sep 17 00:00:00 2001
From: Sheraff <fpellet@ensc.fr>
Date: Sun, 15 Feb 2026 18:29:41 +0100
Subject: [PATCH 1/2] refactor(router-core): remove cachedMatches from reactive
 store

---
 docs/router/api/router/RouterStateType.md     |   2 +
 docs/router/api/router/RouterType.md          |   1 +
 docs/router/api/router/useRouterStateHook.md  |   2 +
 .../store-updates-during-navigation.test.tsx  |   8 +-
 packages/router-core/src/router.ts            | 122 ++++++++----------
 packages/router-core/tests/load.test.ts       |  20 +--
 .../src/BaseTanStackRouterDevtoolsPanel.tsx   |  55 +-------
 .../router-devtools-core/src/useStyles.tsx    |   5 -
 .../store-updates-during-navigation.test.tsx  |   8 +-
 .../store-updates-during-navigation.test.tsx  |   6 +-
 10 files changed, 83 insertions(+), 146 deletions(-)

diff --git a/docs/router/api/router/RouterStateType.md b/docs/router/api/router/RouterStateType.md
index 65dd31db9a..96db3c72c5 100644
--- a/docs/router/api/router/RouterStateType.md
+++ b/docs/router/api/router/RouterStateType.md
@@ -28,6 +28,8 @@ If you previously used `router.state.pendingMatches`, use one of these patterns
 - `router.state.isLoading` to detect active loading work.
 - `router.matchRoute(...)` with `{ pending: true }` when you need to match against the pending location.
 
+If you previously used `router.state.cachedMatches`, note that cached matches are now internal router state and are no longer exposed on `RouterState`.
+
 ### `status` property
 
 - Type: `'pending' | 'idle'`
diff --git a/docs/router/api/router/RouterType.md b/docs/router/api/router/RouterType.md
index c5736638a0..27c77f911b 100644
--- a/docs/router/api/router/RouterType.md
+++ b/docs/router/api/router/RouterType.md
@@ -152,6 +152,7 @@ Invalidates route matches by forcing their `beforeLoad` and `load` functions to
 Remove cached route matches.
 
 - Type: `(opts?: {filter?: (d: MakeRouteMatchUnion<TRouter>) => boolean}) => void`
+- Cached matches are stored internally and are no longer exposed on `router.state`.
 - if `filter` is not supplied, all cached matches will be removed
 - if `filter` is supplied, only matches for which `filter` returns `true` will be removed.
 
diff --git a/docs/router/api/router/useRouterStateHook.md b/docs/router/api/router/useRouterStateHook.md
index bcded1d415..5a17ce2323 100644
--- a/docs/router/api/router/useRouterStateHook.md
+++ b/docs/router/api/router/useRouterStateHook.md
@@ -7,6 +7,8 @@ The `useRouterState` method is a hook that returns the current internal state of
 
 > [!TIP]
 > If you want to access the current location or the current matches, you should try out the [`useLocation`](./useLocationHook.md) and [`useMatches`](./useMatchesHook.md) hooks first. These hooks are designed to be more ergonomic and easier to use than accessing the router state directly.
+>
+> Cached route matches are internal router state and are not exposed through `useRouterState`.
 
 ## useRouterState options
 
diff --git a/packages/react-router/tests/store-updates-during-navigation.test.tsx b/packages/react-router/tests/store-updates-during-navigation.test.tsx
index 69f60ceb60..e02c4e9eb2 100644
--- a/packages/react-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx
@@ -155,7 +155,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
     // This number should be as small as possible to minimize the amount of work
     // that needs to be done during a navigation.
     // Any change that increases this number should be investigated.
-    expect(updates).toBe(4)
+    expect(updates).toBe(1)
   })
 
   test('sync beforeLoad', async () => {
@@ -225,8 +225,8 @@ describe("Store doesn't update *too many* times during navigation", () => {
     // This number should be as small as possible to minimize the amount of work
     // that needs to be done during a navigation.
     // Any change that increases this number should be investigated.
-    expect(updates).toBeGreaterThanOrEqual(9)
-    expect(updates).toBeLessThanOrEqual(11)
+    expect(updates).toBeGreaterThanOrEqual(5)
+    expect(updates).toBeLessThanOrEqual(7)
   })
 
   test('navigate, w/ preloaded & async loaders', async () => {
@@ -293,6 +293,6 @@ describe("Store doesn't update *too many* times during navigation", () => {
     // This number should be as small as possible to minimize the amount of work
     // that needs to be done during a navigation.
     // Any change that increases this number should be investigated.
-    expect(updates).toBe(1)
+    expect(updates).toBe(0)
   })
 })
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index a8bbb6c8aa..486355e0af 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -531,7 +531,6 @@ export interface RouterState<
   isLoading: boolean
   isTransitioning: boolean
   matches: Array<TRouteMatch>
-  cachedMatches: Array<TRouteMatch>
   location: ParsedLocation<FullSearchSchema<TRouteTree>>
   resolvedLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>
   statusCode: number
@@ -963,6 +962,7 @@ export class RouterCore<
   latestLocation!: ParsedLocation<FullSearchSchema<TRouteTree>>
   pendingBuiltLocation?: ParsedLocation<FullSearchSchema<TRouteTree>>
   private pendingMatchesInternal?: Array<AnyRouteMatch>
+  private cachedMatchesInternal: Array<AnyRouteMatch> = []
   basepath!: string
   routeTree!: TRouteTree
   routesById!: RoutesById<TRouteTree>
@@ -1017,6 +1017,25 @@ export class RouterCore<
     return !!this.options.isPrerendering
   }
 
+  private setCachedMatchesInternal = (matches: Array<AnyRouteMatch>) => {
+    this.cachedMatchesInternal = matches.filter(
+      (match) => match.status !== 'redirected',
+    )
+  }
+
+  private updateCachedMatchesInternal = (
+    updater: (matches: Array<AnyRouteMatch>) => Array<AnyRouteMatch>,
+  ) => {
+    this.setCachedMatchesInternal(updater(this.cachedMatchesInternal))
+  }
+
+  private appendCachedMatchesInternal = (matches: Array<AnyRouteMatch>) => {
+    if (!matches.length) {
+      return
+    }
+    this.setCachedMatchesInternal([...this.cachedMatchesInternal, ...matches])
+  }
+
   update: UpdateFn<
     TRouteTree,
     TTrailingSlashOption,
@@ -1117,16 +1136,7 @@ export class RouterCore<
           getInitialRouterState(this.latestLocation),
         ) as unknown as Store<any>
       } else {
-        this.__store = new Store(getInitialRouterState(this.latestLocation), {
-          onUpdate: () => {
-            this.__store.state = {
-              ...this.state,
-              cachedMatches: this.state.cachedMatches.filter(
-                (d) => !['redirected'].includes(d.status),
-              ),
-            }
-          },
-        })
+        this.__store = new Store(getInitialRouterState(this.latestLocation))
 
         setupScrollRestoration(this)
       }
@@ -2342,6 +2352,11 @@ export class RouterCore<
     // Match the routes
     const pendingMatches = this.matchRoutes(this.latestLocation)
     this.pendingMatchesInternal = pendingMatches
+    const pendingMatchIds = new Set(pendingMatches.map((match) => match.id))
+
+    this.updateCachedMatchesInternal((matches) =>
+      matches.filter((match) => !pendingMatchIds.has(match.id)),
+    )
 
     // Ingest the new matches
     this.__store.setState((s) => ({
@@ -2350,10 +2365,6 @@ export class RouterCore<
       statusCode: 200,
       isLoading: true,
       location: this.latestLocation,
-      // If a cached moved to pendingMatches, remove it from cachedMatches
-      cachedMatches: s.cachedMatches.filter(
-        (d) => !pendingMatches.some((e) => e.id === d.id),
-      ),
     }))
   }
 
@@ -2429,21 +2440,15 @@ export class RouterCore<
                         isLoading: false,
                         loadedAt: Date.now(),
                         matches: newMatches,
-                        /**
-                         * When committing new matches, cache any exiting matches that are still usable.
-                         * Routes that resolved with `status: 'error'` or `status: 'notFound'` are
-                         * deliberately excluded from `cachedMatches` so that subsequent invalidations
-                         * or reloads re-run their loaders instead of reusing the failed/not-found data.
-                         */
-                        cachedMatches: [
-                          ...s.cachedMatches,
-                          ...exitingMatches.filter(
-                            (d) =>
-                              d.status !== 'error' && d.status !== 'notFound',
-                          ),
-                        ],
                       }
                     })
+                    this.appendCachedMatchesInternal(
+                      exitingMatches.filter(
+                        (match) =>
+                          match.status !== 'error' &&
+                          match.status !== 'notFound',
+                      ),
+                    )
                     this.pendingMatchesInternal = undefined
                     this.clearExpiredCache()
                   })
@@ -2593,19 +2598,20 @@ export class RouterCore<
         return
       }
 
-      const matchesKey = this.state.matches.some((d) => d.id === id)
-        ? 'matches'
-        : this.state.cachedMatches.some((d) => d.id === id)
-          ? 'cachedMatches'
-          : ''
-
-      if (matchesKey) {
+      if (this.state.matches.some((d) => d.id === id)) {
         this.__store.setState((s) => ({
           ...s,
-          [matchesKey]: s[matchesKey]?.map((d) =>
+          matches: s.matches.map((d) =>
             d.id === id ? updater(d) : d,
           ),
         }))
+        return
+      }
+
+      if (this.cachedMatchesInternal.some((d) => d.id === id)) {
+        this.updateCachedMatchesInternal((matches) =>
+          matches.map((d) => (d.id === id ? updater(d) : d)),
+        )
       }
     })
   }
@@ -2613,7 +2619,7 @@ export class RouterCore<
   getMatch: GetMatchFn = (matchId: string): AnyRouteMatch | undefined => {
     const findFn = (d: { id: string }) => d.id === matchId
     return (
-      this.state.cachedMatches.find(findFn) ??
+      this.cachedMatchesInternal.find(findFn) ??
       this.pendingMatchesInternal?.find(findFn) ??
       this.state.matches.find(findFn)
     )
@@ -2636,8 +2642,8 @@ export class RouterCore<
       TDehydrated
     >
   > = (opts) => {
-    const invalidate = (d: MakeRouteMatch<TRouteTree>) => {
-      if (opts?.filter?.(d as MakeRouteMatchUnion<this>) ?? true) {
+    const invalidate = <TMatch extends AnyRouteMatch>(d: TMatch): TMatch => {
+      if (opts?.filter?.(d as unknown as MakeRouteMatchUnion<this>) ?? true) {
         return {
           ...d,
           invalid: true,
@@ -2646,17 +2652,17 @@ export class RouterCore<
           d.status === 'notFound'
             ? ({ status: 'pending', error: undefined } as const)
             : undefined),
-        }
+        } as TMatch
       }
       return d
     }
 
     this.pendingMatchesInternal = this.pendingMatchesInternal?.map(invalidate)
+    this.updateCachedMatchesInternal((matches) => matches.map(invalidate))
 
     this.__store.setState((s) => ({
       ...s,
       matches: s.matches.map(invalidate),
-      cachedMatches: s.cachedMatches.map(invalidate),
     }))
 
     this.shouldViewTransition = false
@@ -2712,21 +2718,11 @@ export class RouterCore<
   clearCache: ClearCacheFn<this> = (opts) => {
     const filter = opts?.filter
     if (filter !== undefined) {
-      this.__store.setState((s) => {
-        return {
-          ...s,
-          cachedMatches: s.cachedMatches.filter(
-            (m) => !filter(m as MakeRouteMatchUnion<this>),
-          ),
-        }
-      })
+      this.updateCachedMatchesInternal((matches) =>
+        matches.filter((m) => !filter(m as MakeRouteMatchUnion<this>)),
+      )
     } else {
-      this.__store.setState((s) => {
-        return {
-          ...s,
-          cachedMatches: [],
-        }
-      })
+      this.setCachedMatchesInternal([])
     }
   }
 
@@ -2780,20 +2776,13 @@ export class RouterCore<
 
     const loadedMatchIds = new Set([
       ...activeMatchIds,
-      ...this.state.cachedMatches.map((d) => d.id),
+      ...this.cachedMatchesInternal.map((d) => d.id),
     ])
 
     // If the matches are already loaded, we need to add them to the cachedMatches
-    batch(() => {
-      matches.forEach((match) => {
-        if (!loadedMatchIds.has(match.id)) {
-          this.__store.setState((s) => ({
-            ...s,
-            cachedMatches: [...(s.cachedMatches as any), match],
-          }))
-        }
-      })
-    })
+    this.appendCachedMatchesInternal(
+      matches.filter((match) => !loadedMatchIds.has(match.id)),
+    )
 
     try {
       matches = await loadMatches({
@@ -2941,7 +2930,6 @@ export function getInitialRouterState(
     resolvedLocation: undefined,
     location,
     matches: [],
-    cachedMatches: [],
     statusCode: 200,
   }
 }
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index eb2b1ce476..4f26f246f5 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -107,8 +107,8 @@ describe('beforeLoad skip or exec', () => {
     const beforeLoad = vi.fn()
     const router = setup({ beforeLoad })
     await router.preloadRoute({ to: '/foo' })
-    expect(router.state.cachedMatches).toEqual(
-      expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]),
+    expect(router.getMatch('/foo/foo')).toEqual(
+      expect.objectContaining({ id: '/foo/foo' }),
     )
     await sleep(10)
     await router.navigate({ to: '/foo' })
@@ -121,8 +121,8 @@ describe('beforeLoad skip or exec', () => {
     const router = setup({ beforeLoad })
     router.preloadRoute({ to: '/foo' })
     await Promise.resolve()
-    expect(router.state.cachedMatches).toEqual(
-      expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]),
+    expect(router.getMatch('/foo/foo')).toEqual(
+      expect.objectContaining({ id: '/foo/foo' }),
     )
     await router.navigate({ to: '/foo' })
 
@@ -290,8 +290,8 @@ describe('loader skip or exec', () => {
     const loader = vi.fn()
     const router = setup({ loader })
     await router.preloadRoute({ to: '/foo' })
-    expect(router.state.cachedMatches).toEqual(
-      expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]),
+    expect(router.getMatch('/foo/foo')).toEqual(
+      expect.objectContaining({ id: '/foo/foo' }),
     )
     await sleep(10)
     await router.navigate({ to: '/foo' })
@@ -303,8 +303,8 @@ describe('loader skip or exec', () => {
     const loader = vi.fn()
     const router = setup({ loader, staleTime: 1000 })
     await router.preloadRoute({ to: '/foo' })
-    expect(router.state.cachedMatches).toEqual(
-      expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]),
+    expect(router.getMatch('/foo/foo')).toEqual(
+      expect.objectContaining({ id: '/foo/foo' }),
     )
     await sleep(10)
     await router.navigate({ to: '/foo' })
@@ -317,8 +317,8 @@ describe('loader skip or exec', () => {
     const router = setup({ loader })
     router.preloadRoute({ to: '/foo' })
     await Promise.resolve()
-    expect(router.state.cachedMatches).toEqual(
-      expect.arrayContaining([expect.objectContaining({ id: '/foo/foo' })]),
+    expect(router.getMatch('/foo/foo')).toEqual(
+      expect.objectContaining({ id: '/foo/foo' }),
     )
     await router.navigate({ to: '/foo' })
 
diff --git a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx
index ebdfdf15b0..ea8786ee15 100644
--- a/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx
+++ b/packages/router-devtools-core/src/BaseTanStackRouterDevtoolsPanel.tsx
@@ -316,11 +316,7 @@ export const BaseTanStackRouterDevtoolsPanel =
     })
 
     const activeMatch = createMemo(() => {
-      const matches = [
-        ...pendingMatches(),
-        ...routerState().matches,
-        ...routerState().cachedMatches,
-      ]
+      const matches = [...pendingMatches(), ...routerState().matches]
       return matches.find(
         (d) => d.routeId === activeId() || d.id === activeId(),
       )
@@ -613,49 +609,6 @@ export const BaseTanStackRouterDevtoolsPanel =
               </Switch>
             </div>
           </div>
-          {routerState().cachedMatches.length ? (
-            <div class={styles().cachedMatchesContainer}>
-              <div class={styles().detailsHeader}>
-                <div>Cached Matches</div>
-                <div class={styles().detailsHeaderInfo}>
-                  age / staleTime / gcTime
-                </div>
-              </div>
-              <div>
-                {routerState().cachedMatches.map((match: any) => {
-                  return (
-                    <div
-                      role="button"
-                      aria-label={`Open match details for ${match.id}`}
-                      onClick={() =>
-                        setActiveId(activeId() === match.id ? '' : match.id)
-                      }
-                      class={cx(styles().matchRow(match === activeMatch()))}
-                    >
-                      <div
-                        class={cx(
-                          styles().matchIndicator(getStatusColor(match)),
-                        )}
-                      />
-                      <NavigateLink
-                        left={
-                          <NavigateButton
-                            to={match.pathname}
-                            params={match.params}
-                            search={match.search}
-                            router={router}
-                          />
-                        }
-                        right={<AgeTicker match={match} router={router} />}
-                      >
-                        <code class={styles().matchID}>{`${match.id}`}</code>
-                      </NavigateLink>
-                    </div>
-                  )
-                })}
-              </div>
-            </div>
-          ) : null}
         </div>
         {activeMatch() && activeMatch()?.status ? (
           <div class={styles().thirdContainer}>
@@ -688,11 +641,7 @@ export const BaseTanStackRouterDevtoolsPanel =
                       (d: any) => d.id === activeMatch()?.id,
                     )
                       ? 'Pending'
-                      : routerState().matches.find(
-                            (d: any) => d.id === activeMatch()?.id,
-                          )
-                        ? 'Active'
-                        : 'Cached'}
+                      : 'Active'}
                   </div>
                 </div>
                 <div class={styles().matchDetailsInfoLabel}>
diff --git a/packages/router-devtools-core/src/useStyles.tsx b/packages/router-devtools-core/src/useStyles.tsx
index 5524ed0ae8..129172ae0f 100644
--- a/packages/router-devtools-core/src/useStyles.tsx
+++ b/packages/router-devtools-core/src/useStyles.tsx
@@ -399,11 +399,6 @@ const stylesFactory = (shadowDOMTarget?: ShadowRoot) => {
       flex: 1 1 auto;
       overflow-y: auto;
     `,
-    cachedMatchesContainer: css`
-      flex: 1 1 auto;
-      overflow-y: auto;
-      max-height: 50%;
-    `,
     historyContainer: css`
       display: flex;
       flex: 1 1 auto;
diff --git a/packages/solid-router/tests/store-updates-during-navigation.test.tsx b/packages/solid-router/tests/store-updates-during-navigation.test.tsx
index d377346ce8..8a67b969a7 100644
--- a/packages/solid-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/solid-router/tests/store-updates-during-navigation.test.tsx
@@ -156,7 +156,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
     // that needs to be done during a navigation.
     // Any change that increases this number should be investigated.
     // Note: Solid has different update counts than React due to different reactivity
-    expect(updates).toBe(6)
+    expect(updates).toBe(2)
   })
 
   test('sync beforeLoad', async () => {
@@ -226,8 +226,8 @@ describe("Store doesn't update *too many* times during navigation", () => {
     // This number should be as small as possible to minimize the amount of work
     // that needs to be done during a navigation.
     // Any change that increases this number should be investigated.
-    expect(updates).toBeGreaterThanOrEqual(8)
-    expect(updates).toBeLessThanOrEqual(10)
+    expect(updates).toBeGreaterThanOrEqual(3)
+    expect(updates).toBeLessThanOrEqual(5)
   })
 
   test('navigate, w/ preloaded & async loaders', async () => {
@@ -295,6 +295,6 @@ describe("Store doesn't update *too many* times during navigation", () => {
     // This number should be as small as possible to minimize the amount of work
     // that needs to be done during a navigation.
     // Any change that increases this number should be investigated.
-    expect(updates).toBe(2)
+    expect(updates).toBe(0)
   })
 })
diff --git a/packages/vue-router/tests/store-updates-during-navigation.test.tsx b/packages/vue-router/tests/store-updates-during-navigation.test.tsx
index f82b01799d..21429c6f83 100644
--- a/packages/vue-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/vue-router/tests/store-updates-during-navigation.test.tsx
@@ -158,7 +158,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
     // that needs to be done during a navigation.
     // Any change that increases this number should be investigated.
     // Note: Vue has different update counts than React/Solid due to different reactivity
-    expect(updates).toBe(10)
+    expect(updates).toBe(8)
   })
 
   test('sync beforeLoad', async () => {
@@ -234,8 +234,8 @@ describe("Store doesn't update *too many* times during navigation", () => {
     // that needs to be done during a navigation.
     // Any change that increases this number should be investigated.
     // Note: Vue has different update counts than React/Solid due to different reactivity
-    expect(updates).toBeGreaterThanOrEqual(33)
-    expect(updates).toBeLessThanOrEqual(35)
+    expect(updates).toBeGreaterThanOrEqual(30)
+    expect(updates).toBeLessThanOrEqual(32)
   })
 
   test('navigate, w/ preloaded & async loaders', async () => {

From 8dfbe98bd7a42abd4f4866599dc03b8126c857d2 Mon Sep 17 00:00:00 2001
From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com>
Date: Sun, 15 Feb 2026 17:31:08 +0000
Subject: [PATCH 2/2] ci: apply automated fixes

---
 packages/router-core/src/router.ts | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 486355e0af..56290f4b96 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -2601,9 +2601,7 @@ export class RouterCore<
       if (this.state.matches.some((d) => d.id === id)) {
         this.__store.setState((s) => ({
           ...s,
-          matches: s.matches.map((d) =>
-            d.id === id ? updater(d) : d,
-          ),
+          matches: s.matches.map((d) => (d.id === id ? updater(d) : d)),
         }))
         return
       }
