From a0fc7c08e457d14cf02f5730385cab942b83fa5f Mon Sep 17 00:00:00 2001 From: Gianmarco Rengucci Date: Fri, 18 Sep 2026 10:04:49 +0200 Subject: [PATCH 1/5] fix(router-core): advance the pending boundary past settled matches A load transaction presents one snapshot with a single match forced to pending, and it offered any match outside the retained prefix as that boundary before the match settled, whether or not the route had a loader. It then treated every presented match with a pending status as the painted boundary, but only the first one is on screen, because the fallback replaces the rest of the subtree. Together those made a settled ancestor keep selecting itself as the boundary and stop early on its own session, so the presented snapshot froze until the slowest loader in the branch resolved. Navigating between sibling layouts replaced the whole shell, including a layout that had nothing left to load. The painted boundary is now the first presented pending entry, and a settled match no longer takes the boundary, so a layout route with no loader renders as soon as it settles and the fallback stays on the match that is still loading. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/brown-kings-listen.md | 9 + ...pending-boundary-settled-ancestor.test.tsx | 101 ++++++++ packages/router-core/src/load-client.ts | 21 +- .../pending-boundary-settled-ancestor.test.ts | 216 ++++++++++++++++++ 4 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 .changeset/brown-kings-listen.md create mode 100644 packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx create mode 100644 packages/router-core/tests/pending-boundary-settled-ancestor.test.ts diff --git a/.changeset/brown-kings-listen.md b/.changeset/brown-kings-listen.md new file mode 100644 index 00000000000..468cf98bc6b --- /dev/null +++ b/.changeset/brown-kings-listen.md @@ -0,0 +1,9 @@ +--- +'@tanstack/router-core': patch +--- + +Advance the pending boundary past matches that have already settled. + +The router offers a match outside the retained prefix as the pending boundary before it settles, even when the route has no `loader`. It also treated any presented match with status `pending` as the painted boundary, and every match below the boundary carries that status in the offered snapshot. A settled ancestor therefore kept selecting itself as the boundary and then stopped early on its own session, so the presented snapshot stayed `pending` until the slowest loader in the branch resolved. + +The painted boundary is now the first presented `pending` entry, and a settled match no longer takes the boundary. A layout route with no loader, such as one that is only lazy, now renders as soon as it settles, and the fallback stays on the match that is still loading. diff --git a/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx b/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx new file mode 100644 index 00000000000..ce8e6bf8e61 --- /dev/null +++ b/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx @@ -0,0 +1,101 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createControlledPromise, + createLazyRoute, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +// A lazy-only layout settles before its leaf loader, so its shell renders +// while the leaf fallback stays visible. +test('a settled layout renders its shell while its leaf is still loading', async () => { + const headerOptions = createLazyRoute('/users/$userId')({ + component: () => ( +
+

Header shell

+ +
+ ), + }) + const headerChunk = createControlledPromise() + const detailLoader = createControlledPromise() + + const rootRoute = createRootRoute({ component: Outlet }) + const authRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_auth', + component: Outlet, + }) + const sidebarRoute = createRoute({ + getParentRoute: () => authRoute, + id: '_sidebar', + component: Outlet, + }) + const headerRoute = createRoute({ + getParentRoute: () => authRoute, + id: '_header', + }).lazy(() => headerChunk) + + const listRoute = createRoute({ + getParentRoute: () => sidebarRoute, + path: '/users', + component: () =>

User list

, + }) + const detailRoute = createRoute({ + getParentRoute: () => headerRoute, + path: '/users/$userId', + loader: () => detailLoader, + component: () =>

User detail

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authRoute.addChildren([ + sidebarRoute.addChildren([listRoute]), + headerRoute.addChildren([detailRoute]), + ]), + ]), + history: createMemoryHistory({ initialEntries: ['/users'] }), + defaultPendingComponent: () =>

Loading

