Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/poor-dryers-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/router-core': minor
'@tanstack/react-core': minor
'@tanstack/solid-core': minor
'@tanstack/vue-core': minor
---
Comment on lines +1 to +6
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add a @tanstack/react-router changeset here too.

This PR also changes the public file-route loader API in packages/react-router/src/fileRoute.ts. Releasing only @tanstack/router-core would leave @tanstack/react-router consumers without a published version that includes the new object-form loader typing.

Suggested changeset update
 ---
 '@tanstack/router-core': minor
+'@tanstack/react-router': minor
 ---
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
---
'@tanstack/router-core': minor
---
---
'@tanstack/router-core': minor
'@tanstack/react-router': minor
---
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.changeset/poor-dryers-stare.md around lines 1 - 3, The changeset only bumps
'@tanstack/router-core' but the PR also changes the public file-route loader API
in packages/react-router/src/fileRoute.ts (the new object-form loader typing),
so add a corresponding changeset entry for '@tanstack/react-router' describing
this API change; update .changeset to include a minor (or appropriate) release
for '@tanstack/react-router' and reference the file-route loader typing change
so consumers get the published typing update.


feat: add staleReloadMode
12 changes: 11 additions & 1 deletion docs/router/api/router/RouteOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ type beforeLoad = (
- Type:

```tsx
type loader = (
type loaderFn = (
opts: RouteMatch & {
abortController: AbortController
cause: 'preload' | 'enter' | 'stay'
Expand All @@ -136,6 +136,13 @@ type loader = (
route: AnyRoute
},
) => Promise<TLoaderData> | TLoaderData | void

type loader =
| loaderFn
| {
handler: loaderFn
staleReloadMode?: 'background' | 'blocking'
}
```

- Optional
Expand All @@ -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 `<Outlet />` 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).

Expand Down
9 changes: 9 additions & 0 deletions docs/router/api/router/RouterOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
53 changes: 50 additions & 3 deletions docs/router/guide/data-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions packages/react-router/src/fileRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import type {
RouteById,
RouteConstraints,
RouteIds,
RouteLoaderFn,
RouteLoaderEntry,
UpdatableRouteOptions,
UseNavigateResult,
} from '@tanstack/router-core'
Expand Down Expand Up @@ -174,7 +174,7 @@ export function FileRouteLoader<
): <TLoaderFn>(
loaderFn: Constrain<
TLoaderFn,
RouteLoaderFn<
RouteLoaderEntry<
Register,
TRoute['parentRoute'],
TRoute['types']['id'],
Expand Down
2 changes: 2 additions & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,9 @@ export type {
FileBaseRouteOptions,
BaseRouteOptions,
UpdatableRouteOptions,
LoaderStaleReloadMode,
RouteLoaderFn,
RouteLoaderEntry,
LoaderFnContext,
RouteContextFn,
ContextOptions,
Expand Down
31 changes: 24 additions & 7 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand All @@ -680,7 +682,7 @@ const runLoader = async (
}))
}

if (route.options.loader) {
if (loader) {
const loaderData = loaderResultIsPromise
? await loaderResult
: loaderResult
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down
99 changes: 88 additions & 11 deletions packages/router-core/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,11 +292,44 @@ export type ResolveRouteContext<TRouteContextFn, TBeforeLoadFn> = Assign<
ContextAsyncReturnType<TBeforeLoadFn>
>

export type ResolveRouteLoaderFn<TLoaderFn> = 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<TLoaderFn> = unknown extends TLoaderFn
? TLoaderFn
: LooseAsyncReturnType<TLoaderFn> extends never
: LooseAsyncReturnType<ResolveRouteLoaderFn<TLoaderFn>> extends never
? undefined
: LooseAsyncReturnType<TLoaderFn>
: LooseAsyncReturnType<ResolveRouteLoaderFn<TLoaderFn>>

export type ResolveFullSearchSchema<
TParentRoute extends AnyRoute,
Expand Down Expand Up @@ -1010,8 +1043,7 @@ export interface FilebaseRouteOptionsInterface<

loader?: Constrain<
TLoaderFn,
(
ctx: LoaderFnContext<
| RouteLoaderFn<
TRegister,
TParentRoute,
TId,
Expand All @@ -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
>
>
}

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading