From 7a603542064b853631d6057dced87728a501dc1f Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sat, 14 Mar 2026 00:29:19 +0100 Subject: [PATCH 1/3] feat: add staleReloadMode --- .changeset/poor-dryers-stare.md | 5 + docs/router/api/router/RouteOptionsType.md | 12 +- docs/router/api/router/RouterOptionsType.md | 9 + docs/router/guide/data-loading.md | 53 ++- packages/react-router/src/fileRoute.ts | 4 +- packages/router-core/src/index.ts | 2 + packages/router-core/src/load-matches.ts | 31 +- packages/router-core/src/route.ts | 99 +++++- packages/router-core/src/router.ts | 10 + packages/router-core/tests/load.test.ts | 356 ++++++++++++++++---- 10 files changed, 493 insertions(+), 88 deletions(-) create mode 100644 .changeset/poor-dryers-stare.md diff --git a/.changeset/poor-dryers-stare.md b/.changeset/poor-dryers-stare.md new file mode 100644 index 00000000000..44d74125170 --- /dev/null +++ b/.changeset/poor-dryers-stare.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': minor +--- + +feat: add staleReloadMode diff --git a/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index 215e21c9ef4..3e575832de9 100644 --- a/docs/router/api/router/RouteOptionsType.md +++ b/docs/router/api/router/RouteOptionsType.md @@ -122,7 +122,7 @@ type beforeLoad = ( - Type: ```tsx -type loader = ( +type loaderFn = ( opts: RouteMatch & { abortController: AbortController cause: 'preload' | 'enter' | 'stay' @@ -136,6 +136,13 @@ type loader = ( route: AnyRoute }, ) => Promise | TLoaderData | void + +type loader = + | loaderFn + | { + handler: loaderFn + staleReloadMode?: 'background' | 'blocking' + } ``` - Optional @@ -144,6 +151,9 @@ type loader = ( - If this function returns a promise, the route will be put into a pending state and cause rendering to suspend until the promise resolves. If this route's pendingMs threshold is reached, the `pendingComponent` will be shown until it resolves. If the promise rejects, the route will be put into an error state and the error will be thrown during render. - If this function returns a `TLoaderData` object, that object will be stored on the route match until the route match is no longer active. It can be accessed using the `useLoaderData` hook in any component that is a child of the route match before another `` is rendered. - Deps must be returned by your `loaderDeps` function in order to appear. +- Use the object form to configure loader-specific behavior like `staleReloadMode`. +- `staleReloadMode: 'background'` preserves stale-while-revalidate behavior for stale successful matches. +- `staleReloadMode: 'blocking'` waits for the stale loader reload to complete before continuing. > 🚧 `opts.navigate` has been deprecated and will be removed in the next major release. Use `throw redirect({ to: '/somewhere' })` instead. Read more about the `redirect` function [here](./redirectFunction.md). diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index ef1826701c9..7127e1191e4 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -109,6 +109,15 @@ The `RouterOptions` type accepts an object with the following properties and met - Defaults to `0` - The default `staleTime` a route should use if no staleTime is provided. +### `defaultStaleReloadMode` property + +- Type: `'background' | 'blocking'` +- Optional +- Defaults to `'background'` +- Controls how stale successful loader data is revalidated by default. +- `'background'` preserves stale-while-revalidate behavior. +- `'blocking'` waits for the stale loader reload to finish before navigation resolves. + ### `defaultPreloadStaleTime` property - Type: `number` diff --git a/docs/router/guide/data-loading.md b/docs/router/guide/data-loading.md index 85e3c0a4233..2ecdf443add 100644 --- a/docs/router/guide/data-loading.md +++ b/docs/router/guide/data-loading.md @@ -57,7 +57,7 @@ The router cache is built-in and is as easy as returning data from any route's ` ## Route `loader`s -Route `loader` functions are called when a route match is loaded. They are called with a single parameter which is an object containing many helpful properties. We'll go over those in a bit, but first, let's look at an example of a route `loader` function: +Route `loader` functions are called when a route match is loaded. They are called with a single parameter which is an object containing many helpful properties. We'll go over those in a bit, but first, let's look at the two supported `loader` forms: ```tsx // src/routes/posts.tsx @@ -66,6 +66,17 @@ export const Route = createFileRoute('/posts')({ }) ``` +```tsx +// src/routes/posts.tsx +export const Route = createFileRoute('/posts')({ + loader: { + handler: () => fetchPosts(), + }, +}) +``` + +Use the object form when you want to configure loader-specific behavior such as `staleReloadMode`. + ## `loader` Parameters The `loader` function receives a single object with the following properties: @@ -151,12 +162,16 @@ To control router dependencies and "freshness", TanStack Router provides a pleth - The number of milliseconds that a route's data should be kept in the cache before being garbage collected. - `routeOptions.shouldReload` - A function that receives the same `beforeLoad` and `loaderContext` parameters and returns a boolean indicating if the route should reload. This offers one more level of control over when a route should reload beyond `staleTime` and `loaderDeps` and can be used to implement patterns similar to Remix's `shouldLoad` option. +- `routeOptions.loader.staleReloadMode` +- `routerOptions.defaultStaleReloadMode` + - Controls what happens when a matched route already has stale successful data. Use `'background'` for stale-while-revalidate, or `'blocking'` to wait for the stale loader reload to finish before continuing. ### ⚠️ Some Important Defaults - 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. +- By default, `staleReloadMode` is `'background'`, so stale successful matches keep rendering with their existing `loaderData` while the loader revalidates in the background. - `router.invalidate()` will force all active routes to reload their loaders immediately and mark every cached route's data as stale. ### Using `loaderDeps` to access search params @@ -216,9 +231,36 @@ export const Route = createFileRoute('/posts')({ By passing `10_000` to the `staleTime` option, we are telling the router to consider the route's data fresh for 10 seconds. This means that if the user navigates to `/posts` from `/about` within 10 seconds of the last loader result, the route's data will not be reloaded. If the user then navigates to `/posts` from `/about` after 10 seconds, the route's data will be reloaded **in the background**. -## Turning off stale-while-revalidate caching +## Choosing background vs blocking stale reloads + +By default, stale successful matches use stale-while-revalidate behavior. That means the router can render with the existing `loaderData` immediately and then refresh it in the background. + +If you want a specific loader to wait for a stale reload to finish before continuing, use the object form and set `staleReloadMode: 'blocking'`: + +```tsx +// /routes/posts.tsx +export const Route = createFileRoute('/posts')({ + loader: { + handler: () => fetchPosts(), + staleReloadMode: 'blocking', + }, +}) +``` + +You can also change the default for the entire router: + +```tsx +const router = createRouter({ + routeTree, + defaultStaleReloadMode: 'blocking', +}) +``` + +Use `'background'` when showing stale data during revalidation is acceptable. Use `'blocking'` when you want stale matches to behave more like a fresh load and wait for the new loader result. -To disable stale-while-revalidate caching for a route, set the `staleTime` option to `Infinity`: +## Turning off automatic stale reloads + +To disable automatic stale reloads for a route, set the `staleTime` option to `Infinity`: ```tsx // /routes/posts.tsx @@ -237,6 +279,11 @@ const router = createRouter({ }) ``` +This differs from `staleReloadMode: 'blocking'`: + +- `staleTime: Infinity` prevents the route from becoming stale in the first place +- `staleReloadMode: 'blocking'` still allows stale reloads, but waits for them instead of doing them in the background + ## Using `shouldReload` and `gcTime` to opt-out of caching Similar to Remix's default functionality, you may want to configure a route to only load on entry or when critical loader deps change. You can do this by using the `gcTime` option combined with the `shouldReload` option, which accepts either a `boolean` or a function that receives the same `beforeLoad` and `loaderContext` parameters and returns a boolean indicating if the route should reload. diff --git a/packages/react-router/src/fileRoute.ts b/packages/react-router/src/fileRoute.ts index 402d7d67ca0..0b058750562 100644 --- a/packages/react-router/src/fileRoute.ts +++ b/packages/react-router/src/fileRoute.ts @@ -28,7 +28,7 @@ import type { RouteById, RouteConstraints, RouteIds, - RouteLoaderFn, + RouteLoaderEntry, UpdatableRouteOptions, UseNavigateResult, } from '@tanstack/router-core' @@ -174,7 +174,7 @@ export function FileRouteLoader< ): ( loaderFn: Constrain< TLoaderFn, - RouteLoaderFn< + RouteLoaderEntry< Register, TRoute['parentRoute'], TRoute['types']['id'], diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index e35e4b566dc..e428852be4c 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -164,7 +164,9 @@ export type { FileBaseRouteOptions, BaseRouteOptions, UpdatableRouteOptions, + LoaderStaleReloadMode, RouteLoaderFn, + RouteLoaderEntry, LoaderFnContext, RouteContextFn, ContextOptions, diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index 8381e7dc6d6..bd04f00481e 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -657,11 +657,13 @@ const runLoader = async ( } // Kick off the loader! - const loaderResult = route.options.loader?.( + const routeLoader = route.options.loader + const loader = + typeof routeLoader === 'function' ? routeLoader : routeLoader?.handler + const loaderResult = loader?.( getLoaderContext(inner, matchPromises, matchId, index, route), ) - const loaderResultIsPromise = - route.options.loader && isPromise(loaderResult) + const loaderResultIsPromise = !!loader && isPromise(loaderResult) const willLoadSomething = !!( loaderResultIsPromise || @@ -680,7 +682,7 @@ const runLoader = async ( })) } - if (route.options.loader) { + if (loader) { const loaderData = loaderResultIsPromise ? await loaderResult : loaderResult @@ -820,7 +822,11 @@ const loadRouteMatch = async ( (invalid || (shouldReload ?? staleMatchShouldReload)) if (preload && route.options.preload === false) { // Do nothing - } else if (loaderShouldRunAsync && !inner.sync) { + } else if ( + loaderShouldRunAsync && + !inner.sync && + shouldReloadInBackground + ) { loaderIsRunningAsync = true ;(async () => { try { @@ -835,7 +841,7 @@ const loadRouteMatch = async ( } } })() - } else if (status !== 'success' || (loaderShouldRunAsync && inner.sync)) { + } else if (status !== 'success' || loaderShouldRunAsync) { await runLoader(inner, matchPromises, matchId, index, route) } else { syncMatchContext(inner, matchId, index) @@ -846,6 +852,12 @@ const loadRouteMatch = async ( let loaderShouldRunAsync = false let loaderIsRunningAsync = false const route = inner.router.looseRoutesById[routeId]! + const routeLoader = route.options.loader + const shouldReloadInBackground = + ((typeof routeLoader === 'function' + ? undefined + : routeLoader?.staleReloadMode) ?? + inner.router.options.defaultStaleReloadMode) !== 'blocking' if (shouldSkipLoader(inner, matchId)) { const match = inner.router.getMatch(matchId) @@ -871,7 +883,12 @@ const loadRouteMatch = async ( // do not block if we already have stale data we can show // but only if the ongoing load is not a preload since error handling is different for preloads // and we don't want to swallow errors - if (prevMatch.status === 'success' && !inner.sync && !prevMatch.preload) { + if ( + prevMatch.status === 'success' && + !inner.sync && + !prevMatch.preload && + shouldReloadInBackground + ) { return prevMatch } await prevMatch._nonReactive.loaderPromise diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 1c8d4a20a5c..ef80228c8bc 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -292,11 +292,44 @@ export type ResolveRouteContext = Assign< ContextAsyncReturnType > +export type ResolveRouteLoaderFn = TLoaderFn extends { + handler: infer THandler +} + ? THandler + : TLoaderFn + +export type RouteLoaderObject< + TRegister, + TParentRoute extends AnyRoute = AnyRoute, + TId extends string = string, + TParams = {}, + TLoaderDeps = {}, + TRouterContext = {}, + TRouteContextFn = AnyContext, + TBeforeLoadFn = AnyContext, + TServerMiddlewares = unknown, + THandlers = undefined, +> = { + handler: RouteLoaderFn< + TRegister, + TParentRoute, + TId, + TParams, + TLoaderDeps, + TRouterContext, + TRouteContextFn, + TBeforeLoadFn, + TServerMiddlewares, + THandlers + > + staleReloadMode?: LoaderStaleReloadMode +} + export type ResolveLoaderData = unknown extends TLoaderFn ? TLoaderFn - : LooseAsyncReturnType extends never + : LooseAsyncReturnType> extends never ? undefined - : LooseAsyncReturnType + : LooseAsyncReturnType> export type ResolveFullSearchSchema< TParentRoute extends AnyRoute, @@ -1010,8 +1043,7 @@ export interface FilebaseRouteOptionsInterface< loader?: Constrain< TLoaderFn, - ( - ctx: LoaderFnContext< + | RouteLoaderFn< TRegister, TParentRoute, TId, @@ -1022,13 +1054,19 @@ export interface FilebaseRouteOptionsInterface< TBeforeLoadFn, TServerMiddlewares, THandlers - >, - ) => ValidateSerializableLifecycleResult< - TRegister, - TParentRoute, - TSSR, - TLoaderFn - > + > + | RouteLoaderObject< + TRegister, + TParentRoute, + TId, + TParams, + TLoaderDeps, + TRouterContext, + TRouteContextFn, + TBeforeLoadFn, + TServerMiddlewares, + THandlers + > > } @@ -1415,6 +1453,45 @@ export type RouteLoaderFn< >, ) => any +export type LoaderStaleReloadMode = 'background' | 'blocking' + +export type RouteLoaderEntry< + TRegister, + TParentRoute extends AnyRoute = AnyRoute, + TId extends string = string, + TParams = {}, + TLoaderDeps = {}, + TRouterContext = {}, + TRouteContextFn = AnyContext, + TBeforeLoadFn = AnyContext, + TServerMiddlewares = unknown, + THandlers = undefined, +> = + | RouteLoaderFn< + TRegister, + TParentRoute, + TId, + TParams, + TLoaderDeps, + TRouterContext, + TRouteContextFn, + TBeforeLoadFn, + TServerMiddlewares, + THandlers + > + | RouteLoaderObject< + TRegister, + TParentRoute, + TId, + TParams, + TLoaderDeps, + TRouterContext, + TRouteContextFn, + TBeforeLoadFn, + TServerMiddlewares, + THandlers + > + export interface LoaderFnContext< in out TRegister = unknown, in out TParentRoute extends AnyRoute = AnyRoute, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index b956b28baa2..d1cc977445f 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -72,6 +72,7 @@ import type { AnyContext, AnyRoute, AnyRouteWithContext, + LoaderStaleReloadMode, MakeRemountDepsOptionsUnion, RouteContextOptions, RouteLike, @@ -238,6 +239,15 @@ export interface RouterOptions< * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#key-options) */ defaultStaleTime?: number + /** + * The default stale reload mode a route loader should use if no `loader.staleReloadMode` is provided. + * + * `'background'` preserves the current stale-while-revalidate behavior. + * `'blocking'` waits for stale loader reloads to complete before resolving navigation. + * + * @default 'background' + */ + defaultStaleReloadMode?: LoaderStaleReloadMode /** * The default `preloadStaleTime` a route should use if no preloadStaleTime is provided. * diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index f3620ac8e90..62b8107c5ab 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, @@ -9,11 +9,12 @@ import { rootRouteId, } from '../src' import { loadMatches } from '../src/load-matches' -import type { AnyRouter, RootRouteOptions } from '../src' +import type { AnyRouter, LoaderStaleReloadMode, RootRouteOptions } from '../src' type AnyRouteOptions = RootRouteOptions type BeforeLoad = NonNullable type Loader = NonNullable +type LoaderEntry = Exclude describe('redirect resolution', () => { test('resolveRedirect normalizes same-origin Location to path-only', async () => { @@ -266,9 +267,11 @@ describe('loader skip or exec', () => { const setup = ({ loader, staleTime, + defaultStaleReloadMode, }: { loader?: Loader staleTime?: number + defaultStaleReloadMode?: LoaderStaleReloadMode }) => { const rootRoute = new BaseRootRoute({}) @@ -289,6 +292,7 @@ describe('loader skip or exec', () => { const router = new RouterCore({ routeTree, + defaultStaleReloadMode, history: createMemoryHistory(), }) @@ -365,7 +369,7 @@ describe('loader skip or exec', () => { }) test('exec if rejected preload (notFound)', async () => { - const loader = vi.fn(async ({ preload }) => { + const loader: Loader = vi.fn(async ({ preload }) => { if (preload) throw notFound() await Promise.resolve() }) @@ -380,7 +384,7 @@ describe('loader skip or exec', () => { }) test('skip if pending preload (notFound)', async () => { - const loader = vi.fn(async ({ preload }) => { + const loader: Loader = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw notFound() }) @@ -395,7 +399,7 @@ describe('loader skip or exec', () => { }) test('exec if rejected preload (redirect)', async () => { - const loader = vi.fn(async ({ preload }) => { + const loader: Loader = vi.fn(async ({ preload }) => { if (preload) throw redirect({ to: '/bar' }) await Promise.resolve() }) @@ -417,7 +421,7 @@ describe('loader skip or exec', () => { }) test('skip if pending preload (redirect)', async () => { - const loader = vi.fn(async ({ preload }) => { + const loader: Loader = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw redirect({ to: '/bar' }) }) @@ -461,7 +465,7 @@ describe('loader skip or exec', () => { }) test('exec if rejected preload (error)', async () => { - const loader = vi.fn(async ({ preload }) => { + const loader: Loader = vi.fn(async ({ preload }) => { if (preload) throw new Error('error') await Promise.resolve() }) @@ -476,7 +480,7 @@ describe('loader skip or exec', () => { }) test('skip if pending preload (error)', async () => { - const loader = vi.fn(async ({ preload }) => { + const loader: Loader = vi.fn(async ({ preload }) => { await sleep(100) if (preload) throw new Error('error') }) @@ -570,6 +574,161 @@ test('exec on stay (beforeLoad & loader)', async () => { }) describe('stale loader reload triggers', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const getMatchById = ( + router: RouterCore, + id: string, + ) => + router.state.matches.find((match) => match.id === id) ?? + router.state.pendingMatches?.find((match) => match.id === id) ?? + router.state.cachedMatches.find((match) => match.id === id) + + const hasActiveMatch = ( + router: RouterCore, + id: string, + ) => router.state.matches.some((match) => match.id === id) + + const hasPendingMatch = ( + router: RouterCore, + id: string, + ) => router.state.pendingMatches?.some((match) => match.id === id) ?? false + + const setup = ({ + loader, + staleTime, + defaultStaleReloadMode, + }: { + loader?: Loader + staleTime?: number + defaultStaleReloadMode?: LoaderStaleReloadMode + }) => { + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime, + gcTime: 60_000, + }) + + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const routeTree = rootRoute.addChildren([fooRoute, barRoute]) + + return new RouterCore({ + routeTree, + defaultStaleReloadMode, + history: createMemoryHistory(), + }) + } + + const createControlledStaleReload = () => { + let resolveStaleReload: (() => void) | undefined + let callCount = 0 + + const loader = vi.fn(() => { + callCount += 1 + if (callCount === 1) { + return { value: 'first' } + } + + return new Promise<{ value: string }>((resolve) => { + resolveStaleReload = () => resolve({ value: 'second' }) + }) + }) + + return { + loader, + resolveStaleReload: () => resolveStaleReload?.(), + } + } + + const expectBlockingStaleReloadBehavior = async ( + router: RouterCore, + loader: ReturnType, + resolveStaleReload: () => void, + ) => { + await router.navigate({ to: '/foo' }) + expect(loader).toHaveBeenCalledTimes(1) + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'first', + }) + + await vi.advanceTimersByTimeAsync(1) + await router.navigate({ to: '/bar' }) + await vi.advanceTimersByTimeAsync(1) + + const revisit = router.navigate({ to: '/foo' }) + await Promise.resolve() + + expect(loader).toHaveBeenCalledTimes(2) + expect(hasActiveMatch(router, '/bar/bar')).toBe(true) + expect(hasActiveMatch(router, '/foo/foo')).toBe(false) + expect(hasPendingMatch(router, '/foo/foo')).toBe(true) + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'first', + }) + + resolveStaleReload() + await revisit + + expect(loader).toHaveBeenCalledTimes(2) + expect(hasActiveMatch(router, '/foo/foo')).toBe(true) + expect(hasPendingMatch(router, '/foo/foo')).toBe(false) + expect(router.state.location.pathname).toBe('/foo') + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'second', + }) + } + + const expectBackgroundStaleReloadBehavior = async ( + router: RouterCore, + loader: ReturnType, + resolveStaleReload: () => void, + ) => { + await router.navigate({ to: '/foo' }) + expect(loader).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + await router.navigate({ to: '/bar' }) + await vi.advanceTimersByTimeAsync(1) + + const revisit = router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(2) + + await revisit + const backgroundReloadPromise = getMatchById(router, '/foo/foo') + ?._nonReactive.loaderPromise + + expect(backgroundReloadPromise).toBeDefined() + expect(hasActiveMatch(router, '/foo/foo')).toBe(true) + expect(hasPendingMatch(router, '/foo/foo')).toBe(false) + expect(router.state.location.pathname).toBe('/foo') + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'first', + }) + + resolveStaleReload() + await backgroundReloadPromise + + expect(getMatchById(router, '/foo/foo')?.loaderData).toEqual({ + value: 'second', + }) + } + test('skips stale loader when only unrelated search params change', async () => { const rootRoute = new BaseRootRoute({}) const loader = vi.fn(() => ({ ok: true })) @@ -629,71 +788,65 @@ describe('stale loader reload triggers', () => { }) 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 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 }) => ({ - page: search['page'], - }), - }) + const rootChildRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + loader: rootLoader, + staleTime: 0, + gcTime: 0, + loaderDeps: ({ search }: { search: Record }) => ({ + page: search['page'], + }), + }) - const leafRoute = new BaseRoute({ - getParentRoute: () => rootChildRoute, - path: '/$postId', - loader: childLoader, - staleTime: 0, - gcTime: 0, - }) + 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(), - }) + 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' }, - }) + await router.navigate({ + to: '/posts/$postId', + params: { postId: '1' }, + search: { page: '1' }, + }) - expect(rootLoader).toHaveBeenCalledTimes(1) - expect(childLoader).toHaveBeenCalledTimes(1) + expect(rootLoader).toHaveBeenCalledTimes(1) + expect(childLoader).toHaveBeenCalledTimes(1) - await router.preloadRoute({ - to: '/posts/$postId', - params: { postId: '2' }, - search: { page: '2' }, - }) + await router.preloadRoute({ + to: '/posts/$postId', + params: { postId: '2' }, + search: { page: '2' }, + }) - expect(rootLoader).toHaveBeenCalledTimes(2) - expect(childLoader).toHaveBeenCalledTimes(2) + expect(rootLoader).toHaveBeenCalledTimes(2) + expect(childLoader).toHaveBeenCalledTimes(2) - vi.advanceTimersByTime(1) + await vi.advanceTimersByTimeAsync(1) - await router.navigate({ - to: '/posts/$postId', - params: { postId: '2' }, - search: { page: '2' }, - }) + await router.navigate({ + to: '/posts/$postId', + params: { postId: '2' }, + search: { page: '2' }, + }) - expect(rootLoader).toHaveBeenCalledTimes(3) - expect(childLoader).toHaveBeenCalledTimes(3) - } finally { - vi.useRealTimers() - } + expect(rootLoader).toHaveBeenCalledTimes(3) + expect(childLoader).toHaveBeenCalledTimes(3) }) test('skips stale ancestor loader when only a child path param changes', async () => { @@ -806,11 +959,86 @@ describe('stale loader reload triggers', () => { await router.navigate({ to: '/foo', search: { page: '1', filter: 'a' } }) expect(loader).toHaveBeenCalledTimes(1) - await sleep(1) + await vi.advanceTimersByTimeAsync(1) await router.load() + await Promise.resolve() expect(loader).toHaveBeenCalledTimes(2) }) + + test('supports object-form loader handler', async () => { + const handler = vi.fn(() => ({ ok: true })) + const router = setup({ + loader: { + handler, + } satisfies LoaderEntry, + }) + + await router.navigate({ to: '/foo' }) + + expect(handler).toHaveBeenCalledTimes(1) + expect(router.state.matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '/foo/foo', + loaderData: { ok: true }, + }), + ]), + ) + }) + + test('reloads stale loaders in the background by default', async () => { + const { loader, resolveStaleReload } = createControlledStaleReload() + const router = setup({ loader, staleTime: 0 }) + + await expectBackgroundStaleReloadBehavior( + router, + loader, + resolveStaleReload, + ) + }) + + test('blocks stale reloads when loader staleReloadMode is blocking', async () => { + const { loader, resolveStaleReload } = createControlledStaleReload() + const router = setup({ + staleTime: 0, + loader: { + handler: loader, + staleReloadMode: 'blocking', + } satisfies LoaderEntry, + }) + + await expectBlockingStaleReloadBehavior(router, loader, resolveStaleReload) + }) + + test('blocks stale reloads when defaultStaleReloadMode is blocking', async () => { + const { loader, resolveStaleReload } = createControlledStaleReload() + const router = setup({ + loader, + staleTime: 0, + defaultStaleReloadMode: 'blocking', + }) + + await expectBlockingStaleReloadBehavior(router, loader, resolveStaleReload) + }) + + test('loader staleReloadMode overrides defaultStaleReloadMode', async () => { + const { loader, resolveStaleReload } = createControlledStaleReload() + const router = setup({ + staleTime: 0, + defaultStaleReloadMode: 'blocking', + loader: { + handler: loader, + staleReloadMode: 'background', + } satisfies LoaderEntry, + }) + + await expectBackgroundStaleReloadBehavior( + router, + loader, + resolveStaleReload, + ) + }) }) test('cancelMatches after pending timeout', async () => { From 2ca98ebbedbdd17cbd09dd215451b293b359124b Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sat, 14 Mar 2026 01:11:21 +0100 Subject: [PATCH 2/3] update --- .changeset/poor-dryers-stare.md | 4 ++++ packages/solid-router/src/fileRoute.ts | 4 ++-- packages/vue-router/src/fileRoute.ts | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.changeset/poor-dryers-stare.md b/.changeset/poor-dryers-stare.md index 44d74125170..bda582d8c72 100644 --- a/.changeset/poor-dryers-stare.md +++ b/.changeset/poor-dryers-stare.md @@ -1,5 +1,9 @@ --- '@tanstack/router-core': minor +'@tanstack/react-core': minor +'@tanstack/solid-core': minor +'@tanstack/vue-core': minor + --- feat: add staleReloadMode diff --git a/packages/solid-router/src/fileRoute.ts b/packages/solid-router/src/fileRoute.ts index a3bd31bba39..dbe12dd722d 100644 --- a/packages/solid-router/src/fileRoute.ts +++ b/packages/solid-router/src/fileRoute.ts @@ -28,7 +28,7 @@ import type { RouteById, RouteConstraints, RouteIds, - RouteLoaderFn, + RouteLoaderEntry, UpdatableRouteOptions, UseNavigateResult, } from '@tanstack/router-core' @@ -164,7 +164,7 @@ export function FileRouteLoader< ): ( loaderFn: Constrain< TLoaderFn, - RouteLoaderFn< + RouteLoaderEntry< Register, TRoute['parentRoute'], TRoute['types']['id'], diff --git a/packages/vue-router/src/fileRoute.ts b/packages/vue-router/src/fileRoute.ts index 0ab9e0c9833..94ae9ccb9fa 100644 --- a/packages/vue-router/src/fileRoute.ts +++ b/packages/vue-router/src/fileRoute.ts @@ -28,7 +28,7 @@ import type { RouteById, RouteConstraints, RouteIds, - RouteLoaderFn, + RouteLoaderEntry, UpdatableRouteOptions, UseNavigateResult, } from '@tanstack/router-core' @@ -164,7 +164,7 @@ export function FileRouteLoader< ): ( loaderFn: Constrain< TLoaderFn, - RouteLoaderFn< + RouteLoaderEntry< Register, TRoute['parentRoute'], TRoute['types']['id'], From 7ccbfb51bf068d8f8931186e0881f0b325fdddd7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 00:12:49 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- .changeset/poor-dryers-stare.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/poor-dryers-stare.md b/.changeset/poor-dryers-stare.md index bda582d8c72..6d67dc46fd2 100644 --- a/.changeset/poor-dryers-stare.md +++ b/.changeset/poor-dryers-stare.md @@ -3,7 +3,6 @@ '@tanstack/react-core': minor '@tanstack/solid-core': minor '@tanstack/vue-core': minor - --- feat: add staleReloadMode