From 8730761576415ad6eb69acc6296f5909d07bec1c Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Fri, 10 Apr 2026 19:58:43 +0200 Subject: [PATCH 1/2] fix(router): handle redirected lazy pending matches fixes #7120 --- .changeset/go-run-king.md | 7 +++ packages/react-router/src/Match.tsx | 42 ++++++++++------ packages/react-router/tests/lazy/normal.tsx | 2 +- packages/react-router/tests/redirect.test.tsx | 48 +++++++++++++++++++ packages/solid-router/src/Match.tsx | 22 ++++++++- packages/solid-router/tests/lazy/normal.tsx | 2 +- packages/solid-router/tests/redirect.test.tsx | 47 ++++++++++++++++++ packages/vue-router/src/Match.tsx | 19 +++++++- packages/vue-router/tests/lazy/normal.tsx | 2 +- packages/vue-router/tests/redirect.test.tsx | 46 ++++++++++++++++++ 10 files changed, 217 insertions(+), 20 deletions(-) create mode 100644 .changeset/go-run-king.md diff --git a/.changeset/go-run-king.md b/.changeset/go-run-king.md new file mode 100644 index 00000000000..a977a98e0a4 --- /dev/null +++ b/.changeset/go-run-king.md @@ -0,0 +1,7 @@ +--- +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +Fix redirected pending route transitions so lazy target routes can finish loading without stale redirected matches causing render errors. diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index ae30f4b7ec4..a8436d0ae5b 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -259,6 +259,22 @@ 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 match = router.stores.activeMatchStoresById.get(matchId)?.state if (!match) { @@ -287,15 +303,15 @@ export const MatchInner = React.memo(function MatchInnerImpl({ const out = Comp ? : if (match._displayPending) { - throw router.getMatch(match.id)?._nonReactive.displayPendingPromise + throw getMatchPromise(match, 'displayPendingPromise') } if (match._forcePending) { - throw router.getMatch(match.id)?._nonReactive.minPendingPromise + throw getMatchPromise(match, 'minPendingPromise') } if (match.status === 'pending') { - throw router.getMatch(match.id)?._nonReactive.loadPromise + throw getMatchPromise(match, 'loadPromise') } if (match.status === 'notFound') { @@ -317,7 +333,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ invariant() } - throw router.getMatch(match.id)?._nonReactive.loadPromise + throw getMatchPromise(match, 'loadPromise') } if (match.status === 'error') { @@ -384,11 +400,11 @@ export const MatchInner = React.memo(function MatchInnerImpl({ }, [key, route.options.component, router.options.defaultComponent]) if (match._displayPending) { - throw router.getMatch(match.id)?._nonReactive.displayPendingPromise + throw getMatchPromise(match, 'displayPendingPromise') } if (match._forcePending) { - throw router.getMatch(match.id)?._nonReactive.minPendingPromise + throw getMatchPromise(match, 'minPendingPromise') } // see also hydrate() in packages/router-core/src/ssr/ssr-client.ts @@ -413,7 +429,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ } } } - throw router.getMatch(match.id)?._nonReactive.loadPromise + throw getMatchPromise(match, 'loadPromise') } if (match.status === 'notFound') { @@ -428,8 +444,10 @@ export const MatchInner = React.memo(function MatchInnerImpl({ } if (match.status === 'redirected') { - // Redirects should be handled by the router transition. If we happen to - // encounter a redirect here, it's a bug. Let's warn, but render nothing. + // A match can be observed as redirected during an in-flight transition, + // especially when pending UI is already rendering. Suspend on the match's + // load promise so React can abandon this stale render and continue the + // redirect transition. if (!isRedirect(match.error)) { if (process.env.NODE_ENV !== 'production') { throw new Error('Invariant failed: Expected a redirect error') @@ -438,11 +456,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ invariant() } - // warning( - // false, - // 'Tried to render a redirected route match! This is a weird circumstance, please file an issue!', - // ) - throw router.getMatch(match.id)?._nonReactive.loadPromise + throw getMatchPromise(match, 'loadPromise') } if (match.status === 'error') { diff --git a/packages/react-router/tests/lazy/normal.tsx b/packages/react-router/tests/lazy/normal.tsx index 1addf96c74c..675deb83f39 100644 --- a/packages/react-router/tests/lazy/normal.tsx +++ b/packages/react-router/tests/lazy/normal.tsx @@ -2,7 +2,7 @@ import { createLazyFileRoute, createLazyRoute } from '../../src' export function Route(id: string) { return createLazyRoute(id)({ - component: () =>

