diff --git a/.changeset/brown-kings-listen.md b/.changeset/brown-kings-listen.md
new file mode 100644
index 00000000000..317eed597cf
--- /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 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
new file mode 100644
index 00000000000..992943c2277
--- /dev/null
+++ b/packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx
@@ -0,0 +1,309 @@
+import { act, 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()
+})
+
+// A painted fallback holds through its minimum window before the boundary
+// moves to the leaf that is still loading.
+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]),
+ ]),
+ ]),
+ 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' },
+ })
+ 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)
+ })
+
+ // 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 () => {
+ 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 () => {
+ 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/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/src/load-client.ts b/packages/router-core/src/load-client.ts
index ea4f2704471..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,
]
@@ -1453,21 +1453,56 @@ 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) {
- continue
+ let match = matches[index]!
+ let presentedPending =
+ index === paintedBoundary && presented[index]?.id === match.id
+ 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 (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
+ // needs its pending phase) falls through and is offered instead.
+ // 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) {
+ match = descendant
+ index = next
+ presentedPending = false
+ break
+ }
+ }
}
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
@@ -1481,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 = [
@@ -1497,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 */])
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..fdf036d2dcb
--- /dev/null
+++ b/packages/router-core/tests/pending-boundary-settled-ancestor.test.ts
@@ -0,0 +1,344 @@
+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<
+ Awaited[0]>>
+ >()
+ 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('a boundary that never paints does not hold the presented snapshot', async () => {
+ vi.useFakeTimers()
+ 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<
+ Awaited[0]>>
+ >()
+ 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,
+ })
+ // 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' })
+
+ 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 },
+ })
+
+ // 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.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
+ })
+
+ 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 successfully', 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 settling the layout completes the navigation.
+ await navigation
+ expect(
+ router.state.matches.map((match) => [match.routeId, match.status]),
+ ).toEqual([
+ [rootRoute.id, 'success'],
+ [layoutRoute.id, 'success'],
+ [leafRoute.id, 'success'],
+ ])
+ })
+})