Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/brown-kings-listen.md
Original file line number Diff line number Diff line change
@@ -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.
287 changes: 287 additions & 0 deletions packages/react-router/tests/pending-boundary-settled-ancestor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
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: () => (
<div>
<h1>Header shell</h1>
<Outlet />
</div>
),
})
const headerChunk = createControlledPromise<typeof headerOptions>()
const detailLoader = createControlledPromise<string>()

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: () => <p>User list</p>,
})
const detailRoute = createRoute({
getParentRoute: () => headerRoute,
path: '/users/$userId',
loader: () => detailLoader,
component: () => <p>User detail</p>,
})

const router = createRouter({
routeTree: rootRoute.addChildren([
authRoute.addChildren([
sidebarRoute.addChildren([listRoute]),
headerRoute.addChildren([detailRoute]),
]),
]),
history: createMemoryHistory({ initialEntries: ['/users'] }),
defaultPendingComponent: () => <p role="status">Loading</p>,
defaultPendingMs: 0,
defaultPendingMinMs: 0,
})

render(<RouterProvider router={router} />)
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('a painted fallback holds its minimum before the shell renders', async () => {
vi.useFakeTimers()
const headerOptions = createLazyRoute('/users/$userId')({
component: () => (
<div>
<h1>Header shell</h1>
<Outlet />
</div>
),
})
const headerChunk = createControlledPromise<typeof headerOptions>()
const detailLoader = createControlledPromise<string>()

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: () => <p>User list</p>,
})
const detailRoute = createRoute({
getParentRoute: () => headerRoute,
path: '/users/$userId',
loader: () => detailLoader,
component: () => <p>User detail</p>,
})

const router = createRouter({
routeTree: rootRoute.addChildren([
authRoute.addChildren([
sidebarRoute.addChildren([listRoute]),
headerRoute.addChildren([detailRoute]),
]),
]),
history: createMemoryHistory({ initialEntries: ['/users'] }),
defaultPendingComponent: () => <p role="status">Loading</p>,
defaultPendingMs: 0,
defaultPendingMinMs: 400,
})

await router.load()
render(<RouterProvider router={router} />)
expect(screen.getByText('User list')).toBeInTheDocument()

let navigation!: Promise<void>
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(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 act(async () => {
await vi.advanceTimersByTimeAsync(300)
})
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(400)
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: () => (
<div>
<h1>Header shell</h1>
<Outlet />
</div>
),
})
const headerChunk = createControlledPromise<typeof headerOptions>()
const detailLoader = createControlledPromise<string>()

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: () => <p>Header loading</p>,
}).lazy(() => headerChunk)

const listRoute = createRoute({
getParentRoute: () => sidebarRoute,
path: '/users',
component: () => <p>User list</p>,
})
const detailRoute = createRoute({
getParentRoute: () => headerRoute,
path: '/users/$userId',
loader: () => detailLoader,
pendingComponent: () => <p>Leaf loading</p>,
component: () => <p>User detail</p>,
})

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

render(<RouterProvider router={router} />)
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()
})
55 changes: 46 additions & 9 deletions packages/router-core/src/load-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1453,21 +1453,58 @@ 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
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) {
// 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
// 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 =
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
Expand Down
Loading
Loading