diff --git a/docs/router/api/router/LinkOptionsType.md b/docs/router/api/router/LinkOptionsType.md index 481a5b4a3db..cf20e0ff198 100644 --- a/docs/router/api/router/LinkOptionsType.md +++ b/docs/router/api/router/LinkOptionsType.md @@ -9,7 +9,7 @@ The `LinkOptions` type extends the [`NavigateOptions`](./NavigateOptionsType.md) type LinkOptions = NavigateOptions & { target?: HTMLAnchorElement['target'] activeOptions?: ActiveOptions - preload?: false | 'intent' + preload?: false | 'intent' | 'viewport' | 'render' preloadDelay?: number disabled?: boolean } @@ -42,7 +42,7 @@ The `LinkOptions` object accepts/contains the following properties: - Type: `number` - Optional -- Delay intent preloading by this many milliseconds. If the intent exits before this delay, the preload will be cancelled. +- Delay focus and hover intent preloading by this many milliseconds. Touch intent preloads immediately. If focus or hover exits before the delay, the preload will be cancelled. ### `disabled` diff --git a/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index a1c0cb42aa5..ac35cdc8452 100644 --- a/docs/router/api/router/RouteOptionsType.md +++ b/docs/router/api/router/RouteOptionsType.md @@ -115,14 +115,14 @@ type beforeLoad = ( location: ParsedLocation navigate: NavigateFn // @deprecated buildLocation: BuildLocationFn - cause: 'enter' | 'stay' + cause: 'preload' | 'enter' | 'stay' }, ) => Promise | TRouteContext | void ``` - Optional - [`ParsedLocation`](./ParsedLocationType.md) -- This async function is called before a route is loaded. If an error is thrown here, the route's loader will not be called and the route will not render. If thrown during a navigation, the navigation will be canceled and the error will be passed to the `onError` function. If thrown during a preload event, the error will be logged to the console and the preload will fail. +- This async function is called before a route is loaded. If it fails, the route's loader and its descendants will not run. During navigation, ordinary errors become the match's error state and are passed to the `onError` function. During a preload, ordinary errors and not-found results are represented in the returned speculative match lane instead of rejecting the `preloadRoute` promise. - 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 `TRouteContext` object, that object will be merged into the route's context and be made available in the `loader` and other related route components/methods. - It's common to use this function to check if a user is authenticated and redirect them to a login page if they are not. To do this, you can either return or throw a `redirect` object from this function. @@ -159,9 +159,9 @@ type loader = - Optional - [`ParsedLocation`](./ParsedLocationType.md) -- This async function is called when a route is matched and passed the route's match object. If an error is thrown here, the route will be put into an error state and the error will be thrown during render. If thrown during a navigation, the navigation will be canceled and the error will be passed to the `onError` function. If thrown during a preload event, the error will be logged to the console and the preload will fail. +- This async function is called when a route is matched and passed the route's match object. During navigation, ordinary errors become the match's error state and are passed to the `onError` function. During a preload, ordinary errors and not-found results are represented in the returned speculative match lane instead of rejecting the `preloadRoute` promise. - 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. +- If this function returns a `TLoaderData` object, that object will be stored on the route match and can remain available in the in-memory cache after the match becomes inactive. Navigation-owned data uses `gcTime` for retention, while preload-owned data uses `preloadGcTime`. 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. @@ -190,6 +190,13 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record - Defaults to `routerOptions.defaultStaleTime`, which defaults to `0` - The amount of time in milliseconds that a route match's loader data will be considered fresh. If a route match is matched again within this time frame, its loader data will not be reloaded. +### `preload` property + +- Type: `boolean` +- Optional +- Defaults to `true` +- If `false`, speculative preloads still run this route's `beforeLoad` function but skip its `loader`. A navigation runs both `beforeLoad` and the skipped `loader` normally. + ### `preloadStaleTime` property - Type: `number` @@ -202,7 +209,7 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record - Type: `number` - Optional - Defaults to `routerOptions.defaultGcTime`, which defaults to 5 minutes. -- The amount of time in milliseconds that loader data from an ordinary load will be kept in memory after it is no longer in use. +- The retention window in milliseconds for unused loader data from an ordinary load. Once the data is older than this value, it is eligible for pruning during a later cache reconciliation. ### `shouldReload` property @@ -243,7 +250,7 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record - Type: `number` - Optional - Defaults to `routerOptions.defaultPreloadGcTime`, which defaults to 5 minutes. -- The amount of time in milliseconds that loader data produced by a preload can remain in memory while it is not in use. This controls retention; use `preloadStaleTime` to control whether retained data is fresh enough to reuse without reloading. +- The retention window in milliseconds for unused loader data produced by a preload. Once the data is older than this value, it is eligible for pruning during a later cache reconciliation. Use `preloadStaleTime` to control whether retained data is fresh enough to reuse without reloading. ### `preSearchFilters` property (⚠️ deprecated, use `search.middlewares` instead) @@ -266,7 +273,7 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record - Type: `(error: any) => void` - Optional - A function that will be called when an error is thrown during a navigation or preload event. -- If this function throws a [`redirect`](./redirectFunction.md), then the router will process and apply the redirect immediately. +- If this function throws a [`redirect`](./redirectFunction.md), the redirect replaces the original error and becomes control flow for the current navigation or preload operation. If it throws a not-found result, that result replaces the original error in the current match lane. ### `onEnter` property diff --git a/docs/router/api/router/RouterType.md b/docs/router/api/router/RouterType.md index cf857d5e885..cf8fdebeaea 100644 --- a/docs/router/api/router/RouterType.md +++ b/docs/router/api/router/RouterType.md @@ -165,12 +165,16 @@ Loads all of the currently matched route matches and resolves when they are all Preloads all of the matches that match the provided `NavigateOptions`. An active preload is speculative and is not published as the current match -presentation. Successful loader data can enter the normal in-memory route cache -and remain reusable according to `preloadStaleTime` and `preloadGcTime`. - -Every preload and navigation runs its own `beforeLoad` chain. Preloads can -donate cached or in-flight loader work, but never `beforeLoad` context or -control flow. +presentation. Successful loader data can enter the normal in-memory route +cache. Its freshness follows `preloadStaleTime`; once unused and older than +`preloadGcTime`, it is eligible for pruning during a later cache +reconciliation. + +Every preload and navigation runs its own `beforeLoad` chain. A later lane can +reuse successful settled loader data or join loader work that is still in +flight, but it never reuses `beforeLoad` context or an already-settled +redirect, error, or not-found result. If joined loader work later produces a +terminal outcome, all current consumers of that flight observe it. - Type: `(opts: NavigateOptions) => Promise` - Properties diff --git a/docs/router/guide/data-loading.md b/docs/router/guide/data-loading.md index dad97b1e390..26a82eaad68 100644 --- a/docs/router/guide/data-loading.md +++ b/docs/router/guide/data-loading.md @@ -81,7 +81,7 @@ Use the object form when you want to configure loader-specific behavior such as The `loader` function receives a single object with the following properties: -- `abortController` - The route's abortController. Its signal is cancelled when the route is unloaded or when the Route is no longer relevant and the current invocation of the `loader` function becomes outdated. +- `abortController` - The controller for this shareable loader invocation. A preload and navigation can share the same in-flight loader work. Its signal is cancelled after the invocation becomes outdated and no consumer still needs it. - `cause` - The cause of the current route match. Can be either one of the following: - `enter` - When the route is matched and loaded after not being matched in the previous location. - `preload` - When the route is being preloaded. @@ -168,11 +168,11 @@ 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 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, `gcTime` and `preloadGcTime` are **5 minutes**, meaning unused loader data is removed from the in-memory cache after 5 minutes. They can be configured independently. +- By default, `staleTime` is set to `0`, so reusable successful data is immediately considered stale. When the same loader key is entered again or `router.load()` is called explicitly, stale data revalidates in the background by default. A different loader key identifies a separate cache entry and must load if it has no reusable data. +- By default, loader data produced by a preload is considered fresh for **30 seconds**. Every preload and navigation still runs its own `beforeLoad` chain, but a later preload and the first navigation can reuse the preload's loader data or in-flight loader work during that interval. After navigation accepts that loader generation, subsequent freshness checks use the standard `staleTime`. +- By default, `gcTime` and `preloadGcTime` define **5-minute** retention windows. Once unused data is older than its applicable window, it is eligible for pruning during a later cache reconciliation. The two windows can be configured independently. - 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. +- `router.invalidate()` selects matching committed, cached, and in-flight loader generations for invalidation and retires matching active preload lanes. Current active routes reload through the normal loading protocol; cached inactive data remains marked stale and reloads when it is reused. By default, stale successful loader data revalidates in the background unless `sync: true` is requested. ### Using `loaderDeps` to access search params @@ -221,7 +221,13 @@ 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. When a stale route is entered again, its loader key changes, or `router.load()` is called explicitly, the route will reload in the background. +By default, `staleTime` for accepted navigation data is `0`ms, while +`preloadStaleTime` is 30 seconds. A successful preload can therefore provide +loader data to the first navigation during that interval. Once navigation +accepts that loader generation, normal navigation freshness applies. With the +default `staleTime`, the data is immediately stale for a later use of the same +loader key and revalidates in the background while cached data remains visible. +A different loader key identifies a separate cache entry. **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: @@ -313,7 +319,10 @@ and `preloadStaleTime` for freshness, so the default settings keep a recent preload in memory and let the first navigation reuse it without another loader call. -To opt out of preloading, don't turn it on via the `routerOptions.defaultPreload` or `routeOptions.preload` options. +Use `routerOptions.defaultPreload` to control automatic link preloading. Setting +`routeOptions.preload` to `false` has a narrower effect: a speculative lane +still runs that route's `beforeLoad`, but skips its `loader`. Navigation runs +both normally. ## Passing all loader events to an external cache @@ -326,7 +335,11 @@ const router = createRouter({ }) ``` -This will ensure that every preload, load, and reload event will trigger your `loader` functions, which can then be handled and deduped by your external cache. +This makes settled preload data immediately stale in the Router, allowing your +external cache to decide whether to fetch. Retention still follows +`preloadGcTime`, and overlapping preload or navigation consumers can still +share in-flight loader work. A route's `shouldReload` option can also suppress +a loader call. ## Using Router Context @@ -481,7 +494,7 @@ export const Route = createFileRoute('/posts')({ ## Using the Abort Signal -The `abortController` property of the `loader` function is an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). Its signal is cancelled when the route is unloaded or when the `loader` call becomes outdated. This is useful for cancelling network requests when the route is unloaded or when the route's params change. Here is an example using it with a fetch call: +The `abortController` property of the `loader` function is an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) for that loader invocation. A preload and navigation can share an in-flight invocation, so its signal remains active while any consumer still needs the work. The signal is cancelled after the invocation becomes outdated and has no remaining consumers. Here is an example using it with a fetch call: ```tsx // src/routes/posts.tsx diff --git a/docs/router/guide/data-mutations.md b/docs/router/guide/data-mutations.md index 7ffb403bfb1..5fddfffdead 100644 --- a/docs/router/guide/data-mutations.md +++ b/docs/router/guide/data-mutations.md @@ -2,7 +2,11 @@ title: Data Mutations --- -Since TanStack router does not store or cache data, it's role in data mutation is slim to none outside of reacting to potential URL side-effects from external mutation events. That said, we've compiled a list of mutation-related features you might find useful and libraries that implement them. +TanStack Router caches route loader data, but it does not manage mutation or +submission state. Its role in mutation workflows is primarily invalidating +loader data and reacting to potential URL side effects from external mutation +events. That said, we've compiled a list of mutation-related features you might +find useful and libraries that implement them. Look for and use mutation utilities that support: @@ -35,9 +39,14 @@ Similar to data fetching, mutation state isn't a one-size-fits-all solution, so ## Invalidating TanStack Router after a mutation -TanStack Router comes with short-term caching built-in. So even though we're not storing any data after a route match is unmounted, there is a high probability that if any mutations are made related to the data stored in the Router, the current route matches' data could become stale. +TanStack Router comes with short-term caching built in. Loader data can remain +cached after a route match is unmounted, so a mutation can make both active and +cached route data stale. -When mutations related to loader data are made, we can use `router.invalidate` to force the router to reload all of the current route matches: +When mutations related to loader data are made, we can use `router.invalidate` +to invalidate committed, cached, and in-flight loader generations. Matching +active preload lanes are retired, and selected current active matches reload +through the normal loading protocol: ```tsx const router = useRouter() @@ -52,7 +61,9 @@ const addTodo = async (todo: Todo) => { } ``` -Invalidating all of the current route matches happens in the background, so existing data will continue to be served until the new data is ready, just as if you were navigating to a new route. +By default, stale successful loader data revalidates in the background, so +existing data remains visible until the new data is ready. Cached inactive +matches remain marked stale and reload when they are reused. If you want to await the invalidation until all loaders have finished, pass `{sync: true}` into `router.invalidate`: diff --git a/docs/router/guide/navigation.md b/docs/router/guide/navigation.md index dbabc0906ac..b2bb03ad0e2 100644 --- a/docs/router/guide/navigation.md +++ b/docs/router/guide/navigation.md @@ -127,9 +127,10 @@ export type LinkOptions< includeSearch?: boolean explicitUndefined?: boolean } - // If set, will preload the linked route on hover and cache it for this many milliseconds in hopes that the user will eventually navigate there. - preload?: false | 'intent' - // Delay intent preloading by this many milliseconds. If the intent exits before this delay, the preload will be cancelled. + // Choose the preload strategy for this link. `false` disables preloading; + // `'intent'`, `'viewport'`, and `'render'` select when it begins. + preload?: false | 'intent' | 'viewport' | 'render' + // Delay focus/hover intent by this many milliseconds. Touch intent preloads immediately. preloadDelay?: number // If true, will render the link without the href attribute disabled?: boolean @@ -707,7 +708,14 @@ const link = ( ### Link Preloading -The `Link` component supports automatically preloading routes on intent (hovering or touchstart for now). This can be configured as a default in the router options (which we'll talk more about soon) or by passing a `preload='intent'` prop to the `Link` component. Here's an example: +The `Link` component supports four `preload` values: + +- `false` disables automatic preloading. +- `'intent'` preloads when the link receives focus, is hovered, or is touched. +- `'viewport'` preloads when the link enters the viewport. +- `'render'` preloads as soon as the link renders. + +This can be configured as a default in the router options (which we'll talk more about soon) or by passing the `preload` prop to the `Link` component. Here's an intent-preloading example: ```tsx const link = ( @@ -723,7 +731,7 @@ What's even better is that by using a cache-first library like `@tanstack/query` ### Link Preloading Delay -Along with preloading is a configurable delay which determines how long a user must hover over a link to trigger the intent-based preloading. The default delay is 50 milliseconds, but you can change this by passing a `preloadDelay` prop to the `Link` component with the number of milliseconds you'd like to wait: +For `'intent'` preloading, a configurable delay determines how long a link must remain focused or hovered before preloading begins. If focus or hover ends before the delay, the queued preload is cancelled. Touch intent preloads immediately without waiting for the delay. The default delay is 50 milliseconds, but you can change it by passing a `preloadDelay` prop to the `Link` component: ```tsx const link = ( diff --git a/docs/router/guide/preloading.md b/docs/router/guide/preloading.md index a2f57e78e8d..6ae9563f2f8 100644 --- a/docs/router/guide/preloading.md +++ b/docs/router/guide/preloading.md @@ -19,12 +19,13 @@ Preloading in TanStack Router is a way to load a route before the user actually ## How long does preloaded data stay in memory? Successful preloaded loader results can enter the router's in-memory cache with -two independent lifetimes: +two independent policies: - **Freshness defaults to 30 seconds.** Configure it with `defaultPreloadStaleTime` or a route's `preloadStaleTime`. -- **Unused retention defaults to 5 minutes.** Configure it with - `defaultPreloadGcTime` or a route's `preloadGcTime`. +- **The unused retention window defaults to 5 minutes.** Configure it with + `defaultPreloadGcTime` or a route's `preloadGcTime`. Older unused entries + are eligible for pruning during a later cache reconciliation. - **The speculative lane is never promoted into router state.** Navigation creates its own presentation and runs its own `beforeLoad` chain. It can reuse cached loader data or join a loader that is still in flight. @@ -99,9 +100,10 @@ If you're using the built-in loaders, you can control how long preloaded data is Freshness and retention are separate. `preloadStaleTime` controls whether the retained loader result can be reused without another loader call. -`preloadGcTime` (or `defaultPreloadGcTime`) controls how long an unused preload -result can remain in the in-memory cache. Both preload GC options default to 5 -minutes. +`preloadGcTime` (or `defaultPreloadGcTime`) controls when an unused preload +result becomes eligible for pruning during a later cache reconciliation; it +does not schedule a timer to evict the result at that exact moment. Both preload +GC options default to 5 minutes. To change this, you can set the `defaultPreloadStaleTime` option on your router: @@ -145,9 +147,11 @@ export const Route = createFileRoute('/posts/$postId')({ Client-side preloading runs each route's `beforeLoad` with `preload: true`. Every later preload or navigation runs its own `beforeLoad` chain, so a navigation observes `preload: false` even when an identical preload is still -active. Preloads can donate cached or in-flight loader work, but not -`beforeLoad` context, redirects, errors, or not-found results. The -`shouldReload` option remains loader-only. +active. A later lane can reuse successful settled loader data or join loader +work that is still in flight, but it never reuses `beforeLoad` context or an +already-settled redirect, error, or not-found result. If joined loader work +later produces a terminal outcome, all current consumers of that flight observe +it. The `shouldReload` option remains loader-only. If a route has `preload: false`, its speculative lane still runs `beforeLoad`, but skips that route's loader. Navigation runs `beforeLoad` again and performs @@ -157,7 +161,12 @@ the skipped loader work. When integrating external caching libraries like React Query, which have their own mechanisms for determining stale data, you may want to override the default preloading and stale-while-revalidate logic of TanStack Router. These libraries often use options like staleTime to control the freshness of data. -To customize the preloading behavior in TanStack Router and fully leverage your external library's caching strategy, you can bypass the built-in caching by setting routerOptions.defaultPreloadStaleTime or routeOptions.preloadStaleTime to 0. This ensures that all preloads are marked as stale internally, and loaders are always invoked, allowing your external library, such as React Query, to manage data loading and caching. +To let an external cache make the freshness decision, set +`routerOptions.defaultPreloadStaleTime` or `routeOptions.preloadStaleTime` to +`0`. Settled preload data then becomes immediately stale in the Router, while +retention still follows `preloadGcTime`. Overlapping preload or navigation +consumers can still share in-flight loader work, and `shouldReload` can still +suppress a loader call. For example: @@ -193,27 +202,36 @@ This would then allow you, for instance, to use an option like React Query's `st If you need to manually preload a route, use the router's `preloadRoute` method. It accepts a standard TanStack `NavigateOptions` object and returns the -speculative match lane. An ordinary error or not-found is represented in that -returned lane; cancellation or control flow that produces no reusable lane can -return `undefined`. +speculative match lane. An ordinary error or not-found thrown while loading is +represented in that returned lane; cancellation or control flow that produces +no reusable lane can return `undefined`. # React ```tsx +import { isNotFound } from '@tanstack/react-router' + function Component() { const router = useRouter() useEffect(() => { async function preload() { - try { - const matches = await router.preloadRoute({ - to: postRoute, - params: { id: 1 }, - }) - } catch (err) { - // Failed to preload route + const matches = await router.preloadRoute({ + to: postRoute, + params: { id: 1 }, + }) + + const routeFailure = matches?.find( + (match) => + match.status === 'error' || + match.status === 'notFound' || + isNotFound(match.error), + ) + + if (routeFailure) { + // Inspect routeFailure.error } } @@ -227,18 +245,27 @@ function Component() { # Solid ```tsx +import { isNotFound } from '@tanstack/solid-router' + function Component() { const router = useRouter() createEffect(() => { async function preload() { - try { - const matches = await router.preloadRoute({ - to: postRoute, - params: { id: 1 }, - }) - } catch (err) { - // Failed to preload route + const matches = await router.preloadRoute({ + to: postRoute, + params: { id: 1 }, + }) + + const routeFailure = matches?.find( + (match) => + match.status === 'error' || + match.status === 'notFound' || + isNotFound(match.error), + ) + + if (routeFailure) { + // Inspect routeFailure.error } } diff --git a/packages/router-core/src/link.ts b/packages/router-core/src/link.ts index 55d5a79ce84..7ec148b8f4e 100644 --- a/packages/router-core/src/link.ts +++ b/packages/router-core/src/link.ts @@ -671,13 +671,15 @@ export interface LinkOptionsProps { /** * The preloading strategy for this link * - `false` - No preloading - * - `'intent'` - Preload the linked route on hover and cache it for this many milliseconds in hopes that the user will eventually navigate there. + * - `'intent'` - Preload the linked route when the user focuses, hovers over, or touches the link * - `'viewport'` - Preload the linked route when it enters the viewport + * - `'render'` - Preload the linked route as soon as it renders */ preload?: false | 'intent' | 'viewport' | 'render' /** - * When a preload strategy is set, this delays the preload by this many milliseconds. - * If the user exits the link before this delay, the preload will be cancelled. + * When the intent preload strategy is set, this delays focus and hover + * preloading by this many milliseconds. Touch intent preloads immediately. + * If focus or hover exits before this delay, the preload will be cancelled. */ preloadDelay?: number /**