From a0fc7c08e457d14cf02f5730385cab942b83fa5f Mon Sep 17 00:00:00 2001 From: Gianmarco Rengucci Date: Fri, 18 Sep 2026 10:04:49 +0200 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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() }) From ab588f1d7fc6873a29957c1e34cfb725cb4e48de Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 25 Sep 2026 17:13:39 +0200 Subject: [PATCH 06/12] test: tighten settled pending boundary timing coverage --- ...pending-boundary-settled-ancestor.test.tsx | 12 ++- .../tests/pending-boundary-scan.bench.ts | 84 +++++++++++++++++++ .../pending-boundary-settled-ancestor.test.ts | 34 ++++---- 3 files changed, 109 insertions(+), 21 deletions(-) create mode 100644 packages/router-core/tests/pending-boundary-scan.bench.ts 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 8b2df14fead..4f8ee93a30a 100644 --- a/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx +++ b/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx @@ -178,14 +178,14 @@ test('a painted fallback holds its minimum before the shell renders', async () = // The layout settled, but the painted fallback holds its minimum window. await act(async () => { - await vi.advanceTimersByTimeAsync(100) + await vi.advanceTimersByTimeAsync(399) }) 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 act(async () => { - await vi.advanceTimersByTimeAsync(300) + await vi.advanceTimersByTimeAsync(1) }) expect(screen.getByText('Header shell')).toBeInTheDocument() expect(screen.getByRole('status')).toBeInTheDocument() @@ -193,7 +193,13 @@ test('a painted fallback holds its minimum before the shell renders', async () = await act(async () => { detailLoader.resolve('detail data') - await vi.advanceTimersByTimeAsync(400) + await vi.advanceTimersByTimeAsync(399) + }) + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('User detail')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) await navigation }) expect(screen.getByText('User detail')).toBeInTheDocument() diff --git a/packages/router-core/tests/pending-boundary-scan.bench.ts b/packages/router-core/tests/pending-boundary-scan.bench.ts new file mode 100644 index 00000000000..2a3e5045e27 --- /dev/null +++ b/packages/router-core/tests/pending-boundary-scan.bench.ts @@ -0,0 +1,84 @@ +import { bench, describe, expect } from 'vitest' + +type Match = { status: string; _notFound?: boolean } + +// Isolate the settled-ancestor selection mechanism from navigation, timers, +// and rendering. Both versions must select the same boundary before timing. +function original(matches: Array, painted: number) { + for (let index = 0; index < matches.length; index++) { + const match = matches[index]! + if (match.status === 'success' && !match._notFound) { + 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 || index !== painted) { + continue + } + } + return index + } + return -1 +} + +function candidate(matches: Array, painted: number) { + for (let index = 0; index < matches.length; index++) { + const match = matches[index]! + if (match.status === 'success' && !match._notFound) { + if ( + index !== painted || + matches.some( + (descendant) => + descendant.status !== 'success' || descendant._notFound, + ) + ) { + continue + } + } + return index + } + return -1 +} + +for (const depth of [4, 8, 32]) { + const settled = Array.from({ length: depth }, () => ({ status: 'success' })) + const pending = [...settled.slice(0, -1), { status: 'pending' }] + const notFound = [ + ...settled.slice(0, -1), + { status: 'success', _notFound: true }, + ] + const cases: Array<[string, Array, number, number]> = [ + ['unpainted settled ancestors', pending, -1, depth - 1], + ['painted ancestor with pending leaf', pending, 1, depth - 1], + ['painted ancestor with not-found leaf', notFound, 1, depth - 1], + ['terminal painted success', settled, depth - 1, depth - 1], + ['all settled and unpainted', settled, -1, -1], + ['unresolved root', [{ status: 'pending' }, ...settled], 0, 0], + ] + for (const [name, matches, painted, expected] of cases) { + expect(original(matches, painted)).toBe(expected) + expect(candidate(matches, painted)).toBe(expected) + describe(`${depth} matches: ${name}`, () => { + for (const [label, select] of [ + ['original', original], + ['candidate', candidate], + ] as const) { + bench( + label, + () => { + let result = 0 + for (let iteration = 0; iteration < 128; iteration++) { + result += select(matches, painted) + } + return result + }, + { time: 300, warmupTime: 100 }, + ) + } + }) + } +} 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 007c1fcc6d4..fdf036d2dcb 100644 --- a/packages/router-core/tests/pending-boundary-settled-ancestor.test.ts +++ b/packages/router-core/tests/pending-boundary-settled-ancestor.test.ts @@ -155,6 +155,7 @@ describe('pending boundary must advance past settled ancestors', () => { }) test('a boundary that never paints does not hold the presented snapshot', async () => { + vi.useFakeTimers() const leafLoader = createControlledPromise() const rootRoute = new BaseRootRoute({ component: () => null }) @@ -197,12 +198,11 @@ describe('pending boundary must advance past settled ancestors', () => { 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'), - ) + await vi.advanceTimersByTimeAsync(0) + expect( + router.state.matches.find((match) => match.routeId === layoutRoute.id) + ?.status, + ).toBe('pending') layoutChunk.resolve({ options: { id: layoutRoute.id, component: () => null }, @@ -210,15 +210,14 @@ describe('pending boundary must advance past settled ancestors', () => { // 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]), - ).toEqual([ - [rootRoute.id, 'success'], - [layoutRoute.id, 'success'], - [leafRoute.id, 'pending'], - ]), - ) + await vi.advanceTimersByTimeAsync(0) + 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 @@ -284,7 +283,7 @@ describe('pending boundary must advance past settled ancestors', () => { expect(find(leafRoute.id)).toBe('success') }) - test('a branch with nothing left to load commits without offering a boundary', async () => { + test('a branch with nothing left to load commits successfully', async () => { const rootRoute = new BaseRootRoute({ component: () => null }) const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, @@ -332,8 +331,7 @@ describe('pending boundary must advance past settled ancestors', () => { 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. + // The leaf has no loader, so settling the layout completes the navigation. await navigation expect( router.state.matches.map((match) => [match.routeId, match.status]), From b6e9e0fa6e659f93ad32dc2e7d6e089108f08343 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 25 Sep 2026 17:14:49 +0200 Subject: [PATCH 07/12] test: keep boundary benchmark callback void --- packages/router-core/tests/pending-boundary-scan.bench.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/router-core/tests/pending-boundary-scan.bench.ts b/packages/router-core/tests/pending-boundary-scan.bench.ts index 2a3e5045e27..da2f78c9ae6 100644 --- a/packages/router-core/tests/pending-boundary-scan.bench.ts +++ b/packages/router-core/tests/pending-boundary-scan.bench.ts @@ -74,7 +74,9 @@ for (const depth of [4, 8, 32]) { for (let iteration = 0; iteration < 128; iteration++) { result += select(matches, painted) } - return result + if (result !== expected * 128) { + throw new Error('Unexpected pending boundary') + } }, { time: 300, warmupTime: 100 }, ) From 143c88a96aa478d5725be52e7bcdd6862a90470e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 25 Sep 2026 17:29:25 +0200 Subject: [PATCH 08/12] test: cover canceled pending handoff and boundary scan workloads --- ...pending-boundary-settled-ancestor.test.tsx | 206 ++++++++++-------- .../tests/pending-boundary-scan.bench.ts | 36 ++- 2 files changed, 140 insertions(+), 102 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 4f8ee93a30a..992943c2277 100644 --- a/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx +++ b/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx @@ -102,109 +102,125 @@ 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: () => ( -
-

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

, - }) +test.each(['handover', 'navigate away'])( + 'a painted fallback holds its minimum before %s', + async (completion) => { + vi.useFakeTimers() + 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]), + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authRoute.addChildren([ + sidebarRoute.addChildren([listRoute]), + headerRoute.addChildren([detailRoute]), + ]), ]), - ]), - history: createMemoryHistory({ initialEntries: ['/users'] }), - defaultPendingComponent: () =>

