diff --git a/.changeset/bright-cars-lie.md b/.changeset/bright-cars-lie.md
new file mode 100644
index 00000000000..689d193355c
--- /dev/null
+++ b/.changeset/bright-cars-lie.md
@@ -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.
diff --git a/e2e/react-router/issue-7120/index.html b/e2e/react-router/issue-7120/index.html
new file mode 100644
index 00000000000..de92cad2a3b
--- /dev/null
+++ b/e2e/react-router/issue-7120/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Issue 7120
+
+
+
+
+
+
diff --git a/e2e/react-router/issue-7120/package.json b/e2e/react-router/issue-7120/package.json
new file mode 100644
index 00000000000..6fdada56156
--- /dev/null
+++ b/e2e/react-router/issue-7120/package.json
@@ -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"
+ }
+}
diff --git a/e2e/react-router/issue-7120/playwright.config.ts b/e2e/react-router/issue-7120/playwright.config.ts
new file mode 100644
index 00000000000..b8f97fc2cea
--- /dev/null
+++ b/e2e/react-router/issue-7120/playwright.config.ts
@@ -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'] },
+ },
+ ],
+})
diff --git a/e2e/react-router/issue-7120/src/main.tsx b/e2e/react-router/issue-7120/src/main.tsx
new file mode 100644
index 00000000000..46823c831fa
--- /dev/null
+++ b/e2e/react-router/issue-7120/src/main.tsx
@@ -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: () => loading
,
+ 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
+}
+
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+})
+
+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()
+}
diff --git a/e2e/react-router/issue-7120/src/posts.lazy.tsx b/e2e/react-router/issue-7120/src/posts.lazy.tsx
new file mode 100644
index 00000000000..6f554b11d2e
--- /dev/null
+++ b/e2e/react-router/issue-7120/src/posts.lazy.tsx
@@ -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 (
+
+ )
+}
diff --git a/e2e/react-router/issue-7120/src/posts.ts b/e2e/react-router/issue-7120/src/posts.ts
new file mode 100644
index 00000000000..cf44f32db50
--- /dev/null
+++ b/e2e/react-router/issue-7120/src/posts.ts
@@ -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>(`${queryURL}/posts`)
+ .then((r) => r.data.slice(0, 10))
+}
diff --git a/e2e/react-router/issue-7120/src/styles.css b/e2e/react-router/issue-7120/src/styles.css
new file mode 100644
index 00000000000..5d14b075337
--- /dev/null
+++ b/e2e/react-router/issue-7120/src/styles.css
@@ -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;
+}
diff --git a/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts b/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts
new file mode 100644
index 00000000000..b53f0fe68f5
--- /dev/null
+++ b/e2e/react-router/issue-7120/tests/issue-7120.repro.spec.ts
@@ -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 = []
+
+ 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([])
+})
diff --git a/e2e/react-router/issue-7120/tests/setup/global.setup.ts b/e2e/react-router/issue-7120/tests/setup/global.setup.ts
new file mode 100644
index 00000000000..3593d10ab90
--- /dev/null
+++ b/e2e/react-router/issue-7120/tests/setup/global.setup.ts
@@ -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)
+}
diff --git a/e2e/react-router/issue-7120/tests/setup/global.teardown.ts b/e2e/react-router/issue-7120/tests/setup/global.teardown.ts
new file mode 100644
index 00000000000..62fd79911cc
--- /dev/null
+++ b/e2e/react-router/issue-7120/tests/setup/global.teardown.ts
@@ -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)
+}
diff --git a/e2e/react-router/issue-7120/tsconfig.json b/e2e/react-router/issue-7120/tsconfig.json
new file mode 100644
index 00000000000..4f6089bc08d
--- /dev/null
+++ b/e2e/react-router/issue-7120/tsconfig.json
@@ -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"]
+}
diff --git a/e2e/react-router/issue-7120/vite.config.js b/e2e/react-router/issue-7120/vite.config.js
new file mode 100644
index 00000000000..0616e595997
--- /dev/null
+++ b/e2e/react-router/issue-7120/vite.config.js
@@ -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()],
+})
diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx
index fefd256bf64..ae38b6b4e03 100644
--- a/packages/react-router/src/Match.tsx
+++ b/packages/react-router/src/Match.tsx
@@ -8,6 +8,7 @@ import {
invariant,
isNotFound,
isRedirect,
+ markMatchPendingVisible,
rootRouteId,
} from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
@@ -20,7 +21,11 @@ import { renderRouteNotFound } from './renderRouteNotFound'
import { ScrollRestoration } from './scroll-restoration'
import { ClientOnly } from './ClientOnly'
import { useLayoutEffect } from './utils'
-import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core'
+import type {
+ AnyRoute,
+ AnyRouteMatch,
+ RootRouteOptions,
+} from '@tanstack/router-core'
export const Match = React.memo(function MatchImpl({
matchId,
@@ -160,10 +165,18 @@ function MatchView({
const ShellComponent = route.isRoot
? ((route.options as RootRouteOptions).shellComponent ?? SafeFragment)
: SafeFragment
+
return (
-
+
+ }
+ >
resetKey}
errorComponent={routeErrorComponent || ErrorComponent}
@@ -196,7 +209,14 @@ function MatchView({
}}
>
{resolvedNoSsr || matchState._displayPending ? (
-
+
+ }
+ >
) : (
@@ -218,6 +238,25 @@ function MatchView({
)
}
+function PendingRouteMatch({
+ matchId,
+ pendingElement,
+}: {
+ matchId: string
+ pendingElement: React.ReactNode
+}) {
+ const router = useRouter()
+
+ useLayoutEffect(() => {
+ const match = router.getMatch(matchId)
+ if (match) {
+ markMatchPendingVisible(match)
+ }
+ }, [matchId, router])
+
+ return pendingElement
+}
+
// On Rendered can't happen above the root layout because it needs to run after
// the route subtree has committed below the root layout. Keeping it here lets
// us fire onRendered even after a hydration mismatch above the root layout
@@ -261,23 +300,18 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}): any {
const router = useRouter()
- const getMatchPromise = (
- match: {
- id: string
- _nonReactive: {
- displayPendingPromise?: Promise
- minPendingPromise?: Promise
- loadPromise?: Promise
- }
- },
- key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise',
- ) => {
- return (
- router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key]
- )
- }
-
if (isServer ?? router.isServer) {
+ const throwMatchPromise = (
+ match: AnyRouteMatch,
+ key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise',
+ ) => {
+ throw (
+ router.getMatch(match.id)?._nonReactive[key] ??
+ match._nonReactive[key] ??
+ router.latestLoadPromise
+ )
+ }
+
const match = router.stores.matchStores.get(matchId)?.get()
if (!match) {
if (process.env.NODE_ENV !== 'production') {
@@ -305,15 +339,15 @@ export const MatchInner = React.memo(function MatchInnerImpl({
const out = Comp ? :
if (match._displayPending) {
- throw getMatchPromise(match, 'displayPendingPromise')
+ throwMatchPromise(match, 'displayPendingPromise')
}
if (match._forcePending) {
- throw getMatchPromise(match, 'minPendingPromise')
+ throwMatchPromise(match, 'minPendingPromise')
}
if (match.status === 'pending') {
- throw getMatchPromise(match, 'loadPromise')
+ throwMatchPromise(match, 'loadPromise')
}
if (match.status === 'notFound') {
@@ -335,7 +369,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
invariant()
}
- throw getMatchPromise(match, 'loadPromise')
+ throwMatchPromise(match, 'loadPromise')
}
if (match.status === 'error') {
@@ -401,12 +435,39 @@ export const MatchInner = React.memo(function MatchInnerImpl({
return
}, [key, route.options.component, router.options.defaultComponent])
- if (match._displayPending) {
- throw getMatchPromise(match, 'displayPendingPromise')
+ const pendingKey = match._displayPending
+ ? 'displayPendingPromise'
+ : match._forcePending
+ ? 'minPendingPromise'
+ : undefined
+
+ const suspendOrKeepPending = (
+ key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise',
+ ) => {
+ const routerMatch = router.getMatch(match.id)
+
+ const promise =
+ routerMatch?._nonReactive[key] ??
+ match._nonReactive[key] ??
+ router.latestLoadPromise
+
+ if (promise) {
+ throw promise
+ }
+
+ const retainedPendingPromise =
+ routerMatch?._nonReactive.retainedPendingPromise ??
+ match._nonReactive.retainedPendingPromise
+
+ if (retainedPendingPromise) {
+ throw retainedPendingPromise
+ }
+
+ return null
}
- if (match._forcePending) {
- throw getMatchPromise(match, 'minPendingPromise')
+ if (pendingKey) {
+ return suspendOrKeepPending(pendingKey)
}
// see also hydrate() in packages/router-core/src/ssr/ssr-client.ts
@@ -431,7 +492,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
}
}
}
- throw getMatchPromise(match, 'loadPromise')
+ return suspendOrKeepPending('loadPromise')
}
if (match.status === 'notFound') {
@@ -458,7 +519,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
invariant()
}
- throw getMatchPromise(match, 'loadPromise')
+ return suspendOrKeepPending('loadPromise')
}
if (match.status === 'error') {
diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx
index cc15f0da36a..cc47d563eb5 100644
--- a/packages/react-router/tests/redirect.test.tsx
+++ b/packages/react-router/tests/redirect.test.tsx
@@ -146,10 +146,12 @@ describe('redirect', () => {
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
history,
+ defaultViewTransition: true,
})
render()
+ expect(await screen.findByTestId('pending')).toBeInTheDocument()
// The lazy target route adds the async boundary that exposes the stale
// redirected-match render path this regression is guarding.
expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument()
diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts
index 852d186b67d..54cdc233b3c 100644
--- a/packages/router-core/src/Matches.ts
+++ b/packages/router-core/src/Matches.ts
@@ -105,6 +105,12 @@ export const isMatch = (
return value != null
}
+const retainedPendingPromise = new Promise(() => {})
+
+export const markMatchPendingVisible = (match: AnyRouteMatch) => {
+ match._nonReactive.retainedPendingPromise ??= retainedPendingPromise
+}
+
export interface DefaultRouteMatchExtensions {
scripts?: unknown
links?: unknown
@@ -147,6 +153,7 @@ export interface RouteMatch<
loadPromise?: ControlledPromise
displayPendingPromise?: Promise
minPendingPromise?: ControlledPromise
+ retainedPendingPromise?: Promise
dehydrated?: boolean
/** @internal */
error?: unknown
diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts
index 9a1373d9e67..3ce04bd8072 100644
--- a/packages/router-core/src/index.ts
+++ b/packages/router-core/src/index.ts
@@ -76,7 +76,7 @@ export type {
ManifestAssetLink,
} from './manifest'
export { getAssetCrossOrigin, resolveManifestAssetLink } from './manifest'
-export { isMatch } from './Matches'
+export { isMatch, markMatchPendingVisible } from './Matches'
export type {
AnyMatchAndValue,
FindValueByIndex,
diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts
index a06715de43b..b583bf67b49 100644
--- a/packages/router-core/src/load-matches.ts
+++ b/packages/router-core/src/load-matches.ts
@@ -44,6 +44,10 @@ const triggerOnReady = (inner: InnerLoadContext): void | Promise => {
}
}
+const clearRetainedPending = (match: AnyRouteMatch) => {
+ match._nonReactive.retainedPendingPromise = undefined
+}
+
const hasForcePendingActiveMatch = (router: AnyRouter): boolean => {
return router.stores.matchesId.get().some((matchId) => {
return router.stores.matchStores.get(matchId)?.get()._forcePending
@@ -125,6 +129,7 @@ const handleRedirectAndNotFound = (
// in case of a redirecting match during preload, the match does not exist
if (match) {
+ clearRetainedPending(match)
match._nonReactive.beforeLoadPromise?.resolve()
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.beforeLoadPromise = undefined
@@ -229,6 +234,7 @@ const handleSerialError = (
}
inner.updateMatch(matchId, (prev) => {
+ clearRetainedPending(prev)
prev._nonReactive.beforeLoadPromise?.resolve()
prev._nonReactive.beforeLoadPromise = undefined
prev._nonReactive.loadPromise?.resolve()
@@ -836,6 +842,7 @@ const loadRouteMatch = async (
try {
await runLoader(inner, matchPromises, matchId, index, route)
const match = inner.router.getMatch(matchId)!
+ clearRetainedPending(match)
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.loadPromise?.resolve()
match._nonReactive.loaderPromise = undefined
@@ -935,6 +942,7 @@ const loadRouteMatch = async (
}
const match = inner.router.getMatch(matchId)!
if (!loaderIsRunningAsync) {
+ clearRetainedPending(match)
match._nonReactive.loaderPromise?.resolve()
match._nonReactive.loadPromise?.resolve()
match._nonReactive.loadPromise = undefined
diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx
index e04d2877c85..3f07c8889b4 100644
--- a/packages/solid-router/src/Match.tsx
+++ b/packages/solid-router/src/Match.tsx
@@ -5,6 +5,7 @@ import {
invariant,
isNotFound,
isRedirect,
+ markMatchPendingVisible,
rootRouteId,
} from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
@@ -16,7 +17,29 @@ import { nearestMatchContext } from './matchContext'
import { SafeFragment } from './SafeFragment'
import { renderRouteNotFound } from './renderRouteNotFound'
import { ScrollRestoration } from './scroll-restoration'
-import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core'
+import type {
+ AnyRoute,
+ AnyRouteMatch,
+ RootRouteOptions,
+} from '@tanstack/router-core'
+
+const PendingRouteMatch = (props: {
+ matchId: string
+ pendingComponent: unknown
+}) => {
+ const router = useRouter()
+
+ Solid.onMount(() => {
+ const match = router.getMatch(props.matchId)
+ if (match) {
+ markMatchPendingVisible(match)
+ }
+ })
+
+ return props.pendingComponent ? (
+
+ ) : null
+}
export const Match = (props: { matchId: string }) => {
const router = useRouter()
@@ -69,6 +92,16 @@ export const Match = (props: { matchId: string }) => {
route().options.pendingComponent ??
router.options.defaultPendingComponent
+ const renderPending = () => {
+ const PendingComponent = resolvePendingComponent()
+ return (
+
+ )
+ }
+
const routeErrorComponent = () =>
route().options.errorComponent ?? router.options.defaultErrorComponent
@@ -107,7 +140,10 @@ export const Match = (props: { matchId: string }) => {
fallback={
// Don't show fallback on server when using no-ssr mode to avoid hydration mismatch
(isServer ?? router.isServer) && resolvedNoSsr ? undefined : (
-
+
)
}
>
@@ -273,19 +309,37 @@ export const MatchInner = (): any => {
return
}
+ const renderPending = () => {
+ const PendingComponent =
+ route().options.pendingComponent ??
+ router.options.defaultPendingComponent
+
+ return (
+
+ )
+ }
+
const getLoadPromise = (
matchId: string,
- fallbackMatch:
- | {
- _nonReactive: {
- loadPromise?: Promise
- }
- }
- | undefined,
+ fallbackMatch: AnyRouteMatch | undefined,
) => {
return (
router.getMatch(matchId)?._nonReactive.loadPromise ??
- fallbackMatch?._nonReactive.loadPromise
+ fallbackMatch?._nonReactive.loadPromise ??
+ router.latestLoadPromise
+ )
+ }
+
+ const getRetainedPendingPromise = (
+ matchId: string,
+ fallbackMatch: AnyRouteMatch | undefined,
+ ) => {
+ return (
+ router.getMatch(matchId)?._nonReactive.retainedPendingPromise ??
+ fallbackMatch?._nonReactive.retainedPendingPromise
)
}
@@ -305,7 +359,7 @@ export const MatchInner = (): any => {
.displayPendingPromise,
)
- return <>{displayPendingResult()}>
+ return <>{displayPendingResult() ?? renderPending()}>
}}
@@ -316,7 +370,7 @@ export const MatchInner = (): any => {
.minPendingPromise,
)
- return <>{minPendingResult()}>
+ return <>{minPendingResult() ?? renderPending()}>
}}
@@ -353,18 +407,7 @@ export const MatchInner = (): any => {
.loadPromise
})
- const FallbackComponent =
- route().options.pendingComponent ??
- router.options.defaultPendingComponent
-
- return (
- <>
- {FallbackComponent && pendingMinMs > 0 ? (
-
- ) : null}
- {loaderResult()}
- >
- )
+ return <>{loaderResult() ?? renderPending()}>
}}
@@ -409,7 +452,14 @@ export const MatchInner = (): any => {
return getLoadPromise(matchId, routerMatch)
})
- return <>{loaderResult()}>
+ const [retainedPendingResult] = Solid.createResource(
+ async () => {
+ await new Promise((r) => setTimeout(r, 0))
+ return getRetainedPendingPromise(matchId, routerMatch)
+ },
+ )
+
+ return <>{loaderResult() ?? retainedPendingResult()}>
}}
diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx
index 81bd1f6bc64..f9753044501 100644
--- a/packages/solid-router/tests/redirect.test.tsx
+++ b/packages/solid-router/tests/redirect.test.tsx
@@ -138,10 +138,12 @@ describe('redirect', () => {
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
history,
+ defaultViewTransition: true,
})
render(() => )
+ expect(await screen.findByTestId('pending')).toBeInTheDocument()
// The lazy target route adds the async boundary that exposes the stale
// redirected-match render path this regression is guarding.
expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument()
diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx
index 7bd17682b0b..99e86d08c02 100644
--- a/packages/vue-router/src/Match.tsx
+++ b/packages/vue-router/src/Match.tsx
@@ -5,6 +5,7 @@ import {
invariant,
isNotFound,
isRedirect,
+ markMatchPendingVisible,
rootRouteId,
} from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
@@ -21,7 +22,11 @@ import {
import { renderRouteNotFound } from './renderRouteNotFound'
import { ScrollRestoration } from './scroll-restoration'
import type { VNode } from 'vue'
-import type { AnyRoute, RootRouteOptions } from '@tanstack/router-core'
+import type {
+ AnyRoute,
+ AnyRouteMatch,
+ RootRouteOptions,
+} from '@tanstack/router-core'
export const Match = Vue.defineComponent({
name: 'Match',
@@ -93,10 +98,6 @@ export const Match = Vue.defineComponent({
router?.options?.defaultPendingComponent,
)
- const pendingElement = Vue.computed(() =>
- PendingComponent.value ? Vue.h(PendingComponent.value) : undefined,
- )
-
const routeErrorComponent = Vue.computed(
() =>
route.value?.options?.errorComponent ??
@@ -154,7 +155,10 @@ export const Match = Vue.defineComponent({
? Vue.h(
ClientOnly,
{
- fallback: pendingElement.value,
+ fallback: Vue.h(PendingRouteMatch, {
+ matchId: actualMatchId,
+ pendingComponent: PendingComponent.value,
+ }),
},
{
default: () => matchInner,
@@ -281,6 +285,33 @@ const OnRendered = Vue.defineComponent({
},
})
+const PendingRouteMatch = Vue.defineComponent({
+ name: 'PendingRouteMatch',
+ props: {
+ matchId: {
+ type: String,
+ required: true,
+ },
+ pendingComponent: {
+ type: [Object, Function] as Vue.PropType,
+ required: false,
+ default: undefined,
+ },
+ },
+ setup(props) {
+ const router = useRouter()
+
+ Vue.onMounted(() => {
+ const match = router.getMatch(props.matchId)
+ if (match) {
+ markMatchPendingVisible(match)
+ }
+ })
+
+ return () => (props.pendingComponent ? Vue.h(props.pendingComponent) : null)
+ },
+})
+
export const MatchInner = Vue.defineComponent({
name: 'MatchInner',
props: {
@@ -328,15 +359,7 @@ export const MatchInner = Vue.defineComponent({
return {
routeId: matchRouteId,
- match: {
- id: match.id,
- status: match.status,
- error: match.error,
- ssr: match.ssr,
- _forcePending: match._forcePending,
- _displayPending: match._displayPending,
- _nonReactive: match._nonReactive,
- },
+ match,
remountKey,
}
})
@@ -350,18 +373,20 @@ export const MatchInner = Vue.defineComponent({
const remountKey = Vue.computed(() => combinedState.value?.remountKey)
const getMatchPromise = (
- match: {
- id: string
- _nonReactive: {
- displayPendingPromise?: Promise
- minPendingPromise?: Promise
- loadPromise?: Promise
- }
- },
+ match: AnyRouteMatch,
key: 'displayPendingPromise' | 'minPendingPromise' | 'loadPromise',
) => {
return (
- router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key]
+ router.getMatch(match.id)?._nonReactive[key] ??
+ match._nonReactive[key] ??
+ router.latestLoadPromise
+ )
+ }
+
+ const getRetainedPendingPromise = (match: AnyRouteMatch) => {
+ return (
+ router.getMatch(match.id)?._nonReactive.retainedPendingPromise ??
+ match._nonReactive.retainedPendingPromise
)
}
@@ -375,7 +400,10 @@ export const MatchInner = Vue.defineComponent({
route.value.options.pendingComponent ??
router.options.defaultPendingComponent
- return PendingComponent ? Vue.h(PendingComponent) : null
+ return Vue.h(PendingRouteMatch, {
+ matchId: match.value.id,
+ pendingComponent: PendingComponent,
+ })
}
if (match.value._forcePending) {
@@ -383,7 +411,10 @@ export const MatchInner = Vue.defineComponent({
route.value.options.pendingComponent ??
router.options.defaultPendingComponent
- return PendingComponent ? Vue.h(PendingComponent) : null
+ return Vue.h(PendingRouteMatch, {
+ matchId: match.value.id,
+ pendingComponent: PendingComponent,
+ })
}
if (match.value.status === 'notFound') {
@@ -405,7 +436,19 @@ export const MatchInner = Vue.defineComponent({
invariant()
}
- throw getMatchPromise(match.value, 'loadPromise')
+
+ const promise = getMatchPromise(match.value, 'loadPromise')
+ if (promise) {
+ throw promise
+ }
+
+ const retainedPendingPromise = getRetainedPendingPromise(match.value)
+
+ if (retainedPendingPromise) {
+ throw retainedPendingPromise
+ }
+
+ return null
}
if (match.value.status === 'error') {
@@ -464,7 +507,10 @@ export const MatchInner = Vue.defineComponent({
router.options.defaultPendingComponent
if (PendingComponent) {
- return Vue.h(PendingComponent)
+ return Vue.h(PendingRouteMatch, {
+ matchId: match.value.id,
+ pendingComponent: PendingComponent,
+ })
}
// If no pending component, return null while loading
diff --git a/packages/vue-router/tests/redirect.test.tsx b/packages/vue-router/tests/redirect.test.tsx
index b7ba2e1af3e..55cb0316681 100644
--- a/packages/vue-router/tests/redirect.test.tsx
+++ b/packages/vue-router/tests/redirect.test.tsx
@@ -128,10 +128,12 @@ describe('redirect', () => {
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute, postsRoute]),
history: createMemoryHistory({ initialEntries: ['/'] }),
+ defaultViewTransition: true,
})
render()
+ expect(await screen.findByTestId('pending')).toBeInTheDocument()
// The lazy target route adds the async boundary that exposes the stale
// redirected-match render path this regression is guarding.
expect(await screen.findByTestId('lazy-route-page')).toBeInTheDocument()
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 629e8101142..212176cb1ab 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -997,6 +997,46 @@ importers:
specifier: ^8.0.0
version: 8.0.0(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+ e2e/react-router/issue-7120:
+ dependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.2.2
+ version: 4.2.2(vite@8.0.0(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+ '@tanstack/react-router':
+ specifier: workspace:*
+ version: link:../../../packages/react-router
+ react:
+ specifier: ^19.2.3
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.3(react@19.2.3)
+ redaxios:
+ specifier: ^0.5.1
+ version: 0.5.1
+ tailwindcss:
+ specifier: ^4.2.2
+ version: 4.2.2
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.57.0
+ version: 1.58.0
+ '@tanstack/router-e2e-utils':
+ specifier: workspace:^
+ version: link:../../e2e-utils
+ '@types/react':
+ specifier: ^19.2.8
+ version: 19.2.9
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.9)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.0(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.0(@types/node@25.0.9)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.97.2)(sass@1.97.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)
+
e2e/react-router/js-only-file-based:
dependencies:
'@tailwindcss/vite':