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
7 changes: 7 additions & 0 deletions .changeset/bright-cars-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/react-router': patch
---

Fix a client-side crash when a root `beforeLoad` redirect races with pending UI and a lazy target route while `defaultViewTransition` is enabled.

React now handles stale redirected matches more safely during the transition, and a dedicated `e2e/react-router/issue-7120` fixture covers this regression.
12 changes: 12 additions & 0 deletions e2e/react-router/issue-7120/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Issue 7120</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
29 changes: 29 additions & 0 deletions e2e/react-router/issue-7120/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"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": {
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-router": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"redaxios": "^0.5.1",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@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.0"
}
}
33 changes: 33 additions & 0 deletions e2e/react-router/issue-7120/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test'
import {
getDummyServerPort,
getTestServerPort,
} from '@tanstack/router-e2e-utils'
import packageJson from './package.json' with { type: 'json' }

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

export default defineConfig({
testDir: './tests',
workers: 1,
reporter: [['line']],
globalSetup: './tests/setup/global.setup.ts',
globalTeardown: './tests/setup/global.teardown.ts',
use: {
baseURL,
},
webServer: {
command: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && pnpm preview --port ${PORT}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
64 changes: 64 additions & 0 deletions e2e/react-router/issue-7120/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import ReactDOM from 'react-dom/client'
import {
Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
redirect,
} from '@tanstack/react-router'
import { fetchPosts } from './posts'
import './styles.css'

const rootRoute = createRootRoute({
component: RootComponent,
pendingMs: 0,
pendingComponent: () => <div data-testid="root-pending">loading</div>,
beforeLoad: async ({ matches }) => {
if (matches.find((match) => match.routeId === '/posts')) {
return
}

await new Promise((resolve) => setTimeout(resolve, 1000))
throw redirect({ to: '/posts' })
},
})

function RootComponent() {
return <Outlet />
}

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div>Home</div>,
})

const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'posts',
loader: async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
return fetchPosts()
},
}).lazy(() => import('./posts.lazy').then((d) => d.Route))

const routeTree = rootRoute.addChildren([indexRoute, postsRoute])

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

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

const rootElement = document.getElementById('app')!

if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement)
root.render(<RouterProvider router={router} />)
}
28 changes: 28 additions & 0 deletions e2e/react-router/issue-7120/src/posts.lazy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Link, createLazyRoute } from '@tanstack/react-router'

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

function PostsComponent() {
const posts = Route.useLoaderData()

return (
<div className="p-2">
<ul className="list-disc pl-4">
{posts.map((post) => {
return (
<li key={post.id} className="whitespace-nowrap">
<Link
to="/posts"
className="block py-1 px-2 text-blue-600 hover:opacity-75"
>
<div>{post.title.substring(0, 20)}</div>
</Link>
</li>
)
})}
</ul>
</div>
)
}
19 changes: 19 additions & 0 deletions e2e/react-router/issue-7120/src/posts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import axios from 'redaxios'

type PostType = {
id: string
title: string
body: string
}

let queryURL = 'https://jsonplaceholder.typicode.com'

if (import.meta.env.VITE_NODE_ENV === 'test') {
queryURL = `http://localhost:${import.meta.env.VITE_EXTERNAL_PORT}`
}

export const fetchPosts = async () => {
return axios
.get<Array<PostType>>(`${queryURL}/posts`)
.then((r) => r.data.slice(0, 10))
}
23 changes: 23 additions & 0 deletions e2e/react-router/issue-7120/src/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
@import 'tailwindcss' source('../');

@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}

html {
color-scheme: light dark;
}

* {
@apply border-gray-200 dark:border-gray-800;
}

body {
@apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200;
}
18 changes: 18 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,18 @@
import { expect, test } from '@playwright/test'

test('root beforeLoad redirect does not blank when pending UI and view transitions are enabled', async ({
page,
}) => {
const pageErrors: Array<string> = []

page.on('pageerror', (error) => {
pageErrors.push(error.message)
})

await page.goto('/')

await expect(page).toHaveURL(/\/posts$/)
await expect(page.getByText('sunt aut facere repe')).toBeVisible()
await expect(page.getByTestId('root-pending')).not.toBeVisible()
expect(pageErrors).toEqual([])
})
6 changes: 6 additions & 0 deletions e2e/react-router/issue-7120/tests/setup/global.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { e2eStartDummyServer } from '@tanstack/router-e2e-utils'
import packageJson from '../../package.json' with { type: 'json' }

export default async function setup() {
await e2eStartDummyServer(packageJson.name)
}
6 changes: 6 additions & 0 deletions e2e/react-router/issue-7120/tests/setup/global.teardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { e2eStopDummyServer } from '@tanstack/router-e2e-utils'
import packageJson from '../../package.json' with { type: 'json' }

export default async function teardown() {
await e2eStopDummyServer(packageJson.name)
}
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"]
}
7 changes: 7 additions & 0 deletions e2e/react-router/issue-7120/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
plugins: [react(), tailwindcss()],
})
Loading
Loading