Loading

, - defaultPendingMs: 0, - defaultPendingMinMs: 400, - }) - - await router.load() - render() - expect(screen.getByText('User list')).toBeInTheDocument() - - let navigation!: Promise - await act(async () => { - navigation = router.navigate({ - to: '/users/$userId', - params: { userId: 'u1' }, + history: createMemoryHistory({ initialEntries: ['/users'] }), + defaultPendingComponent: () =>

Loading

, + defaultPendingMs: 0, + defaultPendingMinMs: 400, }) - await vi.advanceTimersByTimeAsync(0) - }) - expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.queryByText('Header shell')).not.toBeInTheDocument() - await act(async () => { - headerChunk.resolve(headerOptions) - await vi.advanceTimersByTimeAsync(0) - }) + await router.load() + render() + 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) + }) + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('Header shell')).not.toBeInTheDocument() - // The layout settled, but the painted fallback holds its minimum window. - await act(async () => { - await vi.advanceTimersByTimeAsync(399) - }) - expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.queryByText('Header shell')).not.toBeInTheDocument() + await act(async () => { + headerChunk.resolve(headerOptions) + await vi.advanceTimersByTimeAsync(0) + }) - // Once the minimum elapses the shell renders with the leaf still pending. - await act(async () => { - await vi.advanceTimersByTimeAsync(1) - }) - expect(screen.getByText('Header shell')).toBeInTheDocument() - expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.queryByText('User detail')).not.toBeInTheDocument() + // The layout settled, but the painted fallback holds its minimum window. + await act(async () => { + await vi.advanceTimersByTimeAsync(399) + }) + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('Header shell')).not.toBeInTheDocument() + + if (completion === 'navigate away') { + await act(async () => { + await router.navigate({ to: '/users' }) + detailLoader.resolve('obsolete detail data') + await vi.advanceTimersByTimeAsync(401) + await navigation + }) + expect(screen.getByText('User list')).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.queryByText('Header shell')).not.toBeInTheDocument() + return + } + + // Once the minimum elapses the shell renders with the leaf still pending. + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(screen.getByText('Header shell')).toBeInTheDocument() + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('User detail')).not.toBeInTheDocument() - await act(async () => { - detailLoader.resolve('detail data') - await vi.advanceTimersByTimeAsync(399) - }) - expect(screen.getByRole('status')).toBeInTheDocument() - expect(screen.queryByText('User detail')).not.toBeInTheDocument() + await act(async () => { + detailLoader.resolve('detail data') + await vi.advanceTimersByTimeAsync(399) + }) + expect(screen.getByRole('status')).toBeInTheDocument() + expect(screen.queryByText('User detail')).not.toBeInTheDocument() - await act(async () => { - await vi.advanceTimersByTimeAsync(1) - await navigation - }) - expect(screen.getByText('User detail')).toBeInTheDocument() - expect(screen.getByText('Header shell')).toBeInTheDocument() -}) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + await navigation + }) + expect(screen.getByText('User detail')).toBeInTheDocument() + 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 () => { diff --git a/packages/router-core/tests/pending-boundary-scan.bench.ts b/packages/router-core/tests/pending-boundary-scan.bench.ts index da2f78c9ae6..60c99751726 100644 --- a/packages/router-core/tests/pending-boundary-scan.bench.ts +++ b/packages/router-core/tests/pending-boundary-scan.bench.ts @@ -29,15 +29,16 @@ function candidate(matches: Array, painted: number) { for (let index = 0; index < matches.length; index++) { const match = matches[index]! if (match.status === 'success' && !match._notFound) { - if ( - index !== painted || - matches.some( - (descendant) => - descendant.status !== 'success' || descendant._notFound, - ) - ) { + if (index !== painted) { continue } + for (let next = index + 1; next < matches.length; next++) { + const descendant = matches[next]! + if (descendant.status !== 'success' || descendant._notFound) { + index = next + break + } + } } return index } @@ -54,6 +55,7 @@ for (const depth of [4, 8, 32]) { const cases: Array<[string, Array, number, number]> = [ ['unpainted settled ancestors', pending, -1, depth - 1], ['painted ancestor with pending leaf', pending, 1, depth - 1], + ['painted boundary near pending leaf', pending, depth - 2, depth - 1], ['painted ancestor with not-found leaf', notFound, 1, depth - 1], ['terminal painted success', settled, depth - 1, depth - 1], ['all settled and unpainted', settled, -1, -1], @@ -83,4 +85,24 @@ for (const depth of [4, 8, 32]) { } }) } + describe(`${depth} matches: mixed presentations`, () => { + for (const [label, select] of [ + ['original', original], + ['candidate', candidate], + ] as const) { + bench( + label, + () => { + for (let iteration = 0; iteration < 128; iteration++) { + const [, matches, painted, expected] = + cases[iteration % cases.length]! + if (select(matches, painted) !== expected) { + throw new Error('Unexpected pending boundary') + } + } + }, + { time: 300, warmupTime: 100 }, + ) + } + }) } From e48d797fc6d8fadb6cf30bef4305bb9c9e44ec32 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 25 Sep 2026 17:51:45 +0200 Subject: [PATCH 09/12] perf(router-core): avoid repeated pending descendant scans --- packages/router-core/src/load-client.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index c64a9359c7e..be06c3da3ba 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -1461,19 +1461,17 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { ) let session = router._pending for (let index = 0; index < matches.length; index++) { - const match = matches[index]! - const presentedPending = + let match = matches[index]! + let 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. - // 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) { + if (!presentedPending) { + continue + } // 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) { + if (session?.[1 /* boundaryId */] === match.id) { const remaining = session[2 /* deadline */] - Date.now() if (remaining > 0) { session[0 /* generation */] = tx @@ -1489,17 +1487,17 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { // 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 + // Select the first unresolved descendant directly; the settled matches + // between it and this painted boundary do not need another scan. for (let next = index + 1; next < matches.length; next++) { const descendant = matches[next]! if (descendant.status !== 'success' || descendant._notFound) { - hasPendingDescendant = true + match = descendant + index = next + presentedPending = false break } } - if (hasPendingDescendant || !presentedPending) { - continue - } } const route = getRoute(router, match as WorkMatch) const delay = match.invalid From 874d3f32b2a87645d82282236295c603f419dc3b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 25 Sep 2026 18:27:02 +0200 Subject: [PATCH 10/12] test: cover pending reveal takeover and session bookkeeping --- ...public-presentation-lane-contract.test.tsx | 99 ++++++++ .../pending-session-bookkeeping.bench.ts | 219 ++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 packages/router-core/tests/pending-session-bookkeeping.bench.ts diff --git a/packages/react-router/tests/public-presentation-lane-contract.test.tsx b/packages/react-router/tests/public-presentation-lane-contract.test.tsx index 644c4887e65..168d1b6bc11 100644 --- a/packages/react-router/tests/public-presentation-lane-contract.test.tsx +++ b/packages/react-router/tests/public-presentation-lane-contract.test.tsx @@ -243,6 +243,105 @@ describe('public presentation lane contracts', () => { expect(screen.queryByText('Loading page')).not.toBeInTheDocument() }) + test.each(['same', 'different'])( + '%s-boundary takeover uses the correct reveal deadline before pending UI is painted', + async (boundary) => { + const firstGate = createControlledPromise() + const secondGate = createControlledPromise() + const rootRoute = createRootRoute({ component: Outlet }) + const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page/$id', + validateSearch: (search: Record) => ({ + revision: Number(search.revision), + }), + pendingMs: 100, + pendingMinMs: 0, + pendingComponent: () =>
Loading page
, + beforeLoad: ({ search }) => + search.revision === 1 ? firstGate : secondGate, + component: () => ( +
Page revision {pageRoute.useSearch().revision}
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([homeRoute, pageRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + render() + expect(await screen.findByText('Home')).toBeInTheDocument() + await waitFor(() => expect(router.state.status).toBe('idle')) + vi.useFakeTimers() + vi.setSystemTime(0) + + let successor!: Promise + try { + await act(async () => { + void router.navigate({ + to: '/page/$id', + params: { id: 'first' }, + search: { revision: 1 }, + }) + await vi.advanceTimersByTimeAsync(25) + }) + await act(async () => { + successor = router.navigate({ + to: '/page/$id', + params: { id: boundary === 'same' ? 'first' : 'second' }, + search: { revision: 2 }, + }) + await vi.advanceTimersByTimeAsync(74) + }) + expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.queryByText('Loading page')).not.toBeInTheDocument() + + if (boundary === 'different') { + await act(async () => { + await vi.advanceTimersByTimeAsync(25) + }) + expect(screen.getByText('Home')).toBeInTheDocument() + expect(screen.queryByText('Loading page')).not.toBeInTheDocument() + } + + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(screen.getByText('Loading page')).toBeInTheDocument() + expect(router.state.matches.at(-1)?.search).toMatchObject({ + revision: 2, + }) + + await act(async () => { + firstGate.resolve() + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByText('Loading page')).toBeInTheDocument() + expect(router.state.matches.at(-1)?.search).toMatchObject({ + revision: 2, + }) + + await act(async () => { + secondGate.resolve() + await vi.advanceTimersByTimeAsync(0) + await successor + }) + expect(screen.getByText('Page revision 2')).toBeInTheDocument() + expect(screen.queryByText('Loading page')).not.toBeInTheDocument() + } finally { + firstGate.resolve() + secondGate.resolve() + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + } + }, + ) + test('an earlier pending-ineligible boundary retires a deeper pending minimum', async () => { const childReloadStarted = createControlledPromise() const childReload = createControlledPromise() diff --git a/packages/router-core/tests/pending-session-bookkeeping.bench.ts b/packages/router-core/tests/pending-session-bookkeeping.bench.ts new file mode 100644 index 00000000000..5b9f7cf79e1 --- /dev/null +++ b/packages/router-core/tests/pending-session-bookkeeping.bench.ts @@ -0,0 +1,219 @@ +import { bench, describe, expect } from 'vitest' + +type Session = [ + object, + string, + number, + undefined, + boolean | Promise | undefined, + object, +] +type State = { pending?: Session; clears: number } +type Input = { + owner: object + id: string + component: object + painted: boolean +} + +// Isolate session selection/ownership from clocks and rendering. Clearing a +// timer is counted so equivalence includes retiring a replaced session. +function original(state: State, input: Input) { + let session = state.pending + let tookOver = false + if (session?.[1] === input.id) { + tookOver = session[0] !== input.owner + session[0] = input.owner + } else { + state.clears++ + state.pending = session = undefined + } + if (!session) { + state.pending = session = [ + input.owner, + input.id, + 100, + undefined, + input.painted || undefined, + input.component, + ] + } + if (session[4] && !tookOver && session[5] === input.component) { + return false + } + session[5] = input.component + return true +} + +function candidate(state: State, input: Input) { + let session = state.pending + if (session?.[1] !== input.id) { + state.clears++ + state.pending = session = [ + input.owner, + input.id, + 100, + undefined, + input.painted, + input.component, + ] + } + if ( + session[4] && + session[0] === input.owner && + session[5] === input.component + ) { + return false + } + session[0] = input.owner + session[5] = input.component + return true +} + +const owner = {} +const successor = {} +const component = {} +const lazyComponent = {} +const ack = Promise.resolve(true) +const input: Input = { owner, id: '/page', component, painted: false } +const cases: Array<[string, Session | undefined, Input]> = [ + ['new boundary', undefined, input], + ['hydrated painted boundary', undefined, { ...input, painted: true }], + [ + 'same owner acknowledged', + [owner, '/page', 100, undefined, true, component], + input, + ], + [ + 'same owner awaiting reveal', + [owner, '/page', 100, undefined, undefined, component], + input, + ], + [ + 'successor acknowledged', + [successor, '/page', 100, undefined, true, component], + input, + ], + [ + 'successor awaiting reveal', + [successor, '/page', 100, undefined, undefined, component], + input, + ], + [ + 'lazy fallback replacement', + [owner, '/page', 100, undefined, true, lazyComponent], + input, + ], + [ + 'same owner explicit false ack', + [owner, '/page', 100, undefined, false, component], + input, + ], + [ + 'same owner render acknowledgement', + [owner, '/page', 100, undefined, ack, component], + input, + ], + [ + 'successor render acknowledgement', + [successor, '/page', 100, undefined, ack, component], + input, + ], + [ + 'different boundary', + [owner, '/other', 100, undefined, true, component], + input, + ], +] + +for (const [, session, value] of cases) { + const before: State = { + pending: session?.slice() as Session | undefined, + clears: 0, + } + const after: State = { + pending: session?.slice() as Session | undefined, + clears: 0, + } + expect(candidate(after, value)).toBe(original(before, value)) + expect(after.clears).toBe(before.clears) + expect(after.pending?.slice(0, 4)).toEqual(before.pending?.slice(0, 4)) + expect(Boolean(after.pending?.[4])).toBe(Boolean(before.pending?.[4])) + expect(after.pending?.[5]).toBe(before.pending?.[5]) + if (session?.[4] instanceof Promise) { + expect(after.pending?.[4]).toBe(session[4]) + expect(before.pending?.[4]).toBe(session[4]) + } + expect(after.pending?.[0]).toBe(value.owner) +} + +type Fixture = { + state: State + session: Session | undefined + owner: object | undefined + component: object | undefined + input: Input +} + +// Separate hot call sites avoid comparing a monomorphic first callback with +// a polymorphic second callback. Each loop directly calls its implementation. +function batchOriginal(fixtures: Array) { + let offers = 0 + for (let iteration = 0; iteration < 128; iteration++) { + const fixture = fixtures[iteration % fixtures.length]! + if (fixture.session) { + fixture.session[0] = fixture.owner! + fixture.session[5] = fixture.component! + } + fixture.state.pending = fixture.session + offers += Number(original(fixture.state, fixture.input)) + } + return offers +} + +function batchCandidate(fixtures: Array) { + let offers = 0 + for (let iteration = 0; iteration < 128; iteration++) { + const fixture = fixtures[iteration % fixtures.length]! + if (fixture.session) { + fixture.session[0] = fixture.owner! + fixture.session[5] = fixture.component! + } + fixture.state.pending = fixture.session + offers += Number(candidate(fixture.state, fixture.input)) + } + return offers +} + +for (const [name, distributions] of [ + ...cases.map((entry) => [entry[0], [entry]] as const), + ['mixed sessions', cases] as const, +]) { + describe(name, () => { + for (const [label, batch] of [ + ['candidate', batchCandidate], + ['original', batchOriginal], + ] as const) { + const fixtures: Array = distributions.map( + ([, session, value]) => ({ + state: { pending: undefined, clears: 0 }, + session: session?.slice() as Session | undefined, + owner: session?.[0], + component: session?.[5], + input: value, + }), + ) + const expected = batchOriginal(fixtures) + expect(batchCandidate(fixtures)).toBe(expected) + bench( + label, + () => { + if (batch(fixtures) !== expected) { + throw new Error('Unexpected offer count') + } + }, + { time: 500, warmupTime: 200 }, + ) + } + }) +} From a33788f67f56556182db3a94289b010b09d3a028 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 25 Sep 2026 18:34:48 +0200 Subject: [PATCH 11/12] perf(router-core): simplify pending session bookkeeping --- packages/router-core/src/load-client.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index be06c3da3ba..af14ef832c0 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -234,7 +234,7 @@ export type PendingSession = [ /** Pending reveal time until acknowledged, then minimum-visible-until time. */ deadline: number, revealTimer?: ReturnType, - ack?: Promise | true, + ack?: Promise | boolean, component?: unknown, ] @@ -1516,15 +1516,8 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { } const min = route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0 - let tookOver = false - if (session?.[1 /* boundaryId */] === match.id) { - tookOver = session[0 /* generation */] !== tx - session[0 /* generation */] = tx - } else { + if (session?.[1 /* boundaryId */] !== match.id) { clearTimeout(session?.[3 /* revealTimer */]) - router._pending = session = undefined - } - if (!session) { // Hydration and redirects can preserve pending presentation without a session. // Do not delay it again; conservatively start pendingMinMs from now. router._pending = session = [ @@ -1532,17 +1525,19 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { match.id, presentedPending ? Date.now() + min : tx[4 /* startedAt */] + delay, undefined, - presentedPending || undefined, + presentedPending, component, ] } + // A successor or replacement fallback must publish its own offer. if ( session[4 /* ack */] && - !tookOver && + session[0 /* generation */] === tx && session[5 /* component */] === component ) { return } + session[0 /* generation */] = tx session[5 /* component */] = component if (!session[4 /* ack */]) { clearTimeout(session[3 /* revealTimer */]) From c60add15cbe03ab7240818c5118805d11ee2b45d Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 25 Sep 2026 19:35:06 +0200 Subject: [PATCH 12/12] test: remove pending boundary optimization benchmarks --- .../tests/pending-boundary-scan.bench.ts | 108 --------- .../pending-session-bookkeeping.bench.ts | 219 ------------------ 2 files changed, 327 deletions(-) delete mode 100644 packages/router-core/tests/pending-boundary-scan.bench.ts delete mode 100644 packages/router-core/tests/pending-session-bookkeeping.bench.ts diff --git a/packages/router-core/tests/pending-boundary-scan.bench.ts b/packages/router-core/tests/pending-boundary-scan.bench.ts deleted file mode 100644 index 60c99751726..00000000000 --- a/packages/router-core/tests/pending-boundary-scan.bench.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { bench, describe, expect } from 'vitest' - -type Match = { status: string; _notFound?: boolean } - -// Isolate the settled-ancestor selection mechanism from navigation, timers, -// and rendering. Both versions must select the same boundary before timing. -function original(matches: Array, painted: number) { - for (let index = 0; index < matches.length; index++) { - const match = matches[index]! - if (match.status === 'success' && !match._notFound) { - 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 || index !== painted) { - continue - } - } - return index - } - return -1 -} - -function candidate(matches: Array, painted: number) { - for (let index = 0; index < matches.length; index++) { - const match = matches[index]! - if (match.status === 'success' && !match._notFound) { - if (index !== painted) { - continue - } - for (let next = index + 1; next < matches.length; next++) { - const descendant = matches[next]! - if (descendant.status !== 'success' || descendant._notFound) { - index = next - break - } - } - } - return index - } - return -1 -} - -for (const depth of [4, 8, 32]) { - const settled = Array.from({ length: depth }, () => ({ status: 'success' })) - const pending = [...settled.slice(0, -1), { status: 'pending' }] - const notFound = [ - ...settled.slice(0, -1), - { status: 'success', _notFound: true }, - ] - const cases: Array<[string, Array, number, number]> = [ - ['unpainted settled ancestors', pending, -1, depth - 1], - ['painted ancestor with pending leaf', pending, 1, depth - 1], - ['painted boundary near pending leaf', pending, depth - 2, depth - 1], - ['painted ancestor with not-found leaf', notFound, 1, depth - 1], - ['terminal painted success', settled, depth - 1, depth - 1], - ['all settled and unpainted', settled, -1, -1], - ['unresolved root', [{ status: 'pending' }, ...settled], 0, 0], - ] - for (const [name, matches, painted, expected] of cases) { - expect(original(matches, painted)).toBe(expected) - expect(candidate(matches, painted)).toBe(expected) - describe(`${depth} matches: ${name}`, () => { - for (const [label, select] of [ - ['original', original], - ['candidate', candidate], - ] as const) { - bench( - label, - () => { - let result = 0 - for (let iteration = 0; iteration < 128; iteration++) { - result += select(matches, painted) - } - if (result !== expected * 128) { - throw new Error('Unexpected pending boundary') - } - }, - { time: 300, warmupTime: 100 }, - ) - } - }) - } - describe(`${depth} matches: mixed presentations`, () => { - for (const [label, select] of [ - ['original', original], - ['candidate', candidate], - ] as const) { - bench( - label, - () => { - for (let iteration = 0; iteration < 128; iteration++) { - const [, matches, painted, expected] = - cases[iteration % cases.length]! - if (select(matches, painted) !== expected) { - throw new Error('Unexpected pending boundary') - } - } - }, - { time: 300, warmupTime: 100 }, - ) - } - }) -} diff --git a/packages/router-core/tests/pending-session-bookkeeping.bench.ts b/packages/router-core/tests/pending-session-bookkeeping.bench.ts deleted file mode 100644 index 5b9f7cf79e1..00000000000 --- a/packages/router-core/tests/pending-session-bookkeeping.bench.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { bench, describe, expect } from 'vitest' - -type Session = [ - object, - string, - number, - undefined, - boolean | Promise | undefined, - object, -] -type State = { pending?: Session; clears: number } -type Input = { - owner: object - id: string - component: object - painted: boolean -} - -// Isolate session selection/ownership from clocks and rendering. Clearing a -// timer is counted so equivalence includes retiring a replaced session. -function original(state: State, input: Input) { - let session = state.pending - let tookOver = false - if (session?.[1] === input.id) { - tookOver = session[0] !== input.owner - session[0] = input.owner - } else { - state.clears++ - state.pending = session = undefined - } - if (!session) { - state.pending = session = [ - input.owner, - input.id, - 100, - undefined, - input.painted || undefined, - input.component, - ] - } - if (session[4] && !tookOver && session[5] === input.component) { - return false - } - session[5] = input.component - return true -} - -function candidate(state: State, input: Input) { - let session = state.pending - if (session?.[1] !== input.id) { - state.clears++ - state.pending = session = [ - input.owner, - input.id, - 100, - undefined, - input.painted, - input.component, - ] - } - if ( - session[4] && - session[0] === input.owner && - session[5] === input.component - ) { - return false - } - session[0] = input.owner - session[5] = input.component - return true -} - -const owner = {} -const successor = {} -const component = {} -const lazyComponent = {} -const ack = Promise.resolve(true) -const input: Input = { owner, id: '/page', component, painted: false } -const cases: Array<[string, Session | undefined, Input]> = [ - ['new boundary', undefined, input], - ['hydrated painted boundary', undefined, { ...input, painted: true }], - [ - 'same owner acknowledged', - [owner, '/page', 100, undefined, true, component], - input, - ], - [ - 'same owner awaiting reveal', - [owner, '/page', 100, undefined, undefined, component], - input, - ], - [ - 'successor acknowledged', - [successor, '/page', 100, undefined, true, component], - input, - ], - [ - 'successor awaiting reveal', - [successor, '/page', 100, undefined, undefined, component], - input, - ], - [ - 'lazy fallback replacement', - [owner, '/page', 100, undefined, true, lazyComponent], - input, - ], - [ - 'same owner explicit false ack', - [owner, '/page', 100, undefined, false, component], - input, - ], - [ - 'same owner render acknowledgement', - [owner, '/page', 100, undefined, ack, component], - input, - ], - [ - 'successor render acknowledgement', - [successor, '/page', 100, undefined, ack, component], - input, - ], - [ - 'different boundary', - [owner, '/other', 100, undefined, true, component], - input, - ], -] - -for (const [, session, value] of cases) { - const before: State = { - pending: session?.slice() as Session | undefined, - clears: 0, - } - const after: State = { - pending: session?.slice() as Session | undefined, - clears: 0, - } - expect(candidate(after, value)).toBe(original(before, value)) - expect(after.clears).toBe(before.clears) - expect(after.pending?.slice(0, 4)).toEqual(before.pending?.slice(0, 4)) - expect(Boolean(after.pending?.[4])).toBe(Boolean(before.pending?.[4])) - expect(after.pending?.[5]).toBe(before.pending?.[5]) - if (session?.[4] instanceof Promise) { - expect(after.pending?.[4]).toBe(session[4]) - expect(before.pending?.[4]).toBe(session[4]) - } - expect(after.pending?.[0]).toBe(value.owner) -} - -type Fixture = { - state: State - session: Session | undefined - owner: object | undefined - component: object | undefined - input: Input -} - -// Separate hot call sites avoid comparing a monomorphic first callback with -// a polymorphic second callback. Each loop directly calls its implementation. -function batchOriginal(fixtures: Array) { - let offers = 0 - for (let iteration = 0; iteration < 128; iteration++) { - const fixture = fixtures[iteration % fixtures.length]! - if (fixture.session) { - fixture.session[0] = fixture.owner! - fixture.session[5] = fixture.component! - } - fixture.state.pending = fixture.session - offers += Number(original(fixture.state, fixture.input)) - } - return offers -} - -function batchCandidate(fixtures: Array) { - let offers = 0 - for (let iteration = 0; iteration < 128; iteration++) { - const fixture = fixtures[iteration % fixtures.length]! - if (fixture.session) { - fixture.session[0] = fixture.owner! - fixture.session[5] = fixture.component! - } - fixture.state.pending = fixture.session - offers += Number(candidate(fixture.state, fixture.input)) - } - return offers -} - -for (const [name, distributions] of [ - ...cases.map((entry) => [entry[0], [entry]] as const), - ['mixed sessions', cases] as const, -]) { - describe(name, () => { - for (const [label, batch] of [ - ['candidate', batchCandidate], - ['original', batchOriginal], - ] as const) { - const fixtures: Array = distributions.map( - ([, session, value]) => ({ - state: { pending: undefined, clears: 0 }, - session: session?.slice() as Session | undefined, - owner: session?.[0], - component: session?.[5], - input: value, - }), - ) - const expected = batchOriginal(fixtures) - expect(batchCandidate(fixtures)).toBe(expected) - bench( - label, - () => { - if (batch(fixtures) !== expected) { - throw new Error('Unexpected offer count') - } - }, - { time: 500, warmupTime: 200 }, - ) - } - }) -}