Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
ceaa4d2
fix: lane match loader rewrite
Sheraff Jul 13, 2026
adf73dd
fix client/server build
Sheraff Jul 13, 2026
6f75f50
adopt preloaded beforeLoad
Sheraff Jul 13, 2026
f4b8934
fix adoption of server data
Sheraff Jul 13, 2026
7b5436b
fix internal error propagation, only user code is try/catch
Sheraff Jul 13, 2026
fb21a42
fix: solid Transitionner unsub cleanup before early return
Sheraff Jul 13, 2026
bf90602
fix: react-router not found ambiguous assertion
Sheraff Jul 13, 2026
f2ccae2
docs
Sheraff Jul 13, 2026
da5cb82
fix: preload always releases its borrowed lease
Sheraff Jul 13, 2026
cc21be0
error priority handling
Sheraff Jul 13, 2026
3a56878
Merge branch 'main' into fix-router-core-lane-match-loader
Sheraff Jul 13, 2026
2e8bbbe
Merge remote-tracking branch 'origin/main' into fix-router-core-lane-…
Sheraff Jul 13, 2026
af0f0c7
tests
Sheraff Jul 14, 2026
f54bf4b
fix #7673
Sheraff Jul 14, 2026
7be1149
ci: apply automated fixes
autofix-ci[bot] Jul 14, 2026
0ed58ea
rename consts to uppercase
Sheraff Jul 14, 2026
4fd731e
cleanup load-client
Sheraff Jul 14, 2026
acbfba3
fix load-client for undefined loaderData
Sheraff Jul 14, 2026
8fcf2eb
cleanup load-server
Sheraff Jul 14, 2026
3ba393e
ci: apply automated fixes
autofix-ci[bot] Jul 14, 2026
3d252e6
more cleanup
Sheraff Jul 14, 2026
4150cdf
ci: apply automated fixes
autofix-ci[bot] Jul 14, 2026
3ecc24b
update tests
Sheraff Jul 14, 2026
c801dcc
final coverage
Sheraff Jul 14, 2026
ded47d1
final fixed issue tests
Sheraff Jul 14, 2026
16dfcf9
ci: apply automated fixes
autofix-ci[bot] Jul 14, 2026
05efe8c
Merge remote-tracking branch 'origin/main' into fix-router-core-lane-…
Sheraff Jul 15, 2026
ba80053
minor byte shave in router-core
Sheraff Jul 15, 2026
125b13f
minor byte shave in Match
Sheraff Jul 15, 2026
2bba8fa
minor byte shaving
Sheraff Jul 15, 2026
1f50bd3
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
cf40d59
comments and cleanup
Sheraff Jul 15, 2026
ec631eb
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
b56cb51
test: does `declare` save any bytes?
Sheraff Jul 15, 2026
ef40259
test again, but i selected everything this time
Sheraff Jul 15, 2026
e177851
test: same, but property wasn't even initialized
Sheraff Jul 15, 2026
e7322f8
simplify Transitioner based on having a unique transitioner per router
Sheraff Jul 15, 2026
9810491
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
45f7731
deprecated test
Sheraff Jul 15, 2026
a8d7c8c
no reset key on server
Sheraff Jul 15, 2026
f90018f
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
f7f266b
some jsdoc
Sheraff Jul 15, 2026
5a8cc80
simplify getSemanticMatch
Sheraff Jul 15, 2026
1b20933
more jsdoc
Sheraff Jul 15, 2026
83fea0f
ci: apply automated fixes
autofix-ci[bot] Jul 15, 2026
1f4838d
beforeLoad context cleanup on error + naming + misc bugs
Sheraff Jul 15, 2026
4746633
server preload is no-op
Sheraff Jul 17, 2026
6683863
console.error over promise.reject for lifecycle errors
Sheraff Jul 17, 2026
3d68db6
INTERNALS update
Sheraff Jul 17, 2026
423f905
fix(router-core): complete lane match loader rewrite (#7858)
Sheraff Jul 20, 2026
7443d96
Merge remote-tracking branch 'origin/main' into fix-router-core-lane-…
Sheraff Jul 20, 2026
1f27102
fix(router-core): harden lane loading lifecycle (#7869)
Sheraff Jul 21, 2026
03a1fde
perf(router): reduce SSR lane loading overhead (#7875)
Sheraff Jul 22, 2026
9043f4f
Merge branch 'main' into fix-router-core-lane-match-loader
Sheraff Jul 22, 2026
da82a47
ci: apply automated fixes
autofix-ci[bot] Jul 22, 2026
f84d087
ci: apply automated fixes (attempt 2/3)
autofix-ci[bot] Jul 22, 2026
90a7f39
fix(router): address lane loader regressions (#7880)
Sheraff Jul 23, 2026
baa18cd
fix(router): final-review regressions, changeset, and byte recovery (…
Sheraff Jul 23, 2026
55ceb3a
chore(router-core): lane match loader final byte-shave (#7885)
Sheraff Jul 23, 2026
fd8e256
Merge branch 'main' into fix-router-core-lane-match-loader
Sheraff Jul 23, 2026
a4d5b8f
Merge remote-tracking branch 'origin/main' into fix-router-core-lane-…
Sheraff Jul 23, 2026
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
29 changes: 29 additions & 0 deletions .changeset/lane-match-loader-rewrite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@tanstack/router-core': patch
'@tanstack/react-router': patch
'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
Comment on lines +2 to +5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== changeset file =="
cat -n .changeset/lane-match-loader-rewrite.md

echo
echo "== repo files mentioning semver / changeset / major release =="
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/.git/**' -e 'semver|major release|patch release|changeset' README* .changeset docs . 2>/dev/null | head -n 200

Repository: TanStack/router

Length of output: 18706


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file list around changesets =="
git ls-files '.changeset/*' | sed -n '1,120p'

echo
echo "== outline of the referenced changeset =="
wc -l .changeset/lane-match-loader-rewrite.md
sed -n '1,120p' .changeset/lane-match-loader-rewrite.md

Repository: TanStack/router

Length of output: 3407


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== .changeset/config.json =="
cat -n .changeset/config.json

echo
echo "== docs / config mentioning patch or major policy =="
rg -n --hidden --glob '!**/node_modules/**' --glob '!**/.git/**' -e 'patch.*major|major.*patch|breaking.*patch|changeset.*patch|changeset.*major|semver policy|version policy|breaking change' .changeset docs README* package.json .github . 2>/dev/null | head -n 200

Repository: TanStack/router

Length of output: 16531


Use a major changeset for the API removals. The affected sections remove public members and change StartTransitionFn’s signature, so patch release metadata is inconsistent with the documented breaking surface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/lane-match-loader-rewrite.md around lines 2 - 5, Update the
changeset release levels for `@tanstack/router-core`, `@tanstack/react-router`,
`@tanstack/solid-router`, and `@tanstack/vue-router` from patch to major to reflect
the removed public members and changed StartTransitionFn signature.

---

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`).

**Removed / changed public API**

- `RouterState` no longer includes `loadedAt`, `isTransitioning`, `statusCode`, or `redirect`. Use `match.updatedAt` in place of `loadedAt`; subscribe to `router.state.status` / `router.state.isLoading` in place of `isTransitioning`; server response status and redirect handling are now internal to the server loader and are no longer exposed on `router.state`.
- `RouteMatch.fetchCount` has been removed, with no replacement — it was purely informational.
- `RouteMatch.status` no longer includes `'redirected'` (it remains `'pending' | 'success' | 'error' | 'notFound'`) — redirected matches are dropped from the match list instead of being rendered.
- `RouteMatch.globalNotFound` has been renamed and privatized to the internal `_notFound` field. Use `match.status === 'notFound'` instead.
- Removed `RouterCore` members `getMatch()`, `updateMatch()`, `cancelMatch()`, and `cancelMatches()` — read matches from `router.state.matches` (e.g. `router.state.matches.find((m) => m.id === id)`); there is no replacement for mutating or cancelling an individual in-flight match from outside the router.
- Removed `hasNotFoundMatch()` — use `router.state.matches.some((m) => m.status === 'notFound')`.
- Removed `looseRoutesById` — use `routesById`.
- Removed `isPrerendering()`, `isViewTransitionTypesSupported`, and `viewTransitionPromise`, with no replacement.
- Removed `getParsedLocationHref()` and `clearExpiredCache()`, with no replacement — expired cache entries are now reconciled automatically as part of match commit.
- Removed `latestLoadPromise` and `beforeLoad()`, with no replacement.
- `commitLocationPromise` and `pendingBuiltLocation` are now private (`_commitPromise`, `_pendingLocation`) and no longer part of the public `RouterCore` surface.
- 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`.
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ type RenderedEvent = {
}

type InterruptedNavigationRouter = {
latestLoadPromise: Promise<void> | undefined
load: () => Promise<void>
navigate: (options: {
to: '/fast/$id' | '/slow/$id'
Expand Down Expand Up @@ -104,18 +103,6 @@ function assertSlowNavigationSettlement(settlement: NavigationSettlement) {
)
}

async function awaitExpectedLoadSettlement(loadPromise: Promise<void>) {
try {
await loadPromise
} catch (reason) {
if (reasonHasAbortShape(reason) || reasonHasCancellationShape(reason)) {
return
}

throw reason
}
}

function reasonHasAbortShape(reason: unknown) {
return reason instanceof DOMException && reason.name === 'AbortError'
}
Expand Down Expand Up @@ -144,7 +131,6 @@ export function createWorkload(
let navigateFast: (id: string) => Promise<void> = uninitialized
let startSlowNavigation: (id: string) => Promise<NavigationSettlement> =
uninitializedSettlement
let getLatestLoadPromise: () => Promise<void> | undefined = () => undefined

function assertRenderedPage(page: 'shell' | 'fast', id?: string) {
const element = container?.querySelector<HTMLElement>('[data-bench-page]')
Expand Down Expand Up @@ -203,7 +189,6 @@ export function createWorkload(
const mounted = mountTestApp(container)
const router = mounted.router as InterruptedNavigationRouter
unmount = mounted.unmount
getLatestLoadPromise = () => router.latestLoadPromise

unsub = router.subscribe('onRendered', (event) => {
if (
Expand Down Expand Up @@ -265,7 +250,6 @@ export function createWorkload(
expectedRenderedPath = undefined
navigateFast = uninitialized
startSlowNavigation = uninitializedSettlement
getLatestLoadPromise = () => undefined
}

async function interrupt(
Expand All @@ -276,12 +260,6 @@ export function createWorkload(
const slowNavigation = startSlowNavigation(slowId)

await waitForSlowLoader(slowId)
const slowLoadPromise = getLatestLoadPromise()

if (!slowLoadPromise) {
throw new Error(`Slow navigation did not start a load for id: ${slowId}`)
}

await navigateFast(fastId)
resolveSlowLoader(slowId)

Expand All @@ -291,7 +269,6 @@ export function createWorkload(
assertSlowNavigationSettlement(settlement)
}

await awaitExpectedLoadSettlement(slowLoadPromise)
await drainMicrotasks()
}

Expand Down
4 changes: 2 additions & 2 deletions benchmarks/memory/server/scenarios/aborted-requests/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ const textDecoder = new TextDecoder()
const abortedRequestModes: Record<Framework, AbortedRequestMode> = {
react: {
readMode: 'first-chunk',
cancelMode: 'plain',
cancelMode: 'swallow-abort-error',
},
solid: {
readMode: 'first-chunk',
cancelMode: 'plain',
cancelMode: 'swallow-abort-error',
},
vue: {
readMode: 'shell-before-deferred',
Expand Down
15 changes: 11 additions & 4 deletions docs/router/api/router/RouteMatchType.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,26 @@ interface RouteMatch {
routeId: string
pathname: string
params: Route['allParams']
status: 'pending' | 'success' | 'error' | 'redirected' | 'notFound'
status: 'pending' | 'success' | 'error' | 'notFound'
isFetching: false | 'beforeLoad' | 'loader'
showPending: boolean
error: unknown
paramsError: unknown
searchError: unknown
updatedAt: number
loaderData?: Route['loaderData']
context: Route['allContext']
search: Route['fullSearchSchema']
fetchedAt: number
abortController: AbortController
cause: 'enter' | 'stay'
cause: 'preload' | 'enter' | 'stay'
ssr?: boolean | 'data-only'
}
```

`status` describes the match's render state. `isFetching` independently exposes
active `beforeLoad` or loader work. In particular, a successful match can report
`isFetching: 'loader'` while stale data remains visible during a background
reload.

The router state can contain matches below the pending, error, or not-found
boundary. Those matches remain observable as part of the structurally matched
lane even though the route renderer stops at the boundary.
16 changes: 10 additions & 6 deletions docs/router/api/router/RouteOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ The `RouteOptions` type accepts an object with the following properties:
- Type: `(rawSearchParams: unknown) => TSearchSchema`
- Optional
- A function that will be called when this route is matched and passed the raw search params from the current location and return valid parsed search params. If this function throws, the route will be put into an error state and the error will be thrown during render. If this function does not throw, its return value will be used as the route's search params and the return type will be inferred into the rest of the router.
- This is a planning callback. It must be deterministic and side-effect-free for the same input; do not navigate or mutate application/router state from it.
- Optionally, the parameter type can be tagged with the `SearchSchemaInput` type like this: `(searchParams: TSearchSchemaInput & SearchSchemaInput) => TSearchSchema`. If this tag is present, `TSearchSchemaInput` will be used to type the `search` property of `<Link />` and `navigate()` **instead of** `TSearchSchema`. The difference between `TSearchSchemaInput` and `TSearchSchema` can be useful, for example, to express optional search parameters.

### `search.middlewares` property
Expand All @@ -70,6 +71,7 @@ The `RouteOptions` type accepts an object with the following properties:
- Type: `(rawParams: Record<string, string>) => TParams`
- Optional
- A function that will be called when this route is matched and passed the raw params from the current location and return valid parsed params. If this function throws, the route will be put into an error state and the error will be thrown during render. If this function does not throw, its return value will be used as the route's params and the return type will be inferred into the rest of the router.
- This is a planning callback. It must be deterministic and side-effect-free for the same input.

### `stringifyParams` method (⚠️ deprecated, use `params.stringify` instead)

Expand All @@ -82,6 +84,7 @@ The `RouteOptions` type accepts an object with the following properties:
- Type: `(rawParams: Record<string, string>) => TParams | false`
- Optional
- A function that will be called when this route is matched and passed the raw params from the current location and return valid parsed params. If this function throws, the route will be put into an error state and the error will be thrown during render. If this function returns parsed params, its return value will be used as the route's params and the return type will be inferred into the rest of the router.
- This is a planning callback. It must be deterministic and side-effect-free for the same input.
- Experimental: returning `false` during incoming route matching skips this route and allows matching to continue to another candidate route.

### `params.priority` property
Expand Down Expand Up @@ -176,6 +179,7 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record<string, any>

- Optional
- A function that will be called before this route is matched to provide additional unique identification to the route match and serve as a dependency tracker for when the match should be reloaded. It should return any serializable value that can uniquely identify the route match from navigation to navigation.
- This is a planning callback and cache-key function. It must be deterministic and side-effect-free for the same validated search input. The returned value and any serialization methods on it, such as `toJSON`, must also be side-effect-free.
- By default, path params are already used to uniquely identify a route match, so it's unnecessary to return these here.
- If your route match relies on search params for unique identification, it's required that you return them here so they can be made available in the `loader`'s `deps` argument.

Expand All @@ -191,14 +195,14 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record<string, any>
- Type: `number`
- Optional
- Defaults to `routerOptions.defaultPreloadStaleTime`, which defaults to `30_000` ms (30 seconds)
- The amount of time in milliseconds that a route match's loader data will be considered fresh when preloading. If a route match is preloaded again within this time frame, its loader data will not be reloaded. If a route match is loaded (for navigation) within this time frame, the normal `staleTime` is used instead.
- The amount of time in milliseconds that loader data produced by a preload is considered fresh. Another preload or the first navigation can reuse it within this interval. After navigation accepts the generation, subsequent freshness uses `staleTime`.

### `gcTime` property

- Type: `number`
- Optional
- Defaults to `routerOptions.defaultGcTime`, which defaults to 30 minutes.
- The amount of time in milliseconds that a route match's loader data will be kept in memory after a preload or it is no longer in use.
- 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.

### `shouldReload` property

Expand Down Expand Up @@ -234,12 +238,12 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record<string, any>
- Defaults to `routerOptions.defaultPendingMinMs` which defaults to `500`
- The minimum amount of time in milliseconds that the pending component will be shown for if it is shown. This is useful to prevent the pending component from flashing on the screen for a split second.

### `preloadMaxAge` property
### `preloadGcTime` property

- Type: `number`
- Optional
- Defaults to `30_000` ms (30 seconds)
- The maximum amount of time in milliseconds that a route's preloaded route data will be cached for. If a route is not matched within this time frame, its loader data will be discarded.
- 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.

### `preSearchFilters` property (⚠️ deprecated, use `search.middlewares` instead)

Expand Down
4 changes: 2 additions & 2 deletions docs/router/api/router/RouterOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,14 @@ The `RouterOptions` type accepts an object with the following properties and met

- Type: `number`
- Optional
- Defaults to `routerOptions.defaultGcTime`, which defaults to 30 minutes.
- Defaults to 5 minutes.
- The default `preloadGcTime` a route should use if no preloadGcTime is provided.

### `defaultGcTime` property

- Type: `number`
- Optional
- Defaults to 30 minutes.
- Defaults to 5 minutes.
- The default `gcTime` a route should use if no gcTime is provided.

### `defaultOnCatch` property
Expand Down
29 changes: 16 additions & 13 deletions docs/router/api/router/RouterStateType.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ id: RouterStateType
title: RouterState type
---

The `RouterState` type represents shape of the internal state of the router. The Router's internal state is useful, if you need to access certain internals of the router, such as any pending matches, is the router in its loading state, etc.
The `RouterState` type represents the observable state of the router, including
the requested and resolved locations, match presentation, and foreground loading
status.

```tsx
type RouterState = {
status: 'pending' | 'idle'
isLoading: boolean
isTransitioning: boolean
matches: Array<RouteMatch>
location: ParsedLocation
resolvedLocation: ParsedLocation
resolvedLocation?: ParsedLocation
}
```

Expand All @@ -23,22 +24,24 @@ The `RouterState` type contains all of the properties that are available on the
### `status` property

- Type: `'pending' | 'idle'`
- The current status of the router. If the router is pending, it means that it is currently loading a route or the router is still transitioning to the new route.
- The current foreground status of the router. `pending` means the requested route is still loading or waiting for its framework transition to settle.

### `isLoading` property

- Type: `boolean`
- `true` if the router is currently loading a route or waiting for a route to finish loading.

### `isTransitioning` property

- Type: `boolean`
- `true` if the router is currently transitioning to a new route.
- `true` when `status` is `pending`.
- Background loader refreshes do not change this value. Inspect each match's `isFetching` field to observe foreground `beforeLoad`/loader work and background loader work.

### `matches` property

- Type: [`Array<RouteMatch>`](./RouteMatchType.md)
- An array of all of the route matches that have been resolved and are currently active.
- The match presentation currently exposed to the application.
- A navigation can keep the previous presentation visible until pending UI is
published. Once the destination is presented, this contains its complete
structurally matched lane, including descendants that are still loading.
- Error and not-found results also preserve the complete structurally matched
lane. Rendering stops at the selected pending or terminal boundary; it does
not truncate this array.

### `location` property

Expand All @@ -47,5 +50,5 @@ The `RouterState` type contains all of the properties that are available on the

### `resolvedLocation` property

- Type: [`ParsedLocation`](./ParsedLocationType.md)
- The location that the router has resolved and loaded.
- Type: [`ParsedLocation`](./ParsedLocationType.md) | `undefined`
- The location whose load and framework transition have settled. It is `undefined` before the initial location resolves.
Loading
Loading