diff --git a/docs/router/api/router/RouterType.md b/docs/router/api/router/RouterType.md
index 10734986b3..0b829b85e0 100644
--- a/docs/router/api/router/RouterType.md
+++ b/docs/router/api/router/RouterType.md
@@ -159,7 +159,7 @@ Remove cached route matches.
 
 Loads all of the currently matched route matches and resolves when they are all loaded and ready to be rendered.
 
-> ⚠️⚠️⚠️ **`router.load()` respects `route.staleTime` and will not forcefully reload a route match if it is still fresh. If you need to forcefully reload a route match, use `router.invalidate()` instead.**
+> ⚠️⚠️⚠️ **`router.load()` respects `route.staleTime`: fresh matches stay fresh, but stale matches are revalidated even if their loader key did not change. If you need to forcefully reload all active matches regardless of freshness, use `router.invalidate()` instead.**
 
 - Type: `(opts?: {sync?: boolean}) => Promise<void>`
 - if `sync` is true, the promise returned by this function will only resolve once all loaders have finished.
diff --git a/docs/router/guide/data-loading.md b/docs/router/guide/data-loading.md
index 80e85c1366..85e3c0a423 100644
--- a/docs/router/guide/data-loading.md
+++ b/docs/router/guide/data-loading.md
@@ -154,7 +154,7 @@ To control router dependencies and "freshness", TanStack Router provides a pleth
 
 ### ⚠️ Some Important Defaults
 
-- By default, the `staleTime` is set to `0`, meaning that the route's data will always be considered stale and will always be reloaded in the background when the route is rematched.
+- By default, the `staleTime` is set to `0`, meaning that the route's data is immediately considered stale. Stale matches are reloaded in the background when the route is entered again, when its loader key changes (path params used by the route or `loaderDeps`), or when `router.load()` is called explicitly.
 - By default, a previously preloaded route is considered fresh for **30 seconds**. This means if a route is preloaded, then preloaded again within 30 seconds, the second preload will be ignored. This prevents unnecessary preloads from happening too frequently. **When a route is loaded normally, the standard `staleTime` is used.**
 - By default, the `gcTime` is set to **30 minutes**, meaning that any route data that has not been accessed in 30 minutes will be garbage collected and removed from the cache.
 - `router.invalidate()` will force all active routes to reload their loaders immediately and mark every cached route's data as stale.