, + defaultPendingMs: 0, + defaultPendingMinMs: 0, + }) + + render() + await waitFor(() => expect(screen.getByText('User list')).toBeInTheDocument()) + + const navigation = router.navigate({ + to: '/users/$userId', + params: { userId: 'u1' }, + }) + + await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument()) + expect(screen.queryByText('Header shell')).not.toBeInTheDocument() + + headerChunk.resolve(headerOptions) + + await waitFor(() => + expect(screen.getByText('Header shell')).toBeInTheDocument(), + ) + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('User detail')).not.toBeInTheDocument() + + detailLoader.resolve('detail data') + await navigation + + await waitFor(() => + expect(screen.getByText('User detail')).toBeInTheDocument(), + ) + expect(screen.getByText('Header shell')).toBeInTheDocument() +}) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index ea4f2704471..bf954779f5d 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -1453,21 +1453,26 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { } const matches = tx[3 /* matches */] const presented = router.stores.matches.get() + // Only the first presented `pending` entry is the painted boundary. The + // fallback replaces every match after it, so those carry `pending` in the + // offered snapshot without ever rendering. + const paintedBoundary = presented.findIndex( + (candidate) => candidate.status === 'pending', + ) let session = router._pending for (let index = 0; index < matches.length; index++) { const match = matches[index]! - const success = match.status === 'success' && !match._notFound const presentedPending = - presented[index]?.id === match.id && - presented[index]?.status === 'pending' - if (success && !presentedPending) { + index === paintedBoundary && presented[index]?.id === match.id + // A settled match never keeps the boundary, even while it is painted. The + // boundary advances so the next presented snapshot carries its real status. + if (match.status === 'success' && !match._notFound) { continue } const route = getRoute(router, match as WorkMatch) - const delay = - success || match.invalid - ? 0 - : (route.options.pendingMs ?? router.options.defaultPendingMs) + const delay = match.invalid + ? 0 + : (route.options.pendingMs ?? router.options.defaultPendingMs) const component = route.options.pendingComponent ?? (router.options as any).defaultPendingComponent diff --git a/packages/router-core/tests/pending-boundary-settled-ancestor.test.ts b/packages/router-core/tests/pending-boundary-settled-ancestor.test.ts new file mode 100644 index 00000000000..eadc8ff7db2 --- /dev/null +++ b/packages/router-core/tests/pending-boundary-settled-ancestor.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +/** + * The pending boundary must advance past matches that have already settled. + * A layout route with no loader is offered as the boundary while its lazy + * chunk loads, but once it settles the boundary belongs on the leaf that is + * still loading so the layout shell renders instead of staying behind the + * fallback. + */ + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('pending boundary must advance past settled ancestors', () => { + test('a layout route with no loader does not stay the pending boundary once it settles', async () => { + const leafLoader = createControlledPromise() + + const rootRoute = new BaseRootRoute({ component: () => null }) + const authRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + id: '_auth', + loader: () => ({}), + component: () => null, + }) + const sidebarRoute = new BaseRoute({ + getParentRoute: () => authRoute, + id: '_sidebar', + component: () => null, + }) + // A layout route with no loader that still has a lazy chunk to fetch. + const headerRoute = new BaseRoute({ + getParentRoute: () => authRoute, + id: '_header', + pendingComponent: () => null, + }) + const headerChunk = createControlledPromise() + headerRoute.lazy(() => headerChunk) + + const listRoute = new BaseRoute({ + getParentRoute: () => sidebarRoute, + path: '/users', + component: () => null, + }) + const detailRoute = new BaseRoute({ + getParentRoute: () => headerRoute, + path: '/users/$userId', + loader: () => leafLoader, + pendingComponent: () => null, + component: () => null, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + authRoute.addChildren([ + sidebarRoute.addChildren([listRoute]), + headerRoute.addChildren([detailRoute]), + ]), + ]), + history: createMemoryHistory({ initialEntries: ['/users'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + }) + + await router.load() + + const navigation = router.navigate({ + to: '/users/$userId', + params: { userId: 'u1' }, + }) + + await vi.waitFor(() => + expect( + router.state.matches.find((match) => match.routeId === headerRoute.id) + ?.status, + ).toBe('pending'), + ) + + headerChunk.resolve({ + options: { id: headerRoute.id, component: () => null }, + }) + + await vi.waitFor(() => + expect( + router.state.matches.map((match) => [match.routeId, match.status]), + ).toEqual([ + [rootRoute.id, 'success'], + [authRoute.id, 'success'], + [headerRoute.id, 'success'], + [detailRoute.id, 'pending'], + ]), + ) + + leafLoader.resolve('leaf data') + await navigation + }) + + test('an initial load presents the layout as settled once its loader resolves', async () => { + const layoutLoader = createControlledPromise() + const childLoader = createControlledPromise() + + const rootRoute = new BaseRootRoute({ component: () => null }) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + loader: () => layoutLoader, + pendingComponent: () => null, + component: () => null, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/child', + loader: () => childLoader, + pendingComponent: () => null, + component: () => null, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([layoutRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/child'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + }) + + const load = router.load() + + await vi.waitFor(() => + expect( + router.state.matches.find((match) => match.routeId === layoutRoute.id) + ?.status, + ).toBe('pending'), + ) + + layoutLoader.resolve('layout data') + + await vi.waitFor(() => + expect( + router.state.matches.map((match) => [match.routeId, match.status]), + ).toEqual([ + [rootRoute.id, 'success'], + [layoutRoute.id, 'success'], + [childRoute.id, 'pending'], + ]), + ) + + childLoader.resolve('child data') + await load + }) + + test('pendingMinMs on a settled boundary does not pin the presented snapshot', async () => { + const leafLoader = createControlledPromise() + + const rootRoute = new BaseRootRoute({ component: () => null }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + }) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + pendingComponent: () => null, + }) + const layoutChunk = createControlledPromise() + layoutRoute.lazy(() => layoutChunk) + + const leafRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/leaf', + loader: () => leafLoader, + pendingComponent: () => null, + component: () => null, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([leafRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 400, + }) + + await router.load() + const navigation = router.navigate({ to: '/leaf' }) + + await vi.waitFor(() => + expect( + router.state.matches.find((match) => match.routeId === layoutRoute.id) + ?.status, + ).toBe('pending'), + ) + + layoutChunk.resolve({ + options: { id: layoutRoute.id, component: () => null }, + }) + + await vi.waitFor(() => + expect( + router.state.matches.map((match) => [match.routeId, match.status]), + ).toEqual([ + [rootRoute.id, 'success'], + [layoutRoute.id, 'success'], + [leafRoute.id, 'pending'], + ]), + ) + + leafLoader.resolve('leaf data') + await navigation + }) +}) From 7116510f822e62013d39128b3d13e78ef41ce692 Mon Sep 17 00:00:00 2001 From: Gianmarco Rengucci Date: Sun, 20 Sep 2026 13:18:17 +0200 Subject: [PATCH 2/5] fix(router-core): keep terminal painted boundary for hydrating routes Advance past a settled match only toward pending descendants. A terminal settled match that is still painted falls through and is offered, so data-only routes whose SSR data arrived as success keep their pending phase while the client component hydrates. Verified against tanstack-solid-start-e2e-selective-ssr, which regressed on the unconditional advance. --- .changeset/brown-kings-listen.md | 2 +- packages/router-core/src/load-client.ts | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.changeset/brown-kings-listen.md b/.changeset/brown-kings-listen.md index 468cf98bc6b..b0deb6110fe 100644 --- a/.changeset/brown-kings-listen.md +++ b/.changeset/brown-kings-listen.md @@ -6,4 +6,4 @@ Advance the pending boundary past matches that have already settled. The router offers a match outside the retained prefix as the pending boundary before it settles, even when the route has no `loader`. It also treated any presented match with status `pending` as the painted boundary, and every match below the boundary carries that status in the offered snapshot. A settled ancestor therefore kept selecting itself as the boundary and then stopped early on its own session, so the presented snapshot stayed `pending` until the slowest loader in the branch resolved. -The painted boundary is now the first presented `pending` entry, and a settled match no longer takes the boundary. A layout route with no loader, such as one that is only lazy, now renders as soon as it settles, and the fallback stays on the match that is still loading. +The painted boundary is now the first presented `pending` entry, and a settled match hands over toward pending descendants. A layout route with no loader, such as one that is only lazy, now renders as soon as it settles, and the fallback stays on the match that is still loading. A terminal settled match that is still painted is still offered, so data-only routes keep their pending phase while hydrating on the client. diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index bf954779f5d..c11384e69b7 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -1466,8 +1466,25 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { index === paintedBoundary && presented[index]?.id === match.id // A settled match never keeps the boundary, even while it is painted. The // boundary advances so the next presented snapshot carries its real status. + // A painted boundary holds through its minimum window. The render ack + // arms the session deadline, so a live deadline means the outgoing + // fallback was seen; anything else advances at once. if (match.status === 'success' && !match._notFound) { - continue + // Advance past a settled match only toward pending descendants. A + // terminal settled match that is still painted (e.g. a data-only route + // whose SSR data arrived as success while the client component still + // needs its pending phase) falls through and is offered instead. + let hasPendingDescendant = false + for (let next = index + 1; next < matches.length; next++) { + const descendant = matches[next]! + if (descendant.status !== 'success' || descendant._notFound) { + hasPendingDescendant = true + break + } + } + if (hasPendingDescendant || !presentedPending) { + continue + } } const route = getRoute(router, match as WorkMatch) const delay = match.invalid From 6fdd4480c54735203c380e5ee524c87aa815bcdc Mon Sep 17 00:00:00 2001 From: Gianmarco Rengucci Date: Sun, 20 Sep 2026 13:20:43 +0200 Subject: [PATCH 3/5] fix(router-core): hold painted boundary through its minimum window A settled match that is still painted keeps the boundary until its pendingMinMs window elapses instead of handing over at once. Unpainted boundaries advance immediately. --- .changeset/brown-kings-listen.md | 2 +- ...pending-boundary-settled-ancestor.test.tsx | 92 +++++++++++++++++++ packages/router-core/src/load-client.ts | 15 +++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/.changeset/brown-kings-listen.md b/.changeset/brown-kings-listen.md index b0deb6110fe..317eed597cf 100644 --- a/.changeset/brown-kings-listen.md +++ b/.changeset/brown-kings-listen.md @@ -6,4 +6,4 @@ Advance the pending boundary past matches that have already settled. The router offers a match outside the retained prefix as the pending boundary before it settles, even when the route has no `loader`. It also treated any presented match with status `pending` as the painted boundary, and every match below the boundary carries that status in the offered snapshot. A settled ancestor therefore kept selecting itself as the boundary and then stopped early on its own session, so the presented snapshot stayed `pending` until the slowest loader in the branch resolved. -The painted boundary is now the first presented `pending` entry, and a settled match hands over toward pending descendants. A layout route with no loader, such as one that is only lazy, now renders as soon as it settles, and the fallback stays on the match that is still loading. A terminal settled match that is still painted is still offered, so data-only routes keep their pending phase while hydrating on the client. +The painted boundary is now the first presented `pending` entry, and a settled match hands over toward pending descendants. A layout route with no loader, such as one that is only lazy, now renders as soon as it settles, and the fallback stays on the match that is still loading. A terminal settled match that is still painted is still offered, so data-only routes keep their pending phase while hydrating on the client. A painted boundary holds through its `pendingMinMs` window before handing over, so an outgoing fallback is never swapped faster than its minimum. diff --git a/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx b/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx index ce8e6bf8e61..14269e0c1a5 100644 --- a/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx +++ b/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx @@ -99,3 +99,95 @@ test('a settled layout renders its shell while its leaf is still loading', async ) expect(screen.getByText('Header shell')).toBeInTheDocument() }) + +// A painted fallback holds through its minimum window before the boundary +// moves to the leaf that is still loading. +test('a painted fallback holds its minimum before the shell renders', async () => { + const headerOptions = createLazyRoute('/users/$userId')({ + component: () => ( +
+

