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
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>
26 changes: 26 additions & 0 deletions e2e/react-router/issue-7120/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"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:^",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"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"
}
}
27 changes: 27 additions & 0 deletions e2e/react-router/issue-7120/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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 build && pnpm preview --port ${PORT}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
67 changes: 67 additions & 0 deletions e2e/react-router/issue-7120/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import ReactDOM from 'react-dom/client'
import {
Outlet,
RouterProvider,
createRootRoute,
createRoute,
createRouter,
redirect,
} from '@tanstack/react-router'
import './styles.css'

const posts = [
{
id: '1',
title:
'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
},
]

const rootRoute = createRootRoute({
component: () => <Outlet />,
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' })
},
})

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 posts
},
}).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} />)
}
17 changes: 17 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,17 @@
import { createLazyRoute } from '@tanstack/react-router'

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

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

return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title.slice(0, 20)}</li>
))}
</ul>
)
}
7 changes: 7 additions & 0 deletions e2e/react-router/issue-7120/src/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
html {
color-scheme: light dark;
}

body {
font-family: system-ui, sans-serif;
}
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([])
})
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"]
}
6 changes: 6 additions & 0 deletions e2e/react-router/issue-7120/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
plugins: [react()],
})
11 changes: 9 additions & 2 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { ClientOnly } from './ClientOnly'
import { useLayoutEffect } from './utils'
import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core'

const resolvedPromise = Promise.resolve()

export const Match = React.memo(function MatchImpl({
matchId,
}: {
Expand Down Expand Up @@ -277,6 +279,11 @@ export const MatchInner = React.memo(function MatchInnerImpl({
)
}

const getRedirectPromise = (match: Parameters<typeof getMatchPromise>[0]) =>
getMatchPromise(match, 'loadPromise') ??
router.latestLoadPromise ??
resolvedPromise

if (isServer ?? router.isServer) {
const match = router.stores.matchStores.get(matchId)?.get()
if (!match) {
Expand Down Expand Up @@ -313,7 +320,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}

if (match.status === 'pending') {
throw getMatchPromise(match, 'loadPromise')
throw getRedirectPromise(match)
}

if (match.status === 'notFound') {
Expand Down Expand Up @@ -431,7 +438,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}
}
}
throw getMatchPromise(match, 'loadPromise')
throw getRedirectPromise(match)
}

if (match.status === 'notFound') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBe(8)
expect(updates).toBe(7)
})

test('redirection in preload', async () => {
Expand Down Expand Up @@ -170,7 +170,7 @@ describe("Store doesn't update *too many* times during navigation", () => {
// This number should be as small as possible to minimize the amount of work
// that needs to be done during a navigation.
// Any change that increases this number should be investigated.
expect(updates).toBe(5)
expect(updates).toBe(4)
})

test('nothing', async () => {
Expand Down
73 changes: 48 additions & 25 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,21 @@ type InnerLoadContext = {
preload?: boolean
forceStaleReload?: boolean
onReady?: () => Promise<void>
isLatest?: () => boolean
sync?: boolean
cancelled?: boolean
}

const isStaleLoad = (inner: InnerLoadContext): boolean =>
inner.isLatest?.() === false ||
(!inner.preload && inner.router.latestLocation.href !== inner.location.href)

const triggerOnReady = (inner: InnerLoadContext): void | Promise<void> => {
if (isStaleLoad(inner)) {
inner.cancelled = true
return
}

if (!inner.rendered) {
inner.rendered = true
return inner.onReady?.()
Expand Down Expand Up @@ -117,52 +128,59 @@ const handleRedirectAndNotFound = (
match: AnyRouteMatch | undefined,
err: unknown,
): void => {
if (!isRedirect(err) && !isNotFound(err)) return
const redirect = isRedirect(err) ? err : undefined
const notFound = isNotFound(err) ? err : undefined

if (isRedirect(err) && err.redirectHandled && !err.options.reloadDocument) {
throw err
if (!redirect && !notFound) return

if (redirect?.redirectHandled && !redirect.options.reloadDocument) {
throw redirect
}

// in case of a redirecting match during preload, the match does not exist
if (match) {
// If a rendered client match redirects, the stale render may observe the
// redirected status before the redirect navigation replaces it.
const keepLoadPromise =
redirect &&
inner.rendered &&
!(isServer ?? inner.router.isServer) &&
!inner.preload

match._nonReactive.beforeLoadPromise?.resolve()
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.beforeLoadPromise = undefined
match._nonReactive.loaderPromise = undefined

match._nonReactive.error = err
if (!keepLoadPromise) {
match._nonReactive.error = err

inner.updateMatch(match.id, (prev) => ({
...prev,
status: isRedirect(err)
? 'redirected'
: isNotFound(err)
? 'notFound'
: prev.status === 'pending'
? 'success'
: prev.status,
context: buildMatchContext(inner, match.index),
isFetching: false,
error: err,
}))
inner.updateMatch(match.id, (prev) => ({
...prev,
status: redirect ? 'redirected' : 'notFound',
context: buildMatchContext(inner, match.index),
isFetching: false,
error: err,
}))

match._nonReactive.loadPromise?.resolve()
}

if (isNotFound(err) && !err.routeId) {
if (notFound && !notFound.routeId) {
// Stamp the throwing match's routeId so that the finalization step in
// loadMatches knows where the notFound originated. The actual boundary
// resolution (walking up to the nearest notFoundComponent) is deferred to
// the finalization step, where firstBadMatchIndex is stable and
// headMaxIndex can be capped correctly.
err.routeId = match.routeId
notFound.routeId = match.routeId
}

match._nonReactive.loadPromise?.resolve()
}

if (isRedirect(err)) {
if (redirect) {
inner.rendered = true
err.options._fromLocation = inner.location
err.redirectHandled = true
err = inner.router.resolveRedirect(err)
redirect.options._fromLocation = inner.location
redirect.redirectHandled = true
err = inner.router.resolveRedirect(redirect)
}

throw err
Expand Down Expand Up @@ -967,6 +985,7 @@ export async function loadMatches(arg: {
preload?: boolean
forceStaleReload?: boolean
onReady?: () => Promise<void>
isLatest?: () => boolean
updateMatch: UpdateMatchFn
sync?: boolean
}): Promise<Array<MakeRouteMatch>> {
Expand Down Expand Up @@ -1001,6 +1020,10 @@ export async function loadMatches(arg: {
break
}

if (inner.cancelled) {
return inner.matches
}

if (inner.serialError || inner.firstBadMatchIndex != null) {
break
}
Expand Down
Loading
Loading