From 7663fcf6cbe7350619b78faef106859372042679 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 26 Mar 2026 22:33:24 +0100 Subject: [PATCH 1/5] fix: preserve suspense promises for pending route matches Keep the current suspense promise attached while a route still renders as pending. This prevents aborted or invalidated reloads from throwing undefined instead of suspending. --- packages/react-router/tests/loaders.test.tsx | 96 ++++++++++++++++++++ packages/react-router/tests/router.test.tsx | 73 +++++++++++++++ packages/router-core/src/load-matches.ts | 10 +- 3 files changed, 175 insertions(+), 4 deletions(-) diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index d9b5968d723..e06e1fa7035 100644 --- a/packages/react-router/tests/loaders.test.tsx +++ b/packages/react-router/tests/loaders.test.tsx @@ -5,6 +5,7 @@ import { fireEvent, render, screen, + waitFor, } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' @@ -940,3 +941,98 @@ test('reproducer for #6388 - rapid navigation between parameterized routes shoul expect(paramPage).toHaveTextContent('Param Component 1 Done') expect(loaderCompleteMock).toHaveBeenCalled() }) + +test('keeps rendering the current pending route when a regular navigation aborts it', async () => { + const firstLoaderAborted = vi.fn() + const firstErrorComponentRenderCount = vi.fn() + let resolveSecondLoader: (() => void) | undefined + + const rootRoute = createRootRoute({ + component: () => , + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home page
, + }) + + const firstRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/first', + pendingMs: 0, + loader: async ({ abortController }) => { + await new Promise((_resolve, reject) => { + abortController.signal.addEventListener('abort', () => { + firstLoaderAborted() + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + + return 'first' + }, + component: () => ( +
{firstRoute.useLoaderData()}
+ ), + pendingComponent: () => ( +
Pending first route
+ ), + errorComponent: ({ error }) => { + firstErrorComponentRenderCount(error) + return
{String(error)}
+ }, + }) + + const secondRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/second', + loader: async () => { + await new Promise((resolve) => { + resolveSecondLoader = resolve + }) + + return 'second' + }, + component: () => ( +
{secondRoute.useLoaderData()}
+ ), + }) + + const routeTree = rootRoute.addChildren([indexRoute, firstRoute, secondRoute]) + const router = createRouter({ + routeTree, + history, + defaultPreload: false, + }) + + render() + await act(() => router.latestLoadPromise) + + expect(await screen.findByTestId('home-page')).toBeInTheDocument() + + act(() => { + void router.navigate({ to: '/first' }) + }) + expect(await screen.findByTestId('first-pending')).toBeInTheDocument() + + act(() => { + void router.navigate({ to: '/second' }) + }) + await act(() => sleep(0)) + + await waitFor(() => { + expect(resolveSecondLoader).toBeDefined() + }) + + expect(firstLoaderAborted).toHaveBeenCalled() + expect(screen.getByTestId('first-pending')).toBeInTheDocument() + expect(firstErrorComponentRenderCount).not.toHaveBeenCalled() + expect(screen.queryByTestId('first-error')).not.toBeInTheDocument() + + act(() => { + resolveSecondLoader?.() + }) + + await act(() => router.latestLoadPromise) + expect(await screen.findByTestId('second-page')).toHaveTextContent('second') +}) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 99a6e36d539..6b219871025 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1423,6 +1423,79 @@ describe('invalidate', () => { }) }) + it('keeps rendering a suspense fallback when invalidate({ forcePending: true }) reloads a route', async () => { + const history = createMemoryHistory({ + initialEntries: ['/force-pending'], + }) + const errorComponentRenderCount = vi.fn() + let shouldSuspendReload = false + let resolveReload: (() => void) | undefined + + const rootRoute = createRootRoute({ + component: () => , + }) + + const forcePendingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/force-pending', + pendingMs: 0, + pendingMinMs: 10, + loader: async () => { + if (shouldSuspendReload) { + await new Promise((resolve) => { + resolveReload = resolve + }) + } + + return 'done' + }, + component: () => ( +
+ {forcePendingRoute.useLoaderData()} +
+ ), + pendingComponent: () => ( +
Pending...
+ ), + errorComponent: ({ error }) => { + errorComponentRenderCount(error) + return
{String(error)}
+ }, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([forcePendingRoute]), + history, + }) + + render() + + await act(() => router.load()) + expect(await screen.findByTestId('force-pending-route')).toHaveTextContent( + 'done', + ) + + shouldSuspendReload = true + act(() => { + void router.invalidate({ forcePending: true }) + }) + + expect( + await screen.findByTestId('force-pending-fallback'), + ).toBeInTheDocument() + expect(errorComponentRenderCount).not.toHaveBeenCalled() + expect(screen.queryByTestId('force-pending-error')).not.toBeInTheDocument() + + act(() => { + resolveReload?.() + }) + + await act(() => router.latestLoadPromise) + expect(await screen.findByTestId('force-pending-route')).toHaveTextContent( + 'done', + ) + }) + /** * Regression test: * - When a route loader throws `notFound()`, the match enters a `'notFound'` status. diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index d212c72b926..19b9acbac9b 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -839,9 +839,10 @@ const loadRouteMatch = async ( await runLoader(inner, matchPromises, matchId, index, route) const match = inner.router.getMatch(matchId)! match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() + if (match.status !== 'pending') { + match._nonReactive.loadPromise?.resolve() + } match._nonReactive.loaderPromise = undefined - match._nonReactive.loadPromise = undefined } catch (err) { if (isRedirect(err)) { await inner.router.navigate(err.options) @@ -939,8 +940,9 @@ const loadRouteMatch = async ( const match = inner.router.getMatch(matchId)! if (!loaderIsRunningAsync) { match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() - match._nonReactive.loadPromise = undefined + if (match.status !== 'pending') { + match._nonReactive.loadPromise?.resolve() + } } clearTimeout(match._nonReactive.pendingTimeout) From e1dbc02f99613567a1f8ae594004f22747bdd27f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Thu, 26 Mar 2026 22:57:24 +0100 Subject: [PATCH 2/5] changeset --- .changeset/fancy-camels-rhyme.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fancy-camels-rhyme.md diff --git a/.changeset/fancy-camels-rhyme.md b/.changeset/fancy-camels-rhyme.md new file mode 100644 index 00000000000..0515d57fd9f --- /dev/null +++ b/.changeset/fancy-camels-rhyme.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-router': patch +'@tanstack/router-core': patch +--- + +fix throw undefined on immediate redirect during route load From 00ca45cf4c0d1351e122be2061ebb7b79683c719 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 27 Mar 2026 07:18:41 +0100 Subject: [PATCH 3/5] fix: keep React suspense alive without blocking Solid Keep resolving shared load promises in core so Solid suspense and selective SSR can complete, but let React create a temporary local suspense promise when a pending match no longer has a pending load promise to throw. --- packages/react-router/src/Match.tsx | 52 +++++++++++++++++++++--- packages/router-core/src/load-matches.ts | 8 +--- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 04e354d0835..6734abab539 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -19,6 +19,7 @@ import { ScrollRestoration } from './scroll-restoration' import { ClientOnly } from './ClientOnly' import type { AnyRoute, + ControlledPromise, ParsedLocation, RootRouteOptions, } from '@tanstack/router-core' @@ -339,6 +340,16 @@ export const MatchInner = React.memo(function MatchInnerImpl({ return out } + return +}) + +function MatchInnerClient({ + matchId, + router, +}: { + matchId: string + router: ReturnType +}) { const matchStore = router.stores.activeMatchStoresById.get(matchId) if (!matchStore) { if (process.env.NODE_ENV !== 'production') { @@ -349,11 +360,9 @@ export const MatchInner = React.memo(function MatchInnerImpl({ invariant() } - // eslint-disable-next-line react-hooks/rules-of-hooks const match = useStore(matchStore, (value) => value) const routeId = match.routeId as string const route = router.routesById[routeId] as AnyRoute - // eslint-disable-next-line react-hooks/rules-of-hooks const key = React.useMemo(() => { const remountFn = (router.routesById[routeId] as AnyRoute).options.remountDeps ?? @@ -374,7 +383,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({ router.routesById, ]) - // eslint-disable-next-line react-hooks/rules-of-hooks const out = React.useMemo(() => { const Comp = route.options.component ?? router.options.defaultComponent if (Comp) { @@ -383,6 +391,38 @@ export const MatchInner = React.memo(function MatchInnerImpl({ return }, [key, route.options.component, router.options.defaultComponent]) + const suspensePromiseRef = React.useRef | undefined>( + undefined, + ) + + React.useEffect(() => { + if (match.status !== 'pending' && match.status !== 'redirected') { + suspensePromiseRef.current?.resolve() + suspensePromiseRef.current = undefined + } + }, [match.status]) + + React.useEffect(() => { + return () => { + suspensePromiseRef.current?.resolve() + suspensePromiseRef.current = undefined + } + }, [match.id]) + + const getSuspensePromise = React.useCallback(() => { + const loadPromise = router.getMatch(match.id)?._nonReactive.loadPromise + + if (loadPromise?.status === 'pending') { + return loadPromise + } + + if (!suspensePromiseRef.current) { + suspensePromiseRef.current = createControlledPromise() + } + + return suspensePromiseRef.current + }, [router, match.id]) + if (match._displayPending) { throw router.getMatch(match.id)?._nonReactive.displayPendingPromise } @@ -413,7 +453,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ } } } - throw router.getMatch(match.id)?._nonReactive.loadPromise + throw getSuspensePromise() } if (match.status === 'notFound') { @@ -442,7 +482,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ // 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 getSuspensePromise() } if (match.status === 'error') { @@ -471,7 +511,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ } return out -}) +} /** * Render the next child match in the route tree. Typically used inside diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index 19b9acbac9b..fc4e0585a90 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -839,9 +839,7 @@ const loadRouteMatch = async ( await runLoader(inner, matchPromises, matchId, index, route) const match = inner.router.getMatch(matchId)! match._nonReactive.loaderPromise?.resolve() - if (match.status !== 'pending') { - match._nonReactive.loadPromise?.resolve() - } + match._nonReactive.loadPromise?.resolve() match._nonReactive.loaderPromise = undefined } catch (err) { if (isRedirect(err)) { @@ -940,9 +938,7 @@ const loadRouteMatch = async ( const match = inner.router.getMatch(matchId)! if (!loaderIsRunningAsync) { match._nonReactive.loaderPromise?.resolve() - if (match.status !== 'pending') { - match._nonReactive.loadPromise?.resolve() - } + match._nonReactive.loadPromise?.resolve() } clearTimeout(match._nonReactive.pendingTimeout) From c07c593421d9bad1d9557842e6c1d38afa0aca52 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 27 Mar 2026 07:24:07 +0100 Subject: [PATCH 4/5] Revert "fix: keep React suspense alive without blocking Solid" This reverts commit 00ca45cf4c0d1351e122be2061ebb7b79683c719. --- packages/react-router/src/Match.tsx | 52 +++--------------------- packages/router-core/src/load-matches.ts | 8 +++- 2 files changed, 12 insertions(+), 48 deletions(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 6734abab539..04e354d0835 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -19,7 +19,6 @@ import { ScrollRestoration } from './scroll-restoration' import { ClientOnly } from './ClientOnly' import type { AnyRoute, - ControlledPromise, ParsedLocation, RootRouteOptions, } from '@tanstack/router-core' @@ -340,16 +339,6 @@ export const MatchInner = React.memo(function MatchInnerImpl({ return out } - return -}) - -function MatchInnerClient({ - matchId, - router, -}: { - matchId: string - router: ReturnType -}) { const matchStore = router.stores.activeMatchStoresById.get(matchId) if (!matchStore) { if (process.env.NODE_ENV !== 'production') { @@ -360,9 +349,11 @@ function MatchInnerClient({ invariant() } + // eslint-disable-next-line react-hooks/rules-of-hooks const match = useStore(matchStore, (value) => value) const routeId = match.routeId as string const route = router.routesById[routeId] as AnyRoute + // eslint-disable-next-line react-hooks/rules-of-hooks const key = React.useMemo(() => { const remountFn = (router.routesById[routeId] as AnyRoute).options.remountDeps ?? @@ -383,6 +374,7 @@ function MatchInnerClient({ router.routesById, ]) + // eslint-disable-next-line react-hooks/rules-of-hooks const out = React.useMemo(() => { const Comp = route.options.component ?? router.options.defaultComponent if (Comp) { @@ -391,38 +383,6 @@ function MatchInnerClient({ return }, [key, route.options.component, router.options.defaultComponent]) - const suspensePromiseRef = React.useRef | undefined>( - undefined, - ) - - React.useEffect(() => { - if (match.status !== 'pending' && match.status !== 'redirected') { - suspensePromiseRef.current?.resolve() - suspensePromiseRef.current = undefined - } - }, [match.status]) - - React.useEffect(() => { - return () => { - suspensePromiseRef.current?.resolve() - suspensePromiseRef.current = undefined - } - }, [match.id]) - - const getSuspensePromise = React.useCallback(() => { - const loadPromise = router.getMatch(match.id)?._nonReactive.loadPromise - - if (loadPromise?.status === 'pending') { - return loadPromise - } - - if (!suspensePromiseRef.current) { - suspensePromiseRef.current = createControlledPromise() - } - - return suspensePromiseRef.current - }, [router, match.id]) - if (match._displayPending) { throw router.getMatch(match.id)?._nonReactive.displayPendingPromise } @@ -453,7 +413,7 @@ function MatchInnerClient({ } } } - throw getSuspensePromise() + throw router.getMatch(match.id)?._nonReactive.loadPromise } if (match.status === 'notFound') { @@ -482,7 +442,7 @@ function MatchInnerClient({ // false, // 'Tried to render a redirected route match! This is a weird circumstance, please file an issue!', // ) - throw getSuspensePromise() + throw router.getMatch(match.id)?._nonReactive.loadPromise } if (match.status === 'error') { @@ -511,7 +471,7 @@ function MatchInnerClient({ } return out -} +}) /** * Render the next child match in the route tree. Typically used inside diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index fc4e0585a90..19b9acbac9b 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -839,7 +839,9 @@ const loadRouteMatch = async ( await runLoader(inner, matchPromises, matchId, index, route) const match = inner.router.getMatch(matchId)! match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() + if (match.status !== 'pending') { + match._nonReactive.loadPromise?.resolve() + } match._nonReactive.loaderPromise = undefined } catch (err) { if (isRedirect(err)) { @@ -938,7 +940,9 @@ const loadRouteMatch = async ( const match = inner.router.getMatch(matchId)! if (!loaderIsRunningAsync) { match._nonReactive.loaderPromise?.resolve() - match._nonReactive.loadPromise?.resolve() + if (match.status !== 'pending') { + match._nonReactive.loadPromise?.resolve() + } } clearTimeout(match._nonReactive.pendingTimeout) From 8e16dff9e260a3a0bbdc9fc4a719a1b82a80e0e9 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 27 Mar 2026 11:44:40 +0100 Subject: [PATCH 5/5] maybe? --- packages/react-router/src/Match.tsx | 5 ++- packages/router-core/src/Matches.ts | 1 + packages/router-core/src/load-matches.ts | 8 ++--- packages/router-core/src/router.ts | 39 +++++++++++++++++++++--- 4 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 04e354d0835..79e26ffdb5f 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -413,7 +413,10 @@ export const MatchInner = React.memo(function MatchInnerImpl({ } } } - throw router.getMatch(match.id)?._nonReactive.loadPromise + if (match._nonReactive.pendingRenderPromise?.status !== 'pending') { + match._nonReactive.pendingRenderPromise = createControlledPromise() + } + throw match._nonReactive.pendingRenderPromise } if (match.status === 'notFound') { diff --git a/packages/router-core/src/Matches.ts b/packages/router-core/src/Matches.ts index 852d186b67d..0ba56004d23 100644 --- a/packages/router-core/src/Matches.ts +++ b/packages/router-core/src/Matches.ts @@ -145,6 +145,7 @@ export interface RouteMatch< /** @internal */ pendingTimeout?: ReturnType loadPromise?: ControlledPromise + pendingRenderPromise?: ControlledPromise displayPendingPromise?: Promise minPendingPromise?: ControlledPromise dehydrated?: boolean diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index 19b9acbac9b..fc4e0585a90 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -839,9 +839,7 @@ const loadRouteMatch = async ( await runLoader(inner, matchPromises, matchId, index, route) const match = inner.router.getMatch(matchId)! match._nonReactive.loaderPromise?.resolve() - if (match.status !== 'pending') { - match._nonReactive.loadPromise?.resolve() - } + match._nonReactive.loadPromise?.resolve() match._nonReactive.loaderPromise = undefined } catch (err) { if (isRedirect(err)) { @@ -940,9 +938,7 @@ const loadRouteMatch = async ( const match = inner.router.getMatch(matchId)! if (!loaderIsRunningAsync) { match._nonReactive.loaderPromise?.resolve() - if (match.status !== 'pending') { - match._nonReactive.loadPromise?.resolve() - } + match._nonReactive.loadPromise?.resolve() } clearTimeout(match._nonReactive.pendingTimeout) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 965fefda339..663a2b82d7a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -112,6 +112,13 @@ export type ControllablePromise = Promise & { export type InjectedHtmlEntry = Promise +const clearPendingRenderPromise = (match: AnyRouteMatch | undefined) => { + if (match) { + match._nonReactive.pendingRenderPromise?.resolve() + match._nonReactive.pendingRenderPromise = undefined + } +} + export interface Register { // Lots of things on here like... // router @@ -2475,6 +2482,19 @@ export class RouterCore< * or reloads re-run their loaders instead of reusing the failed/not-found data. */ if (mountPending) { + const pendingMatchesById = new Map( + pendingMatches.map((match) => [match.id, match]), + ) + + currentMatches.forEach((currentMatch) => { + if ( + pendingMatchesById.get(currentMatch.id)?.status !== + 'pending' + ) { + clearPendingRenderPromise(currentMatch) + } + }) + this.stores.setActiveMatches(pendingMatches) this.stores.setPendingMatches([]) this.stores.setCachedMatches([ @@ -2634,7 +2654,11 @@ export class RouterCore< const activeMatch = this.stores.activeMatchStoresById.get(id) if (activeMatch) { - activeMatch.setState(updater) + const next = updater(activeMatch.state) + if (next.status !== 'pending') { + clearPendingRenderPromise(activeMatch.state) + } + activeMatch.setState(() => next) return } @@ -2696,9 +2720,16 @@ export class RouterCore< } this.batch(() => { - this.stores.setActiveMatches( - this.stores.activeMatchesSnapshot.state.map(invalidate), - ) + const activeMatches = this.stores.activeMatchesSnapshot.state + const nextActiveMatches = activeMatches.map(invalidate) + + activeMatches.forEach((activeMatch, index) => { + if (nextActiveMatches[index]?.status !== 'pending') { + clearPendingRenderPromise(activeMatch) + } + }) + + this.stores.setActiveMatches(nextActiveMatches) this.stores.setCachedMatches( this.stores.cachedMatchesSnapshot.state.map(invalidate), )