Header shell

+ +
+ ), + }) + const headerChunk = createControlledPromise() + const detailLoader = createControlledPromise() + + const rootRoute = createRootRoute({ component: Outlet }) + const authRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_auth', + component: Outlet, + }) + const sidebarRoute = createRoute({ + getParentRoute: () => authRoute, + id: '_sidebar', + component: Outlet, + }) + const headerRoute = createRoute({ + getParentRoute: () => authRoute, + id: '_header', + }).lazy(() => headerChunk) + + const listRoute = createRoute({ + getParentRoute: () => sidebarRoute, + path: '/users', + component: () =>

User list

, + }) + const detailRoute = createRoute({ + getParentRoute: () => headerRoute, + path: '/users/$userId', + loader: () => detailLoader, + component: () =>

User detail

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authRoute.addChildren([ + sidebarRoute.addChildren([listRoute]), + headerRoute.addChildren([detailRoute]), + ]), + ]), + history: createMemoryHistory({ initialEntries: ['/users'] }), + defaultPendingComponent: () =>

Loading

, + defaultPendingMs: 0, + defaultPendingMinMs: 400, + }) + + render() + await waitFor(() => + expect(screen.getByText('User list')).toBeInTheDocument(), + ) + + const navigation = router.navigate({ + to: '/users/$userId', + params: { userId: 'u1' }, + }) + + await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument()) + expect(screen.queryByText('Header shell')).not.toBeInTheDocument() + + headerChunk.resolve(headerOptions) + + // The layout settled, but the painted fallback holds its minimum window. + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('Header shell')).not.toBeInTheDocument() + + // Once the minimum elapses the shell renders with the leaf still pending. + await waitFor( + () => expect(screen.getByText('Header shell')).toBeInTheDocument(), + { timeout: 5000 }, + ) + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('User detail')).not.toBeInTheDocument() + + detailLoader.resolve('detail data') + await navigation + + await waitFor(() => + expect(screen.getByText('User detail')).toBeInTheDocument(), + ) + expect(screen.getByText('Header shell')).toBeInTheDocument() +}) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index c11384e69b7..c64a9359c7e 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -1470,6 +1470,21 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { // arms the session deadline, so a live deadline means the outgoing // fallback was seen; anything else advances at once. if (match.status === 'success' && !match._notFound) { + // A painted boundary holds through its minimum window. The render ack + // arms the session deadline, so a live deadline means the outgoing + // fallback was seen; anything else advances at once. + if (presentedPending && session?.[1 /* boundaryId */] === match.id) { + const remaining = session[2 /* deadline */] - Date.now() + if (remaining > 0) { + session[0 /* generation */] = tx + clearTimeout(session[3 /* revealTimer */]) + session[3 /* revealTimer */] = setTimeout( + () => offerPending(router, tx), + remaining, + ) + return + } + } // Advance past a settled match only toward pending descendants. A // terminal settled match that is still painted (e.g. a data-only route // whose SSR data arrived as success while the client component still From fbf3d11b4bbfc732aad2c545c5e3834ecc7413d0 Mon Sep 17 00:00:00 2001 From: Gianmarco Rengucci Date: Sun, 20 Sep 2026 13:21:29 +0200 Subject: [PATCH 4/5] test(router-core,react-router): cover pending boundary handover Mid-chain step-down as ancestors settle, fully-settling branch committing without a boundary, unpainted boundary advancing at once, and fallback swap from layout to leaf (React). --- ...pending-boundary-settled-ancestor.test.tsx | 90 +++++++++++- .../pending-boundary-settled-ancestor.test.ts | 136 +++++++++++++++++- 2 files changed, 220 insertions(+), 6 deletions(-) diff --git a/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx b/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx index 14269e0c1a5..dfa00d01547 100644 --- a/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx +++ b/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx @@ -156,9 +156,7 @@ test('a painted fallback holds its minimum before the shell renders', async () = }) render() - await waitFor(() => - expect(screen.getByText('User list')).toBeInTheDocument(), - ) + await waitFor(() => expect(screen.getByText('User list')).toBeInTheDocument()) const navigation = router.navigate({ to: '/users/$userId', @@ -191,3 +189,89 @@ test('a painted fallback holds its minimum before the shell renders', async () = ) expect(screen.getByText('Header shell')).toBeInTheDocument() }) + +// Each boundary shows its own fallback as the boundary moves down the branch. +test('the pending fallback moves from the layout to the leaf', async () => { + const headerOptions = createLazyRoute('/users/$userId')({ + component: () => ( +
+