I'm a normal route

, + component: () =>

I'm a normal route

, }) } diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx index a391d02898f..cc15f0da36a 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -1,3 +1,4 @@ +import * as React from 'react' import { cleanup, configure, @@ -10,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { Link, + Outlet, RouterProvider, createBrowserHistory, createMemoryHistory, @@ -111,6 +113,52 @@ describe('redirect', () => { expect(nestedFooLoaderMock).toHaveBeenCalled() }) + test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => { + let hasRedirected = false + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + const rootRoute = createRootRoute({ + component: () => , + pendingMs: 0, + pendingComponent: () =>
loading
, + beforeLoad: async () => { + await sleep(WAIT_TIME) + if (!hasRedirected) { + hasRedirected = true + throw redirect({ to: '/posts' }) + } + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index page
, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }).lazy(() => import('./lazy/normal').then((d) => d.Route('/posts'))) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history, + }) + + render() + + // 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() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(router.state.location.href).toBe('/posts') + expect(router.state.status).toBe('idle') + expect(consoleError).not.toHaveBeenCalled() + }) + test('when `redirect` is thrown in `loader`', async () => { const nestedLoaderMock = vi.fn() const nestedFooLoaderMock = vi.fn() diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index b1f1c2b824f..156c3847fb3 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -273,6 +273,22 @@ export const MatchInner = (): any => { return } + const getLoadPromise = ( + matchId: string, + fallbackMatch: + | { + _nonReactive: { + loadPromise?: Promise + } + } + | undefined, + ) => { + return ( + router.getMatch(matchId)?._nonReactive.loadPromise ?? + fallbackMatch?._nonReactive.loadPromise + ) + } + const keyedOut = () => ( {(_key) => out()} @@ -375,6 +391,9 @@ export const MatchInner = (): any => { {(_) => { + const matchId = currentMatch().id + const routerMatch = router.getMatch(matchId) + if (!isRedirect(currentMatch().error)) { if (process.env.NODE_ENV !== 'production') { throw new Error( @@ -387,8 +406,7 @@ export const MatchInner = (): any => { const [loaderResult] = Solid.createResource(async () => { await new Promise((r) => setTimeout(r, 0)) - return router.getMatch(currentMatch().id)?._nonReactive - .loadPromise + return getLoadPromise(matchId, routerMatch) }) return <>{loaderResult()} diff --git a/packages/solid-router/tests/lazy/normal.tsx b/packages/solid-router/tests/lazy/normal.tsx index 5b0f169da4c..61eba8ab023 100644 --- a/packages/solid-router/tests/lazy/normal.tsx +++ b/packages/solid-router/tests/lazy/normal.tsx @@ -2,7 +2,7 @@ import { createLazyFileRoute, createLazyRoute } from '../../src' export function Route(id: string) { return createLazyRoute(id)({ - component: () =>

I'm a normal route

, + component: () =>

I'm a normal route

, }) } diff --git a/packages/solid-router/tests/redirect.test.tsx b/packages/solid-router/tests/redirect.test.tsx index 5d3ecc898ac..81bd1f6bc64 100644 --- a/packages/solid-router/tests/redirect.test.tsx +++ b/packages/solid-router/tests/redirect.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { Link, + Outlet, RouterProvider, createBrowserHistory, createMemoryHistory, @@ -104,6 +105,52 @@ describe('redirect', () => { expect(nestedFooLoaderMock).toHaveBeenCalled() }) + test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => { + let hasRedirected = false + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + const rootRoute = createRootRoute({ + component: () => , + pendingMs: 0, + pendingComponent: () =>
loading
, + beforeLoad: async () => { + await sleep(WAIT_TIME) + if (!hasRedirected) { + hasRedirected = true + throw redirect({ to: '/posts' }) + } + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index page
, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }).lazy(() => import('./lazy/normal').then((d) => d.Route('/posts'))) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history, + }) + + render(() => ) + + // 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() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(router.state.location.href).toBe('/posts') + expect(router.state.status).toBe('idle') + expect(consoleError).not.toHaveBeenCalled() + }) + test('when `redirect` is thrown in `loader`', async () => { const nestedLoaderMock = vi.fn() const nestedFooLoaderMock = vi.fn() diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index f078986be70..70d50ced4df 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -337,6 +337,7 @@ export const MatchInner = Vue.defineComponent({ ssr: match.ssr, _forcePending: match._forcePending, _displayPending: match._displayPending, + _nonReactive: match._nonReactive, }, remountKey, } @@ -350,6 +351,22 @@ export const MatchInner = Vue.defineComponent({ const match = Vue.computed(() => combinedState.value?.match) const remountKey = Vue.computed(() => combinedState.value?.remountKey) + 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] + ) + } + return (): VNode | null => { // If match doesn't exist, return null (component is being unmounted or not ready) if (!combinedState.value || !match.value || !route.value) return null @@ -390,7 +407,7 @@ export const MatchInner = Vue.defineComponent({ invariant() } - throw router.getMatch(match.value.id)?._nonReactive.loadPromise + throw getMatchPromise(match.value, 'loadPromise') } if (match.value.status === 'error') { diff --git a/packages/vue-router/tests/lazy/normal.tsx b/packages/vue-router/tests/lazy/normal.tsx index 5b0f169da4c..61eba8ab023 100644 --- a/packages/vue-router/tests/lazy/normal.tsx +++ b/packages/vue-router/tests/lazy/normal.tsx @@ -2,7 +2,7 @@ import { createLazyFileRoute, createLazyRoute } from '../../src' export function Route(id: string) { return createLazyRoute(id)({ - component: () =>

I'm a normal route

, + component: () =>

I'm a normal route

, }) } diff --git a/packages/vue-router/tests/redirect.test.tsx b/packages/vue-router/tests/redirect.test.tsx index f198f8adf68..b7ba2e1af3e 100644 --- a/packages/vue-router/tests/redirect.test.tsx +++ b/packages/vue-router/tests/redirect.test.tsx @@ -4,6 +4,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest' import { Link, + Outlet, RouterProvider, createMemoryHistory, createRootRoute, @@ -94,6 +95,51 @@ describe('redirect', () => { expect(nestedFooLoaderMock).toHaveBeenCalled() }) + test('when root `beforeLoad` redirects while root pendingComponent is showing and the target route is lazy', async () => { + let hasRedirected = false + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + const rootRoute = createRootRoute({ + component: () => , + pendingMs: 0, + pendingComponent: () =>
loading
, + beforeLoad: async () => { + await sleep(WAIT_TIME) + if (!hasRedirected) { + hasRedirected = true + throw redirect({ to: '/posts' }) + } + }, + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Index page
, + }) + + const postsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/posts', + }).lazy(() => import('./lazy/normal').then((d) => d.Route('/posts'))) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, postsRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + + // 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() + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(router.state.location.href).toBe('/posts') + expect(consoleError).not.toHaveBeenCalled() + }) + test('when `redirect` is thrown in `loader`', async () => { const nestedLoaderMock = vi.fn() const nestedFooLoaderMock = vi.fn() From 4882d5e4b0d827b8bdf6dc608fe493fb1a1796cc Mon Sep 17 00:00:00 2001 From: "nx-cloud[bot]" <71083854+nx-cloud[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:10:32 +0000 Subject: [PATCH 2/2] fix(router): handle redirected lazy pending matches [Self-Healing CI Rerun]