Skip to content
Open
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: 7 additions & 1 deletion .changeset/lane-match-loader-rewrite.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@
'@tanstack/react-router': patch
'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
'@tanstack/router-devtools-core': patch
'@tanstack/react-router-devtools': patch
'@tanstack/solid-router-devtools': patch
'@tanstack/vue-router-devtools': patch
'@tanstack/router-devtools': patch
---

Rewrite match loading around a lane-based scheduler that tracks each navigation, preload, and background reload as an ordered unit of work. This fixes pending/redirect/retry state leaking between overlapping navigations, restores correct SSR status codes for redirects, errors, and not-found responses, and closes hydration gaps where the client re-ran work the server had already completed.

- Route `headers()` now only runs on the server, matching the documented behavior — it is no longer invoked during client-side asset projection.
- Default `gcTime` and `preloadGcTime` are reduced from 30 minutes (`1_800_000`) to 5 minutes (`300_000`).
- Devtools packages are released alongside the router packages so they consume the rewritten router store interface.

**Removed / changed public API**

Expand All @@ -26,4 +32,4 @@ Rewrite match loading around a lane-based scheduler that tracks each navigation,
- Removed the exported `GetMatchFn` and `UpdateMatchFn` types, along with the methods they typed.
- Removed the standalone `getMatchedRoutes()` export from `@tanstack/router-core` — use the `router.getMatchedRoutes()` instance method instead.
- `MatchRoutesOpts.preload` and `MatchRoutesOpts.dest` have been removed.
- `StartTransitionFn` is now `(fn, expected, urgent?) => Promise<boolean>` (previously `(fn) => void`). This only affects custom framework adapters that implement `startTransition`.
- `StartTransitionFn` is now `(fn, expected) => Promise<boolean>` (previously `(fn) => void`). This only affects custom framework adapters that implement `startTransition`.
9 changes: 4 additions & 5 deletions docs/router/api/router/RouterType.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,8 @@ protocol.

- Type: `(opts?: {filter?: (d: MakeRouteMatchUnion<TRouter>) => boolean, sync?: boolean, forcePending?: boolean }) => Promise<void>`
- This is useful any time your loader data might be out of date or stale. For example, if you have a route that displays a list of posts, and you have a loader function that fetches the list of posts from an API, you might want to invalidate the route matches for that route any time a new post is created so that the list of posts is always up-to-date.
- If `filter` is not supplied, all committed and cached match generations are invalidated.
- If `filter` is supplied, it is evaluated against committed and cached matches. Selecting one generation invalidates every committed or cached generation with the same match ID.
- If `filter` is not supplied, all committed and cached match generations are invalidated and all active preloads are canceled.
- If `filter` is supplied, it is evaluated against the available committed, cached, loading, and preloading matches. Selecting one match invalidates every generation with the same match ID. An active preload is canceled when its lane contains a selected match ID.
- Invalidation reruns `beforeLoad`; reusable loader data is marked stale and reloads through the normal loading protocol. Route-level `context` remains reusable while the match ID is unchanged.
- If `sync` is `true`, stale loader work is blocking and the returned promise resolves after it finishes instead of leaving a background refresh detached.
- If `forcePending` is `true`, selected routes that need loading enter the normal pending protocol even when successful data was already available.
Expand Down Expand Up @@ -169,9 +169,8 @@ presentation. Successful loader data can enter the normal in-memory route cache
and remain reusable according to `preloadStaleTime` and `preloadGcTime`.

Completed preload `beforeLoad` context is not cached. A later navigation reruns
`beforeLoad` unless it adopts an identical whole-route preload that is still
active. Adoption also requires unchanged router context, additional context,
and user-supplied location state.
`beforeLoad` with navigation semantics. It can still join a pending loader for
the same match ID or reuse completed cached loader data.

- Type: `(opts: NavigateOptions) => Promise<RouteMatch[] | undefined>`
- Properties
Expand Down
9 changes: 3 additions & 6 deletions docs/router/guide/preloading.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,8 @@ two independent lifetimes:
- **Unused retention defaults to 5 minutes.** Configure it with
`defaultPreloadGcTime` or a route's `preloadGcTime`.
- **The speculative lane is never promoted into router state.** Navigation
creates its own presentation and can reuse cached loader data. It reruns
`beforeLoad` unless it adopts the same whole lane while that preload is still
active.
creates its own presentation and reruns `beforeLoad`, but it can reuse cached
loader data or join a loader for the same match that is still pending.