Header shell

+ +
+ ), + }) + const headerChunk = createControlledPromise() + const detailLoader = createControlledPromise() + + const rootRoute = createRootRoute({ component: Outlet }) + const authRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_auth', + component: Outlet, + }) + const sidebarRoute = createRoute({ + getParentRoute: () => authRoute, + id: '_sidebar', + component: Outlet, + }) + const headerRoute = createRoute({ + getParentRoute: () => authRoute, + id: '_header', + pendingComponent: () =>

Header loading

, + }).lazy(() => headerChunk) + + const listRoute = createRoute({ + getParentRoute: () => sidebarRoute, + path: '/users', + component: () =>

User list

, + }) + const detailRoute = createRoute({ + getParentRoute: () => headerRoute, + path: '/users/$userId', + loader: () => detailLoader, + pendingComponent: () =>

Leaf loading

, + component: () =>

User detail

, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authRoute.addChildren([ + sidebarRoute.addChildren([listRoute]), + headerRoute.addChildren([detailRoute]), + ]), + ]), + history: createMemoryHistory({ initialEntries: ['/users'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + }) + + render() + await waitFor(() => expect(screen.getByText('User list')).toBeInTheDocument()) + + const navigation = router.navigate({ + to: '/users/$userId', + params: { userId: 'u1' }, + }) + + await waitFor(() => + expect(screen.getByText('Header loading')).toBeInTheDocument(), + ) + expect(screen.queryByText('Header shell')).not.toBeInTheDocument() + + headerChunk.resolve(headerOptions) + + await waitFor(() => + expect(screen.getByText('Header shell')).toBeInTheDocument(), + ) + expect(screen.getByText('Leaf loading')).toBeInTheDocument() + expect(screen.queryByText('Header loading')).not.toBeInTheDocument() + expect(screen.queryByText('User detail')).not.toBeInTheDocument() + + detailLoader.resolve('detail data') + await navigation + + await waitFor(() => + expect(screen.getByText('User detail')).toBeInTheDocument(), + ) + expect(screen.getByText('Header shell')).toBeInTheDocument() +}) diff --git a/packages/router-core/tests/pending-boundary-settled-ancestor.test.ts b/packages/router-core/tests/pending-boundary-settled-ancestor.test.ts index eadc8ff7db2..007c1fcc6d4 100644 --- a/packages/router-core/tests/pending-boundary-settled-ancestor.test.ts +++ b/packages/router-core/tests/pending-boundary-settled-ancestor.test.ts @@ -38,7 +38,10 @@ describe('pending boundary must advance past settled ancestors', () => { id: '_header', pendingComponent: () => null, }) - const headerChunk = createControlledPromise() + const headerChunk = + createControlledPromise< + Awaited[0]>> + >() headerRoute.lazy(() => headerChunk) const listRoute = new BaseRoute({ @@ -151,7 +154,7 @@ describe('pending boundary must advance past settled ancestors', () => { await load }) - test('pendingMinMs on a settled boundary does not pin the presented snapshot', async () => { + test('a boundary that never paints does not hold the presented snapshot', async () => { const leafLoader = createControlledPromise() const rootRoute = new BaseRootRoute({ component: () => null }) @@ -165,7 +168,10 @@ describe('pending boundary must advance past settled ancestors', () => { id: '_layout', pendingComponent: () => null, }) - const layoutChunk = createControlledPromise() + const layoutChunk = + createControlledPromise< + Awaited[0]>> + >() layoutRoute.lazy(() => layoutChunk) const leafRoute = new BaseRoute({ @@ -185,6 +191,8 @@ describe('pending boundary must advance past settled ancestors', () => { defaultPendingMs: 0, defaultPendingMinMs: 400, }) + // The bare test router never renders, so no render ack arms a minimum + // window. The painted hold is covered by the React test instead. await router.load() const navigation = router.navigate({ to: '/leaf' }) @@ -200,6 +208,8 @@ describe('pending boundary must advance past settled ancestors', () => { options: { id: layoutRoute.id, component: () => null }, }) + // Nothing painted the boundary, so no minimum window holds it back and + // the boundary advances to the leaf as soon as the layout settles. await vi.waitFor(() => expect( router.state.matches.map((match) => [match.routeId, match.status]), @@ -213,4 +223,124 @@ describe('pending boundary must advance past settled ancestors', () => { leafLoader.resolve('leaf data') await navigation }) + + test('the boundary steps down as each ancestor in the chain settles', async () => { + const layoutLoader = createControlledPromise() + const midLoader = createControlledPromise() + const leafLoader = createControlledPromise() + + const rootRoute = new BaseRootRoute({ component: () => null }) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + loader: () => layoutLoader, + pendingComponent: () => null, + component: () => null, + }) + const midRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/mid', + loader: () => midLoader, + pendingComponent: () => null, + component: () => null, + }) + const leafRoute = new BaseRoute({ + getParentRoute: () => midRoute, + path: '/leaf', + loader: () => leafLoader, + pendingComponent: () => null, + component: () => null, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + layoutRoute.addChildren([midRoute.addChildren([leafRoute])]), + ]), + history: createMemoryHistory({ initialEntries: ['/mid/leaf'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + }) + + const find = (id: string) => + router.state.matches.find((match) => match.routeId === id)?.status + + const load = router.load() + + await vi.waitFor(() => expect(find(layoutRoute.id)).toBe('pending')) + + layoutLoader.resolve('layout data') + // The layout turning successful is what proves the boundary stepped down. + // A stuck boundary would keep forcing it pending in the presented snapshot. + await vi.waitFor(() => expect(find(layoutRoute.id)).toBe('success')) + await vi.waitFor(() => expect(find(midRoute.id)).toBe('pending')) + + midLoader.resolve('mid data') + await vi.waitFor(() => expect(find(midRoute.id)).toBe('success')) + await vi.waitFor(() => expect(find(leafRoute.id)).toBe('pending')) + + leafLoader.resolve('leaf data') + await load + + expect(find(leafRoute.id)).toBe('success') + }) + + test('a branch with nothing left to load commits without offering a boundary', async () => { + const rootRoute = new BaseRootRoute({ component: () => null }) + const indexRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => null, + }) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + id: '_layout', + pendingComponent: () => null, + }) + const layoutChunk = + createControlledPromise< + Awaited[0]>> + >() + layoutRoute.lazy(() => layoutChunk) + + const leafRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/leaf', + component: () => null, + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + layoutRoute.addChildren([leafRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 0, + }) + + await router.load() + const navigation = router.navigate({ to: '/leaf' }) + + await vi.waitFor(() => + expect( + router.state.matches.find((match) => match.routeId === layoutRoute.id) + ?.status, + ).toBe('pending'), + ) + + layoutChunk.resolve({ + options: { id: layoutRoute.id, component: () => null }, + }) + + // The leaf has no loader, so once the layout settles no match is left to + // offer and the branch commits without another boundary. + await navigation + expect( + router.state.matches.map((match) => [match.routeId, match.status]), + ).toEqual([ + [rootRoute.id, 'success'], + [layoutRoute.id, 'success'], + [leafRoute.id, 'success'], + ]) + }) }) From dc59eb9df4d3241998f04f7576703a1bced544a9 Mon Sep 17 00:00:00 2001 From: Gianmarco Rengucci Date: Sun, 20 Sep 2026 14:01:58 +0200 Subject: [PATCH 5/5] test(react-router): use fake timers for pending-min hold assertion Drive the painted hold test with a controlled clock instead of a real sleep so a delayed event loop cannot expire the minimum window before the hold assertion runs. --- ...pending-boundary-settled-ancestor.test.tsx | 50 +++++++++++-------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx b/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx index dfa00d01547..8b2df14fead 100644 --- a/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx +++ b/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, expect, test, vi } from 'vitest' import { Outlet, @@ -103,6 +103,7 @@ test('a settled layout renders its shell while its leaf is still loading', async // A painted fallback holds through its minimum window before the boundary // moves to the leaf that is still loading. test('a painted fallback holds its minimum before the shell renders', async () => { + vi.useFakeTimers() const headerOptions = createLazyRoute('/users/$userId')({ component: () => (
@@ -155,38 +156,47 @@ test('a painted fallback holds its minimum before the shell renders', async () = defaultPendingMinMs: 400, }) + await router.load() render() - await waitFor(() => expect(screen.getByText('User list')).toBeInTheDocument()) - - const navigation = router.navigate({ - to: '/users/$userId', - params: { userId: 'u1' }, + expect(screen.getByText('User list')).toBeInTheDocument() + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ + to: '/users/$userId', + params: { userId: 'u1' }, + }) + await vi.advanceTimersByTimeAsync(0) }) - - await waitFor(() => expect(screen.getByRole('status')).toBeInTheDocument()) + expect(screen.getByRole('status')).toBeInTheDocument() expect(screen.queryByText('Header shell')).not.toBeInTheDocument() - headerChunk.resolve(headerOptions) + await act(async () => { + headerChunk.resolve(headerOptions) + await vi.advanceTimersByTimeAsync(0) + }) // The layout settled, but the painted fallback holds its minimum window. - await new Promise((resolve) => setTimeout(resolve, 100)) + await act(async () => { + await vi.advanceTimersByTimeAsync(100) + }) expect(screen.getByRole('status')).toBeInTheDocument() expect(screen.queryByText('Header shell')).not.toBeInTheDocument() // Once the minimum elapses the shell renders with the leaf still pending. - await waitFor( - () => expect(screen.getByText('Header shell')).toBeInTheDocument(), - { timeout: 5000 }, - ) + await act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + expect(screen.getByText('Header shell')).toBeInTheDocument() expect(screen.getByRole('status')).toBeInTheDocument() expect(screen.queryByText('User detail')).not.toBeInTheDocument() - detailLoader.resolve('detail data') - await navigation - - await waitFor(() => - expect(screen.getByText('User detail')).toBeInTheDocument(), - ) + await act(async () => { + detailLoader.resolve('detail data') + await vi.advanceTimersByTimeAsync(400) + await navigation + }) + expect(screen.getByText('User detail')).toBeInTheDocument() expect(screen.getByText('Header shell')).toBeInTheDocument() })