@@ -201,7 +201,7 @@ export const Route = createFileRoute('/posts')({
 
 ### Using `staleTime` to control how long data is considered fresh
 
-By default, `staleTime` for navigations is set to `0`ms (and 30 seconds for preloads) which means that the route's data will always be considered stale and will always be reloaded in the background when the route is matched and navigated to.
+By default, `staleTime` for navigations is set to `0`ms (and 30 seconds for preloads) which means that the route's data will always be considered stale. When a stale route is entered again, its loader key changes, or `router.load()` is called explicitly, the route will reload in the background.
 
 **This is a good default for most use cases, but you may find that some route data is more static or potentially expensive to load.** In these cases, you can use the `staleTime` option to control how long the route's data is considered fresh for navigations. Let's take a look at an example:
 
diff --git a/e2e/react-start/spa-mode/tests/app.spec.ts b/e2e/react-start/spa-mode/tests/app.spec.ts
index c18a0ff65d..ad437c0353 100644
--- a/e2e/react-start/spa-mode/tests/app.spec.ts
+++ b/e2e/react-start/spa-mode/tests/app.spec.ts
@@ -5,9 +5,16 @@ import type { Page } from '@playwright/test'
 async function runTest(
   page: Page,
   expectedData: {
-    root: 'server' | 'client'
-    posts: 'server' | 'client'
-    postId: 'server' | 'client'
+    loader: {
+      root: 'server' | 'client'
+      posts: 'server' | 'client'
+      postId: 'server' | 'client'
+    }
+    context: {
+      root: 'server' | 'client'
+      posts: 'server' | 'client'
+      postId: 'server' | 'client'
+    }
   },
 ) {
   // wait for page to be loaded by waiting for the leaf route to be rendered
@@ -15,36 +22,64 @@ async function runTest(
 
   // check expectations
   await Promise.all(
-    Object.entries(expectedData).map(async ([route, expectedData]) => {
+    Object.entries(expectedData.loader).map(async ([route, expectedLoader]) => {
       await expect(page.getByTestId(`${route}-loader`)).toContainText(
-        expectedData,
-      )
-      await expect(page.getByTestId(`${route}-context`)).toContainText(
-        expectedData,
+        expectedLoader,
       )
     }),
   )
+  await Promise.all(
+    Object.entries(expectedData.context).map(
+      async ([route, expectedContext]) => {
+        await expect(page.getByTestId(`${route}-context`)).toContainText(
+          expectedContext,
+        )
+      },
+    ),
+  )
   await expect(page.getByTestId('router-isLoading')).toContainText('false')
   await expect(page.getByTestId('router-status')).toContainText('idle')
 }
 test.describe('SPA mode', () => {
-  test(`directly visiting prerendered /posts/1`, async ({ page }) => {
+  test(`directly visiting prerendered /posts/1`, async ({
+    page,
+  }: {
+    page: Page
+  }) => {
     await page.goto('/posts/1')
     await runTest(page, {
-      root: 'server',
-      posts: 'server',
-      postId: 'server',
+      loader: {
+        root: 'server',
+        posts: 'server',
+        postId: 'server',
+      },
+      context: {
+        root: 'server',
+        posts: 'server',
+        postId: 'server',
+      },
     })
   })
 
-  test(`client-side navigation to /posts/1`, async ({ page }) => {
+  test(`client-side navigation to /posts/1`, async ({
+    page,
+  }: {
+    page: Page
+  }) => {
     await page.goto('/')
     const testId = 'link-posts-1'
     await page.getByTestId(testId).click()
     await runTest(page, {
-      root: 'client',
-      posts: 'client',
-      postId: 'client',
+      loader: {
+        root: 'server',
+        posts: 'client',
+        postId: 'client',
+      },
+      context: {
+        root: 'client',
+        posts: 'client',
+        postId: 'client',
+      },
     })
   })
 })
diff --git a/e2e/solid-start/spa-mode/tests/app.spec.ts b/e2e/solid-start/spa-mode/tests/app.spec.ts
index c18a0ff65d..ad437c0353 100644
--- a/e2e/solid-start/spa-mode/tests/app.spec.ts
+++ b/e2e/solid-start/spa-mode/tests/app.spec.ts
@@ -5,9 +5,16 @@ import type { Page } from '@playwright/test'
 async function runTest(
   page: Page,
   expectedData: {
-    root: 'server' | 'client'
-    posts: 'server' | 'client'
-    postId: 'server' | 'client'
+    loader: {
+      root: 'server' | 'client'
+      posts: 'server' | 'client'
+      postId: 'server' | 'client'
+    }
+    context: {
+      root: 'server' | 'client'
+      posts: 'server' | 'client'
+      postId: 'server' | 'client'
+    }
   },
 ) {
   // wait for page to be loaded by waiting for the leaf route to be rendered
@@ -15,36 +22,64 @@ async function runTest(
 
   // check expectations
   await Promise.all(
-    Object.entries(expectedData).map(async ([route, expectedData]) => {
+    Object.entries(expectedData.loader).map(async ([route, expectedLoader]) => {
       await expect(page.getByTestId(`${route}-loader`)).toContainText(
-        expectedData,
-      )
-      await expect(page.getByTestId(`${route}-context`)).toContainText(
-        expectedData,
+        expectedLoader,
       )
     }),
   )
+  await Promise.all(
+    Object.entries(expectedData.context).map(
+      async ([route, expectedContext]) => {
+        await expect(page.getByTestId(`${route}-context`)).toContainText(
+          expectedContext,
+        )
+      },
+    ),
+  )
   await expect(page.getByTestId('router-isLoading')).toContainText('false')
   await expect(page.getByTestId('router-status')).toContainText('idle')
 }
 test.describe('SPA mode', () => {
-  test(`directly visiting prerendered /posts/1`, async ({ page }) => {
+  test(`directly visiting prerendered /posts/1`, async ({
+    page,
+  }: {
+    page: Page
+  }) => {
     await page.goto('/posts/1')
     await runTest(page, {
-      root: 'server',
-      posts: 'server',
-      postId: 'server',
+      loader: {
+        root: 'server',
+        posts: 'server',
+        postId: 'server',
+      },
+      context: {
+        root: 'server',
+        posts: 'server',
+        postId: 'server',
+      },
     })
   })
 
-  test(`client-side navigation to /posts/1`, async ({ page }) => {
+  test(`client-side navigation to /posts/1`, async ({
+    page,
+  }: {
+    page: Page
+  }) => {
     await page.goto('/')
     const testId = 'link-posts-1'
     await page.getByTestId(testId).click()
     await runTest(page, {
-      root: 'client',
-      posts: 'client',
-      postId: 'client',
+      loader: {
+        root: 'server',
+        posts: 'client',
+        postId: 'client',
+      },
+      context: {
+        root: 'client',
+        posts: 'client',
+        postId: 'client',
+      },
     })
   })
 })
diff --git a/e2e/vue-start/spa-mode/tests/app.spec.ts b/e2e/vue-start/spa-mode/tests/app.spec.ts
index c18a0ff65d..ad437c0353 100644
--- a/e2e/vue-start/spa-mode/tests/app.spec.ts
+++ b/e2e/vue-start/spa-mode/tests/app.spec.ts
@@ -5,9 +5,16 @@ import type { Page } from '@playwright/test'
 async function runTest(
   page: Page,
   expectedData: {
-    root: 'server' | 'client'
-    posts: 'server' | 'client'
-    postId: 'server' | 'client'
+    loader: {
+      root: 'server' | 'client'
+      posts: 'server' | 'client'
+      postId: 'server' | 'client'
+    }
+    context: {
+      root: 'server' | 'client'
+      posts: 'server' | 'client'
+      postId: 'server' | 'client'
+    }
   },
 ) {
   // wait for page to be loaded by waiting for the leaf route to be rendered
@@ -15,36 +22,64 @@ async function runTest(
 
   // check expectations
   await Promise.all(
-    Object.entries(expectedData).map(async ([route, expectedData]) => {
+    Object.entries(expectedData.loader).map(async ([route, expectedLoader]) => {
       await expect(page.getByTestId(`${route}-loader`)).toContainText(
-        expectedData,
-      )
-      await expect(page.getByTestId(`${route}-context`)).toContainText(
-        expectedData,
+        expectedLoader,
       )
     }),
   )
+  await Promise.all(
+    Object.entries(expectedData.context).map(
+      async ([route, expectedContext]) => {
+        await expect(page.getByTestId(`${route}-context`)).toContainText(
+          expectedContext,
+        )
+      },
+    ),
+  )
   await expect(page.getByTestId('router-isLoading')).toContainText('false')
   await expect(page.getByTestId('router-status')).toContainText('idle')
 }
 test.describe('SPA mode', () => {
-  test(`directly visiting prerendered /posts/1`, async ({ page }) => {
+  test(`directly visiting prerendered /posts/1`, async ({
+    page,
+  }: {
+    page: Page
+  }) => {
     await page.goto('/posts/1')
     await runTest(page, {
-      root: 'server',
-      posts: 'server',
-      postId: 'server',
+      loader: {
+        root: 'server',
+        posts: 'server',
+        postId: 'server',
+      },
+      context: {
+        root: 'server',
+        posts: 'server',
+        postId: 'server',
+      },
     })
   })
 
-  test(`client-side navigation to /posts/1`, async ({ page }) => {
+  test(`client-side navigation to /posts/1`, async ({
+    page,
+  }: {
+    page: Page
+  }) => {
     await page.goto('/')
     const testId = 'link-posts-1'
     await page.getByTestId(testId).click()
     await runTest(page, {
-      root: 'client',
-      posts: 'client',
-      postId: 'client',
+      loader: {
+        root: 'server',
+        posts: 'client',
+        postId: 'client',
+      },
+      context: {
+        root: 'client',
+        posts: 'client',
+        postId: 'client',
+      },
     })
   })
 })
diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts
index 678888bee5..402d7d67ca 100644
--- a/packages/react-router/src/fileRoute.ts
+++ b/packages/react-router/src/fileRoute.ts
@@ -8,6 +8,7 @@ import { useSearch } from './useSearch'
 import { useParams } from './useParams'
 import { useNavigate } from './useNavigate'
 import { useRouter } from './useRouter'
+import { useRouteContext } from './useRouteContext'
 import type { UseParamsRoute } from './useParams'
 import type { UseMatchRoute } from './useMatch'
 import type { UseSearchRoute } from './useSearch'
@@ -229,10 +230,7 @@ export class LazyRoute<TRoute extends AnyRoute> {
   }
 
   useRouteContext: UseRouteContextRoute<TRoute['id']> = (opts) => {
-    return useMatch({
-      from: this.options.id,
-      select: (d: any) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.options.id })
   }
 
   useSearch: UseSearchRoute<TRoute['id']> = (opts) => {
diff --git a/packages/react-router/src/route.tsx b/packages/react-router/src/route.tsx
index 1d82c18153..a5b15dd825 100644
--- a/packages/react-router/src/route.tsx
+++ b/packages/react-router/src/route.tsx
@@ -11,6 +11,7 @@ import { useParams } from './useParams'
 import { useSearch } from './useSearch'
 import { useNavigate } from './useNavigate'
 import { useMatch } from './useMatch'
+import { useRouteContext } from './useRouteContext'
 import { useRouter } from './useRouter'
 import { Link } from './link'
 import type {
@@ -114,10 +115,7 @@ export class RouteApi<
   }
 
   useRouteContext: UseRouteContextRoute<TId> = (opts) => {
-    return useMatch({
-      from: this.id as any,
-      select: (d) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.id as any })
   }
 
   useSearch: UseSearchRoute<TId> = (opts) => {
@@ -272,11 +270,7 @@ export class Route<
   }
 
   useRouteContext: UseRouteContextRoute<TId> = (opts?) => {
-    return useMatch({
-      ...opts,
-      from: this.id,
-      select: (d) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.id })
   }
 
   useSearch: UseSearchRoute<TId> = (opts) => {
@@ -548,11 +542,7 @@ export class RootRoute<
   }
 
   useRouteContext: UseRouteContextRoute<RootRouteId> = (opts) => {
-    return useMatch({
-      ...opts,
-      from: this.id,
-      select: (d) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.id })
   }
 
   useSearch: UseSearchRoute<RootRouteId> = (opts) => {
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 303df8a403..a2baa3cdd7 100644
--- a/packages/react-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/react-router/tests/store-updates-during-navigation.test.tsx
@@ -136,7 +136,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).toBeGreaterThanOrEqual(10) // WARN: this is flaky, and sometimes (rarely) is 12
+    expect(updates).toBeGreaterThanOrEqual(9) // WARN: this is flaky, and sometimes (rarely) is 12
     expect(updates).toBeLessThanOrEqual(13)
   })
 
@@ -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(5)
   })
 
   test('sync beforeLoad', async () => {
@@ -171,7 +171,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).toBeGreaterThanOrEqual(9) // WARN: this is flaky
+    expect(updates).toBeGreaterThanOrEqual(8) // WARN: this is flaky
     expect(updates).toBeLessThanOrEqual(12)
   })
 
@@ -183,7 +183,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).toBeGreaterThanOrEqual(6) // WARN: this is flaky, and sometimes (rarely) is 9
+    expect(updates).toBeGreaterThanOrEqual(5) // WARN: this is flaky, and sometimes (rarely) is 9
     expect(updates).toBeLessThanOrEqual(9)
   })
 
@@ -241,8 +241,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(7)
-    expect(updates).toBeLessThanOrEqual(8)
+    expect(updates).toBeGreaterThanOrEqual(8)
+    expect(updates).toBeLessThanOrEqual(9)
   })
 
   test('navigate, w/ preloaded & sync loaders', async () => {
@@ -258,7 +258,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(6)
+    expect(updates).toBe(8)
   })
 
   test('navigate, w/ previous navigation & async loader', async () => {
@@ -274,7 +274,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(5)
+    expect(updates).toBe(7)
   })
 
   test('preload a preloaded route w/ async loader', async () => {
@@ -292,6 +292,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(2)
   })
 })
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index 0f753f1ca3..3c859165d8 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -33,6 +33,7 @@ type InnerLoadContext = {
   updateMatch: UpdateMatchFn
   matches: Array<AnyRouteMatch>
   preload?: boolean
+  forceStaleReload?: boolean
   onReady?: () => Promise<void>
   sync?: boolean
 }
@@ -166,7 +167,10 @@ const shouldSkipLoader = (
   inner: InnerLoadContext,
   matchId: string,
 ): boolean => {
-  const match = inner.router.getMatch(matchId)!
+  const match = inner.router.getMatch(matchId)
+  if (!match) {
+    return true
+  }
   // upon hydration, we skip the loader if the match has been dehydrated on the server
   if (!(isServer ?? inner.router.isServer) && match._nonReactive.dehydrated) {
     return true
@@ -179,6 +183,21 @@ const shouldSkipLoader = (
   return false
 }
 
+const syncMatchContext = (
+  inner: InnerLoadContext,
+  matchId: string,
+  index: number,
+): void => {
+  const nextContext = buildMatchContext(inner, index)
+
+  inner.updateMatch(matchId, (prev) => {
+    return {
+      ...prev,
+      context: nextContext,
+    }
+  })
+}
+
 const handleSerialError = (
   inner: InnerLoadContext,
   index: number,
@@ -479,8 +498,6 @@ const executeBeforeLoad = (
 
     batch(() => {
       pending()
-      // Only store __beforeLoadContext here, don't update context yet
-      // Context will be updated in loadRouteMatch after loader completes
       inner.updateMatch(matchId, (prev) => ({
         ...prev,
         __beforeLoadContext: beforeLoadContext,
@@ -762,6 +779,7 @@ const loadRouteMatch = async (
   async function handleLoader(
     preload: boolean,
     prevMatch: AnyRouteMatch,
+    previousRouteMatchId: string | undefined,
     match: AnyRouteMatch,
     route: AnyRoute,
   ) {
@@ -787,8 +805,15 @@ const loadRouteMatch = async (
 
     // If the route is successful and still fresh, just resolve
     const { status, invalid } = match
+    const staleMatchShouldReload =
+      age > staleAge &&
+      (!!inner.forceStaleReload ||
+        match.cause === 'enter' ||
+        (previousRouteMatchId !== undefined &&
+          previousRouteMatchId !== match.id))
     loaderShouldRunAsync =
-      status === 'success' && (invalid || (shouldReload ?? age > staleAge))
+      status === 'success' &&
+      (invalid || (shouldReload ?? staleMatchShouldReload))
     if (preload && route.options.preload === false) {
       // Do nothing
     } else if (loaderShouldRunAsync && !inner.sync) {
@@ -808,6 +833,8 @@ const loadRouteMatch = async (
       })()
     } else if (status !== 'success' || (loaderShouldRunAsync && inner.sync)) {
       await runLoader(inner, matchPromises, matchId, index, route)
+    } else {
+      syncMatchContext(inner, matchId, index)
     }
   }
 
@@ -817,11 +844,22 @@ const loadRouteMatch = async (
   const route = inner.router.looseRoutesById[routeId]!
 
   if (shouldSkipLoader(inner, matchId)) {
+    const match = inner.router.getMatch(matchId)
+    if (!match) {
+      return inner.matches[index]!
+    }
+
+    syncMatchContext(inner, matchId, index)
+
     if (isServer ?? inner.router.isServer) {
       return inner.router.getMatch(matchId)!
     }
   } else {
     const prevMatch = inner.router.getMatch(matchId)! // This is where all of the stale-while-revalidate magic happens
+    const previousRouteMatchId =
+      inner.router.state.matches[index]?.routeId === routeId
+        ? inner.router.state.matches[index]!.id
+        : inner.router.state.matches.find((d) => d.routeId === routeId)?.id
     const preload = resolvePreload(inner, matchId)
 
     // there is a loaderPromise, so we are in the middle of a load
@@ -840,7 +878,13 @@ const loadRouteMatch = async (
       }
 
       if (match.status === 'pending') {
-        await handleLoader(preload, prevMatch, match, route)
+        await handleLoader(
+          preload,
+          prevMatch,
+          previousRouteMatchId,
+          match,
+          route,
+        )
       }
     } else {
       const nextPreload =
@@ -854,7 +898,7 @@ const loadRouteMatch = async (
         }))
       }
 
-      await handleLoader(preload, prevMatch, match, route)
+      await handleLoader(preload, prevMatch, previousRouteMatchId, match, route)
     }
   }
   const match = inner.router.getMatch(matchId)!
@@ -886,6 +930,7 @@ export async function loadMatches(arg: {
   location: ParsedLocation
   matches: Array<AnyRouteMatch>
   preload?: boolean
+  forceStaleReload?: boolean
   onReady?: () => Promise<void>
   updateMatch: UpdateMatchFn
   sync?: boolean
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 89c975ef11..58bd3ff9eb 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -2364,6 +2364,7 @@ export class RouterCore<
     let redirect: AnyRedirect | undefined
     let notFound: NotFoundError | undefined
     let loadPromise: Promise<void>
+    const previousLocation = this.state.resolvedLocation ?? this.state.location
 
     // eslint-disable-next-line prefer-const
     loadPromise = new Promise<void>((resolve) => {
@@ -2394,6 +2395,7 @@ export class RouterCore<
           await loadMatches({
             router: this,
             sync: opts?.sync,
+            forceStaleReload: previousLocation.href === next.href,
             matches: this.state.pendingMatches as Array<AnyRouteMatch>,
             location: next,
             updateMatch: this.updateMatch,
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index a19865b606..769d5f1500 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -541,6 +541,250 @@ test('exec on stay (beforeLoad & loader)', async () => {
   expect(layoutBeforeLoadResolved).toBe(true)
 })
 
+describe('stale loader reload triggers', () => {
+  test('skips stale loader when only unrelated search params change', async () => {
+    const rootRoute = new BaseRootRoute({})
+    const loader = vi.fn(() => ({ ok: true }))
+
+    const fooRoute = new BaseRoute({
+      getParentRoute: () => rootRoute,
+      path: '/foo',
+      loader,
+      staleTime: 0,
+      gcTime: 0,
+      loaderDeps: ({ search }: { search: Record<string, unknown> }) => ({
+        page: search['page'],
+      }),
+    })
+
+    const routeTree = rootRoute.addChildren([fooRoute])
+    const router = new RouterCore({
+      routeTree,
+      history: createMemoryHistory(),
+    })
+
+    await router.navigate({ to: '/foo', search: { page: '1', filter: 'a' } })
+    expect(loader).toHaveBeenCalledTimes(1)
+
+    await router.navigate({ to: '/foo', search: { page: '1', filter: 'b' } })
+
+    expect(loader).toHaveBeenCalledTimes(1)
+  })
+
+  test('reloads stale loader when loader deps change', async () => {
+    const rootRoute = new BaseRootRoute({})
+    const loader = vi.fn(() => ({ ok: true }))
+
+    const fooRoute = new BaseRoute({
+      getParentRoute: () => rootRoute,
+      path: '/foo',
+      loader,
+      staleTime: 0,
+      gcTime: 0,
+      loaderDeps: ({ search }: { search: Record<string, unknown> }) => ({
+        page: search['page'],
+      }),
+    })
+
+    const routeTree = rootRoute.addChildren([fooRoute])
+    const router = new RouterCore({
+      routeTree,
+      history: createMemoryHistory(),
+    })
+
+    await router.navigate({ to: '/foo', search: { page: '1' } })
+    expect(loader).toHaveBeenCalledTimes(1)
+
+    await router.navigate({ to: '/foo', search: { page: '2' } })
+
+    expect(loader).toHaveBeenCalledTimes(2)
+  })
+
+  test('reloads a stale preloaded loader when switching to a different match id of the same route', async () => {
+    vi.useFakeTimers()
+
+    try {
+      const rootRoute = new BaseRootRoute({})
+      const rootLoader = vi.fn(() => ({ ok: true }))
+      const childLoader = vi.fn(() => ({ ok: true }))
+
+      const rootChildRoute = new BaseRoute({
+        getParentRoute: () => rootRoute,
+        path: '/posts',
+        loader: rootLoader,
+        staleTime: 0,
+        gcTime: 0,
+        loaderDeps: ({ search }: { search: Record<string, unknown> }) => ({
+          page: search['page'],
+        }),
+      })
+
+      const leafRoute = new BaseRoute({
+        getParentRoute: () => rootChildRoute,
+        path: '/$postId',
+        loader: childLoader,
+        staleTime: 0,
+        gcTime: 0,
+      })
+
+      const routeTree = rootRoute.addChildren([
+        rootChildRoute.addChildren([leafRoute]),
+      ])
+      const router = new RouterCore({
+        routeTree,
+        history: createMemoryHistory(),
+      })
+
+      await router.navigate({
+        to: '/posts/$postId',
+        params: { postId: '1' },
+        search: { page: '1' },
+      })
+
+      expect(rootLoader).toHaveBeenCalledTimes(1)
+      expect(childLoader).toHaveBeenCalledTimes(1)
+
+      await router.preloadRoute({
+        to: '/posts/$postId',
+        params: { postId: '2' },
+        search: { page: '2' },
+      })
+
+      expect(rootLoader).toHaveBeenCalledTimes(2)
+      expect(childLoader).toHaveBeenCalledTimes(2)
+
+      vi.advanceTimersByTime(1)
+
+      await router.navigate({
+        to: '/posts/$postId',
+        params: { postId: '2' },
+        search: { page: '2' },
+      })
+
+      expect(rootLoader).toHaveBeenCalledTimes(3)
+      expect(childLoader).toHaveBeenCalledTimes(3)
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  test('skips stale ancestor loader when only a child path param changes', async () => {
+    const rootRoute = new BaseRootRoute({})
+    const parentLoader = vi.fn(() => ({ ok: true }))
+    const childLoader = vi.fn(() => ({ ok: true }))
+
+    const orgRoute = new BaseRoute({
+      getParentRoute: () => rootRoute,
+      path: '/orgs/$orgId',
+      loader: parentLoader,
+      staleTime: 0,
+      gcTime: 0,
+    })
+
+    const userRoute = new BaseRoute({
+      getParentRoute: () => orgRoute,
+      path: '/users/$userId',
+      loader: childLoader,
+      staleTime: 0,
+      gcTime: 0,
+    })
+
+    const routeTree = rootRoute.addChildren([orgRoute.addChildren([userRoute])])
+    const router = new RouterCore({
+      routeTree,
+      history: createMemoryHistory(),
+    })
+
+    await router.navigate({
+      to: '/orgs/$orgId/users/$userId',
+      params: { orgId: 'acme', userId: 'u1' },
+    })
+    expect(parentLoader).toHaveBeenCalledTimes(1)
+    expect(childLoader).toHaveBeenCalledTimes(1)
+
+    await router.navigate({
+      to: '/orgs/$orgId/users/$userId',
+      params: { orgId: 'acme', userId: 'u2' },
+    })
+
+    expect(parentLoader).toHaveBeenCalledTimes(1)
+    expect(childLoader).toHaveBeenCalledTimes(2)
+  })
+
+  test('reloads stale ancestor loader when its own path param changes', async () => {
+    const rootRoute = new BaseRootRoute({})
+    const parentLoader = vi.fn(() => ({ ok: true }))
+    const childLoader = vi.fn(() => ({ ok: true }))
+
+    const orgRoute = new BaseRoute({
+      getParentRoute: () => rootRoute,
+      path: '/orgs/$orgId',
+      loader: parentLoader,
+      staleTime: 0,
+      gcTime: 0,
+    })
+
+    const userRoute = new BaseRoute({
+      getParentRoute: () => orgRoute,
+      path: '/users/$userId',
+      loader: childLoader,
+      staleTime: 0,
+      gcTime: 0,
+    })
+
+    const routeTree = rootRoute.addChildren([orgRoute.addChildren([userRoute])])
+    const router = new RouterCore({
+      routeTree,
+      history: createMemoryHistory(),
+    })
+
+    await router.navigate({
+      to: '/orgs/$orgId/users/$userId',
+      params: { orgId: 'acme', userId: 'u1' },
+    })
+    expect(parentLoader).toHaveBeenCalledTimes(1)
+    expect(childLoader).toHaveBeenCalledTimes(1)
+
+    await router.navigate({
+      to: '/orgs/$orgId/users/$userId',
+      params: { orgId: 'beta', userId: 'u2' },
+    })
+
+    expect(parentLoader).toHaveBeenCalledTimes(2)
+    expect(childLoader).toHaveBeenCalledTimes(2)
+  })
+
+  test('revalidates stale loaders on explicit same-location router.load()', async () => {
+    const rootRoute = new BaseRootRoute({})
+    const loader = vi.fn(() => ({ ok: true }))
+
+    const fooRoute = new BaseRoute({
+      getParentRoute: () => rootRoute,
+      path: '/foo',
+      loader,
+      staleTime: 0,
+      gcTime: 0,
+      loaderDeps: ({ search }: { search: Record<string, unknown> }) => ({
+        page: search['page'],
+      }),
+    })
+
+    const routeTree = rootRoute.addChildren([fooRoute])
+    const router = new RouterCore({
+      routeTree,
+      history: createMemoryHistory(),
+    })
+
+    await router.navigate({ to: '/foo', search: { page: '1', filter: 'a' } })
+    expect(loader).toHaveBeenCalledTimes(1)
+
+    await sleep(1)
+    await router.load()
+
+    expect(loader).toHaveBeenCalledTimes(2)
+  })
+})
+
 test('cancelMatches after pending timeout', async () => {
   const WAIT_TIME = 5
   const onAbortMock = vi.fn()
diff --git a/packages/solid-router/src/fileRoute.ts b/packages/solid-router/src/fileRoute.ts
index 83f8466cf2..a3bd31bba3 100644
--- a/packages/solid-router/src/fileRoute.ts
+++ b/packages/solid-router/src/fileRoute.ts
@@ -8,6 +8,7 @@ import { useSearch } from './useSearch'
 import { useParams } from './useParams'
 import { useNavigate } from './useNavigate'
 import { useRouter } from './useRouter'
+import { useRouteContext } from './useRouteContext'
 import type { UseParamsRoute } from './useParams'
 import type { UseMatchRoute } from './useMatch'
 import type { UseSearchRoute } from './useSearch'
@@ -217,10 +218,7 @@ export class LazyRoute<TRoute extends AnyRoute> {
   }
 
   useRouteContext: UseRouteContextRoute<TRoute['id']> = (opts) => {
-    return useMatch({
-      from: this.options.id,
-      select: (d: any) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.options.id }) as any
   }
 
   useSearch: UseSearchRoute<TRoute['id']> = (opts) => {
diff --git a/packages/solid-router/src/route.tsx b/packages/solid-router/src/route.tsx
index 284a905703..da7e639a3a 100644
--- a/packages/solid-router/src/route.tsx
+++ b/packages/solid-router/src/route.tsx
@@ -11,6 +11,7 @@ import { useParams } from './useParams'
 import { useSearch } from './useSearch'
 import { useNavigate } from './useNavigate'
 import { useMatch } from './useMatch'
+import { useRouteContext } from './useRouteContext'
 import { useRouter } from './useRouter'
 import type {
   AnyContext,
@@ -104,10 +105,7 @@ export class RouteApi<
   }
 
   useRouteContext: UseRouteContextRoute<TId> = (opts) => {
-    return useMatch({
-      from: this.id as any,
-      select: (d) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.id as any }) as any
   }
 
   useSearch: UseSearchRoute<TId> = (opts) => {
@@ -255,11 +253,7 @@ export class Route<
   }
 
   useRouteContext: UseRouteContextRoute<TId> = (opts?) => {
-    return useMatch({
-      ...opts,
-      from: this.id,
-      select: (d) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.id }) as any
   }
 
   useSearch: UseSearchRoute<TId> = (opts) => {
@@ -497,11 +491,7 @@ export class RootRoute<
   }
 
   useRouteContext: UseRouteContextRoute<RootRouteId> = (opts) => {
-    return useMatch({
-      ...opts,
-      from: this.id,
-      select: (d) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.id }) as any
   }
 
   useSearch: UseSearchRoute<RootRouteId> = (opts) => {
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 41ad1aad7f..690af325f6 100644
--- a/packages/solid-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/solid-router/tests/store-updates-during-navigation.test.tsx
@@ -136,7 +136,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).toBeGreaterThanOrEqual(10) // WARN: this is flaky, and sometimes (rarely) is 12
+    expect(updates).toBeGreaterThanOrEqual(9) // WARN: this is flaky, and sometimes (rarely) is 12
     expect(updates).toBeLessThanOrEqual(13)
   })
 
@@ -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(7)
   })
 
   test('sync beforeLoad', async () => {
@@ -184,7 +184,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).toBeGreaterThanOrEqual(6) // WARN: this is flaky
+    expect(updates).toBeGreaterThanOrEqual(5) // WARN: this is flaky
     expect(updates).toBeLessThanOrEqual(10)
   })
 
@@ -260,7 +260,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 one fewer update than React due to different reactivity
-    expect(updates).toBe(7)
+    expect(updates).toBe(9)
   })
 
   test('navigate, w/ previous navigation & async loader', async () => {
@@ -276,7 +276,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(5)
+    expect(updates).toBe(7)
   })
 
   test('preload a preloaded route w/ async loader', async () => {
@@ -294,6 +294,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(3)
   })
 })
diff --git a/packages/vue-router/src/fileRoute.ts b/packages/vue-router/src/fileRoute.ts
index 86d27f3579..0ab9e0c983 100644
--- a/packages/vue-router/src/fileRoute.ts
+++ b/packages/vue-router/src/fileRoute.ts
@@ -8,6 +8,7 @@ import { useSearch } from './useSearch'
 import { useParams } from './useParams'
 import { useNavigate } from './useNavigate'
 import { useRouter } from './useRouter'
+import { useRouteContext } from './useRouteContext'
 import type { UseParamsRoute } from './useParams'
 import type { UseMatchRoute } from './useMatch'
 import type { UseSearchRoute } from './useSearch'
@@ -217,10 +218,7 @@ export class LazyRoute<TRoute extends AnyRoute> {
   }
 
   useRouteContext: UseRouteContextRoute<TRoute['id']> = (opts) => {
-    return useMatch({
-      from: this.options.id,
-      select: (d: any) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.options.id }) as any
   }
 
   useSearch: UseSearchRoute<TRoute['id']> = (opts) => {
diff --git a/packages/vue-router/src/route.ts b/packages/vue-router/src/route.ts
index efaf1e75e1..9a958f111d 100644
--- a/packages/vue-router/src/route.ts
+++ b/packages/vue-router/src/route.ts
@@ -12,6 +12,7 @@ import { useParams } from './useParams'
 import { useSearch } from './useSearch'
 import { useNavigate } from './useNavigate'
 import { useMatch } from './useMatch'
+import { useRouteContext } from './useRouteContext'
 import { useRouter } from './useRouter'
 import type {
   AnyContext,
@@ -105,10 +106,7 @@ export class RouteApi<
   }
 
   useRouteContext: UseRouteContextRoute<TId> = (opts) => {
-    return useMatch({
-      from: this.id as any,
-      select: (d) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.id as any }) as any
   }
 
   useSearch: UseSearchRoute<TId> = (opts) => {
@@ -261,11 +259,7 @@ export class Route<
   }
 
   useRouteContext: UseRouteContextRoute<TId> = (opts?) => {
-    return useMatch({
-      ...opts,
-      from: this.id,
-      select: (d) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.id }) as any
   }
 
   useSearch: UseSearchRoute<TId> = (opts) => {
@@ -507,11 +501,7 @@ export class RootRoute<
   }
 
   useRouteContext: UseRouteContextRoute<RootRouteId> = (opts) => {
-    return useMatch({
-      ...opts,
-      from: this.id,
-      select: (d) => (opts?.select ? opts.select(d.context) : d.context),
-    }) as any
+    return useRouteContext({ ...(opts as any), from: this.id }) as any
   }
 
   useSearch: UseSearchRoute<RootRouteId> = (opts) => {
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 eb0d451b67..9518b0bbef 100644
--- a/packages/vue-router/tests/store-updates-during-navigation.test.tsx
+++ b/packages/vue-router/tests/store-updates-during-navigation.test.tsx
@@ -157,7 +157,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(13)
   })
 
   test('sync beforeLoad', async () => {
@@ -187,7 +187,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
     // Any change that increases this number should be investigated.
     // Note: Vue has different update counts than React/Solid due to different reactivity
     // Vue's reactivity model may cause slightly more updates due to computed refs
-    expect(updates).toBeGreaterThanOrEqual(14) // WARN: this is flaky
+    expect(updates).toBeGreaterThanOrEqual(12) // WARN: this is flaky
     expect(updates).toBeLessThanOrEqual(26)
   })
 
@@ -248,7 +248,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(18)
+    expect(updates).toBe(24)
   })
 
   test('navigate, w/ preloaded & sync loaders', async () => {
@@ -265,7 +265,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(16)
+    expect(updates).toBe(22)
   })
 
   test('navigate, w/ previous navigation & async loader', async () => {
@@ -282,7 +282,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(12)
+    expect(updates).toBe(18)
   })
 
   test('preload a preloaded route w/ async loader', async () => {
@@ -301,6 +301,6 @@ 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(3)
+    expect(updates).toBe(6)
   })
 })
