Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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