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'], + ]) + }) +})