Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
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
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
2 changes: 2 additions & 0 deletions docs/router/guide/preloading.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ export const Route = createFileRoute('/posts/$postId')({
})
```

Client-side preloading also runs each new route's `beforeLoad` with `preload: true`. When a completed successful preload is still fresh under the built-in freshness and invalidation policy used for preloaded loader data, the client router can reuse the context returned by that invocation instead of calling `beforeLoad` again with `preload: false`. Reuse follows route match identity, including `loaderDeps`, and requires a reusable parent context. The `shouldReload` option remains loader-only. Pending, failed, invalidated, or stale preloads do not donate their `beforeLoad` context to a client navigation.

## Preloading with External Libraries

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.
Expand Down
10 changes: 10 additions & 0 deletions e2e/react-router/issue-7120/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Issues 7120 and 7367</title>
<script type="module" src="/src/main.tsx"></script>
</head>
<body id="root"></body>
</html>
27 changes: 27 additions & 0 deletions e2e/react-router/issue-7120/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "tanstack-router-e2e-react-issue-7120",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"dev:e2e": "vite",
"build": "vite build && tsc --noEmit",
"preview": "vite preview",
"start": "vite",
"test:e2e": "rm -rf port*.txt; playwright test --project=chromium"
},
"dependencies": {
"@tanstack/react-router": "workspace:^",
"@tanstack/router-plugin": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@playwright/test": "^1.61.0",
"@tanstack/router-e2e-utils": "workspace:^",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.14"
}
}
25 changes: 25 additions & 0 deletions e2e/react-router/issue-7120/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test'
import { getTestServerPort } from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }

const PORT = await getTestServerPort(packageJson.name)
const baseURL = `http://localhost:${PORT}`

export default defineConfig({
testDir: './tests',
workers: 1,
reporter: [['line']],
use: { baseURL },
webServer: {
command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} pnpm dev:e2e --port ${PORT}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
21 changes: 21 additions & 0 deletions e2e/react-router/issue-7120/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

const router = createRouter({
routeTree,
defaultViewTransition: true,
})

declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
35 changes: 35 additions & 0 deletions e2e/react-router/issue-7120/src/redirectGate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
type RedirectGate = {
waiting: boolean
released: boolean
release: () => void
}

declare global {
interface Window {
redirectGate: RedirectGate
}
}

let releasePromise!: () => void
const promise = new Promise<void>((resolve) => {
releasePromise = resolve
})

const redirectGate: RedirectGate = {
waiting: false,
released: false,
release: () => {
if (!redirectGate.released) {
redirectGate.released = true
releasePromise()
}
},
}

window.redirectGate = redirectGate

export async function waitForRedirectGate() {
redirectGate.waiting = true
await promise
redirectGate.waiting = false
}
77 changes: 77 additions & 0 deletions e2e/react-router/issue-7120/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/* eslint-disable */

// @ts-nocheck

// noinspection JSUnusedGlobalSymbols

// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// 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'

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)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/posts': typeof PostsRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/posts': typeof PostsRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/posts': typeof PostsRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/posts'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/posts'
id: '__root__' | '/' | '/posts'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
PostsRoute: typeof PostsRoute
}

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
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
PostsRoute: PostsRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
33 changes: 33 additions & 0 deletions e2e/react-router/issue-7120/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Outlet, createRootRoute, redirect } from '@tanstack/react-router'
import { waitForRedirectGate } from '../redirectGate'

let shouldRedirect = true

export const Route = createRootRoute({
pendingMs: 0,
pendingMinMs: 500,
pendingComponent: RootPendingComponent,
beforeLoad: async ({ location }) => {
if (location.pathname !== '/' || !shouldRedirect) {
return
}

await waitForRedirectGate()

if (!shouldRedirect) {
return
}

shouldRedirect = false
throw redirect({ to: '/posts', replace: true })
},
component: () => <Outlet />,
})

function RootPendingComponent() {
return (
<div role="status" data-testid="root-pending">
Loading route
</div>
)
}
5 changes: 5 additions & 0 deletions e2e/react-router/issue-7120/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
component: () => <main>Home</main>,
})
14 changes: 14 additions & 0 deletions e2e/react-router/issue-7120/src/routes/posts.lazy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { createLazyFileRoute } from '@tanstack/react-router'

export const Route = createLazyFileRoute('/posts')({
component: PostsComponent,
})

function PostsComponent() {
return (
<main>
<h1>Posts loaded</h1>
<p>The redirected lazy route rendered.</p>
</main>
)
}
3 changes: 3 additions & 0 deletions e2e/react-router/issue-7120/src/routes/posts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts')({})
1 change: 1 addition & 0 deletions e2e/react-router/issue-7120/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="vite/client" />
33 changes: 33 additions & 0 deletions e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test'

test('#7120 / #7367: a root redirect cannot commit a stale match during a view transition', async ({
page,
}) => {
const pageErrors: Array<string> = []
const consoleErrors: Array<string> = []

page.on('pageerror', (error) => {
pageErrors.push(error.message || String(error))
})
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text())
}
})

await page.goto('/')

const pending = page.getByTestId('root-pending')
await expect(pending).toBeVisible()
expect(await page.evaluate(() => window.redirectGate.waiting)).toBe(true)

await page.evaluate(() => window.redirectGate.release())

await expect(page).toHaveURL(/\/posts$/)
await expect(
page.getByRole('heading', { name: 'Posts loaded' }),
).toBeVisible()
await expect(pending).not.toBeVisible()
expect(pageErrors).toEqual([])
expect(consoleErrors).toEqual([])
})
15 changes: 15 additions & 0 deletions e2e/react-router/issue-7120/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"target": "ESNext",
"moduleResolution": "Bundler",
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"exclude": ["node_modules", "dist"]
}
Loading
Loading