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 ( +
+
    + {posts.map((post) => { + return ( +
  • + +
    {post.title.substring(0, 20)}
    + +
  • + ) + })} +
+
+ ) +} 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':