If you need more control over preloading, caching and/or garbage collection of preloaded data, you should use an external caching library like [TanStack Query](https://tanstack.com/query).

Expand Down Expand Up @@ -145,9 +144,7 @@ export const Route = createFileRoute('/posts/$postId')({

Client-side preloading also runs each new route's `beforeLoad` with `preload: true`. A completed preload never caches the context returned by `beforeLoad`; a later navigation calls `beforeLoad` again with `preload: false`, even when it reuses the preload's cached loader data.

There is one exception: if navigation starts while an identical whole-route preload is still running, it can adopt that active work. Compatibility includes the complete ordered route lane, params, search, router context, additional context, user-supplied location state, redirect depth, and any explicit mask's href, search, state, and `unmaskOnReload` value. Router-managed history and temporary-mask keys are ignored. In that case navigation can use the active preload's `beforeLoad` result, or follow its redirect, without calling `beforeLoad` again. A completed, failed, not-found, or canceled preload does not donate `beforeLoad` context to a later navigation. The `shouldReload` option remains loader-only.

If any route in the lane has `preload: false`, navigation does not adopt the active preload. It reruns the `beforeLoad` chain and performs the loader work that speculation skipped.
A navigation never adopts speculative `beforeLoad` context or control flow. If a loader for the same match ID is already pending, navigation can join that loader promise after running its own `beforeLoad`. The joined promise shares its loader outcome, including an error, not-found result, or redirect. Cancellation remains lane-local: canceling one waiter releases its lease without canceling a generation that another lane still owns. Only successful loader data persists in the loader cache; after a failed preload loader has settled, a later navigation runs a new generation. Invalidation or a `shouldReload` decision can also require a new generation. A route with `preload: false` skips its speculative loader work and loads normally during navigation.

## Preloading with External Libraries

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as PostsRouteImport } from './routes/posts'
import { Route as PreloadDisabledRouteImport } from './routes/preload-disabled'
import { Route as SharedSingletonRouteImport } from './routes/shared-singleton'
import { Route as ViewportTestRouteImport } from './routes/viewport-test'
import { Route as WithoutLoaderRouteImport } from './routes/without-loader'
Expand All @@ -35,6 +36,11 @@ const PostsRoute = PostsRouteImport.update({
path: '/posts',
getParentRoute: () => rootRouteImport,
} as any)
const PreloadDisabledRoute = PreloadDisabledRouteImport.update({
id: '/preload-disabled',
path: '/preload-disabled',
getParentRoute: () => rootRouteImport,
} as any)
const SharedSingletonRoute = SharedSingletonRouteImport.update({
id: '/shared-singleton',
path: '/shared-singleton',
Expand Down Expand Up @@ -78,6 +84,7 @@ const LayoutLayout2LayoutBRoute = LayoutLayout2LayoutBRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/posts': typeof PostsRouteWithChildren
'/preload-disabled': typeof PreloadDisabledRoute
'/shared-singleton': typeof SharedSingletonRoute
'/viewport-test': typeof ViewportTestRoute
'/without-loader': typeof WithoutLoaderRoute
Expand All @@ -88,6 +95,7 @@ export interface FileRoutesByFullPath {
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/preload-disabled': typeof PreloadDisabledRoute
'/shared-singleton': typeof SharedSingletonRoute
'/viewport-test': typeof ViewportTestRoute
'/without-loader': typeof WithoutLoaderRoute
Expand All @@ -101,6 +109,7 @@ export interface FileRoutesById {
'/': typeof IndexRoute
'/_layout': typeof LayoutRouteWithChildren
'/posts': typeof PostsRouteWithChildren
'/preload-disabled': typeof PreloadDisabledRoute
'/shared-singleton': typeof SharedSingletonRoute
'/viewport-test': typeof ViewportTestRoute
'/without-loader': typeof WithoutLoaderRoute
Expand All @@ -115,6 +124,7 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/posts'
| '/preload-disabled'
| '/shared-singleton'
| '/viewport-test'
| '/without-loader'
Expand All @@ -125,6 +135,7 @@ export interface FileRouteTypes {
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/preload-disabled'
| '/shared-singleton'
| '/viewport-test'
| '/without-loader'
Expand All @@ -137,6 +148,7 @@ export interface FileRouteTypes {
| '/'
| '/_layout'
| '/posts'
| '/preload-disabled'
| '/shared-singleton'
| '/viewport-test'
| '/without-loader'
Expand All @@ -151,6 +163,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LayoutRoute: typeof LayoutRouteWithChildren
PostsRoute: typeof PostsRouteWithChildren
PreloadDisabledRoute: typeof PreloadDisabledRoute
SharedSingletonRoute: typeof SharedSingletonRoute
ViewportTestRoute: typeof ViewportTestRoute
WithoutLoaderRoute: typeof WithoutLoaderRoute
Expand Down Expand Up @@ -179,6 +192,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PostsRouteImport
parentRoute: typeof rootRouteImport
}
'/preload-disabled': {
id: '/preload-disabled'
path: '/preload-disabled'
fullPath: '/preload-disabled'
preLoaderRoute: typeof PreloadDisabledRouteImport
parentRoute: typeof rootRouteImport
}
'/shared-singleton': {
id: '/shared-singleton'
path: '/shared-singleton'
Expand Down Expand Up @@ -279,6 +299,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LayoutRoute: LayoutRouteWithChildren,
PostsRoute: PostsRouteWithChildren,
PreloadDisabledRoute: PreloadDisabledRoute,
SharedSingletonRoute: SharedSingletonRoute,
ViewportTestRoute: ViewportTestRoute,
WithoutLoaderRoute: WithoutLoaderRoute,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function RootComponent() {
>
without-loader
</Link>{' '}
<Link to="/preload-disabled">preload-disabled</Link>{' '}
<Link
to="/shared-singleton"
activeProps={{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/preload-disabled')({
preload: false,
component: () => <div data-testid="preload-disabled">preload disabled</div>,
})
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,28 @@ test('scrolling into viewport a link with preload=viewport to a route should pre
: '/assets/viewport-test'
expect(request.url()).toEqual(expect.stringContaining(expectedString))
})

test('preload: false skips an auto-code-split component chunk until navigation', async ({
page,
}) => {
await page.waitForLoadState('networkidle')
const expectedString =
process.env.NODE_ENV === 'development'
? 'preload-disabled.tsx?tsr-split'
: '/assets/preload-disabled'
const matchingRequests: Array<string> = []
page.on('request', (request) => {
if (request.url().includes(expectedString)) {
matchingRequests.push(request.url())
}
})

const link = page.getByRole('link', { name: 'preload-disabled' })
await link.hover()
await page.waitForTimeout(300)
expect(matchingRequests).toEqual([])

await link.click()
await expect(page.getByTestId('preload-disabled')).toBeVisible()
expect(matchingRequests.length).toBeGreaterThan(0)
})
4 changes: 4 additions & 0 deletions e2e/react-router/basic-file-based/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ const router = createRouter({
defaultStaleTime: 5000,
scrollRestoration: true,
routeMasks: [mask],
unmaskOnReload: true,
})

// Test hook for browser-history integration tests in this fixture.
;(window as any).__TSR_TEST_ROUTER__ = router

// Register things for typesafety
declare module '@tanstack/react-router' {
interface Register {
Expand Down
65 changes: 65 additions & 0 deletions e2e/react-router/basic-file-based/tests/mask.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'

async function readLocation(page: Page) {
return page.evaluate(() => {
const location = (window as any).__TSR_TEST_ROUTER__.state.location
return {
pathname: location.pathname,
maskedPathname: location.maskedLocation?.pathname,
maskedSearch: location.maskedLocation?.search,
maskedStateSource: location.maskedLocation?.state?.source,
}
})
}

test('route masks transform params and expose masked pathname in the browser (react)', async ({
page,
Expand All @@ -24,3 +37,55 @@ test('route masks transform params and expose masked pathname in the browser (re
'/masks/public/user-42',
)
})

test('native history restores a reversible mask across traversal and reload', async ({
page,
}) => {
await page.goto('/masks')

await page.evaluate(async () => {
await (window as any).__TSR_TEST_ROUTER__.navigate({
to: '/masks/admin/$userId',
params: { userId: '42' },
mask: {
to: '/masks/public/$username',
params: { username: 'user-42' },
search: { source: 'reversible' },
state: { source: 'state' },
unmaskOnReload: false,
},
})
})

await page.waitForURL('/masks/public/user-42?source=reversible')
await expect(page.getByTestId('admin-user-id')).toHaveText('42')
expect(await readLocation(page)).toEqual({
pathname: '/masks/admin/42',
maskedPathname: '/masks/public/user-42',
maskedSearch: { source: 'reversible' },
maskedStateSource: 'state',
})

await page.evaluate(async () => {
await (window as any).__TSR_TEST_ROUTER__.navigate({ to: '/masks' })
})
await page.waitForURL('/masks')
await page.goBack()
await expect(page.getByTestId('admin-user-id')).toHaveText('42')
expect(await readLocation(page)).toMatchObject({
pathname: '/masks/admin/42',
maskedPathname: '/masks/public/user-42',
maskedSearch: { source: 'reversible' },
maskedStateSource: 'state',
})

await page.reload()
await expect(page).toHaveURL('/masks/public/user-42?source=reversible')
await expect(page.getByTestId('admin-user-id')).toHaveText('42')
expect(await readLocation(page)).toMatchObject({
pathname: '/masks/admin/42',
maskedPathname: '/masks/public/user-42',
maskedSearch: { source: 'reversible' },
maskedStateSource: 'state',
})
})
26 changes: 13 additions & 13 deletions e2e/react-router/issue-7120/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as PostsRouteImport } from './routes/posts'
import { Route as IndexRouteImport } from './routes/index'
import { Route as PostsRouteImport } from './routes/posts'

const PostsRoute = PostsRouteImport.update({
id: '/posts',
path: '/posts',
getParentRoute: () => rootRouteImport,
} as any).lazy(() => import('./routes/posts.lazy').then((d) => d.Route))
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const PostsRoute = PostsRouteImport.update({
id: '/posts',
path: '/posts',
getParentRoute: () => rootRouteImport,
} as any).lazy(() => import('./routes/posts.lazy').then((d) => d.Route))

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
Expand Down Expand Up @@ -51,20 +51,20 @@ export interface RootRouteChildren {

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/posts': {
id: '/posts'
path: '/posts'
fullPath: '/posts'
preLoaderRoute: typeof PostsRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/posts': {
id: '/posts'
path: '/posts'
fullPath: '/posts'
preLoaderRoute: typeof PostsRouteImport
parentRoute: typeof rootRouteImport
}
}
}

Expand Down
Loading
Loading