diff --git a/.changeset/clean-eagles-open.md b/.changeset/clean-eagles-open.md
new file mode 100644
index 00000000000..9d7554e7b0f
--- /dev/null
+++ b/.changeset/clean-eagles-open.md
@@ -0,0 +1,8 @@
+---
+'@tanstack/react-router': patch
+'@tanstack/solid-router': patch
+'@tanstack/router-core': patch
+'@tanstack/vue-router': patch
+---
+
+preserve pending UI across retained routes
diff --git a/e2e/solid-start/basic/tests/navigation.spec.ts b/e2e/solid-start/basic/tests/navigation.spec.ts
index ed0b0d3a4d0..058eb0cd035 100644
--- a/e2e/solid-start/basic/tests/navigation.spec.ts
+++ b/e2e/solid-start/basic/tests/navigation.spec.ts
@@ -50,7 +50,7 @@ test('client side navigating to a route with scripts', async ({ page }) => {
await page.waitForURL('/')
await page.getByRole('link', { name: 'Scripts', exact: true }).click()
await expect(page.getByTestId('scripts-test-heading')).toBeInViewport()
- expect(await page.evaluate('window.SCRIPT_1')).toBe(true)
+ await page.waitForFunction(() => (window as any).SCRIPT_1 === true)
expect(await page.evaluate('window.SCRIPT_2')).toBe(undefined)
})
diff --git a/packages/react-router/tests/hydration-terminal-lane.test.tsx b/packages/react-router/tests/hydration-terminal-lane.test.tsx
index 35784ea916e..c53dc62ae33 100644
--- a/packages/react-router/tests/hydration-terminal-lane.test.tsx
+++ b/packages/react-router/tests/hydration-terminal-lane.test.tsx
@@ -1,4 +1,4 @@
-import { cleanup, render, screen } from '@testing-library/react'
+import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { hydrate } from '@tanstack/router-core/ssr/client'
import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id'
@@ -20,18 +20,20 @@ function bootstrap(
ssr: AnyRouteMatch['ssr']
data?: unknown
error?: unknown
+ notFound?: boolean
}>,
): void {
window.$_TSR = {
router: {
manifest: undefined,
- matches: matches.map(({ match, status, ssr, data, error }) => ({
+ matches: matches.map(({ match, status, ssr, data, error, notFound }) => ({
i: dehydrateSsrMatchId(match.id),
l: data,
e: error,
s: status,
ssr,
u: Date.now(),
+ ...(notFound ? { g: true } : {}),
})),
},
h: vi.fn(),
@@ -44,6 +46,7 @@ function bootstrap(
afterEach(() => {
cleanup()
+ vi.useRealTimers()
delete window.$_TSR
})
@@ -95,4 +98,44 @@ describe('hydration terminal lane', () => {
expect(parentLoader).not.toHaveBeenCalled()
expect(childLoader).toHaveBeenCalledTimes(1)
})
+
+ test('keeps a hydrated pending fallback through its minimum before a terminal result', async () => {
+ const rootRoute = createRootRoute({
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () =>
Missing page pending
,
+ notFoundComponent: () => Missing page
,
+ })
+ const router = createRouter({
+ history: createMemoryHistory({ initialEntries: ['/missing'] }),
+ routeTree: rootRoute,
+ })
+ const matches = router.matchRoutes(router.state.location)
+ expect(matches[0]?._notFound).toBe(true)
+ bootstrap([
+ {
+ match: matches[0]!,
+ status: 'pending',
+ ssr: false,
+ notFound: true,
+ },
+ ])
+
+ await hydrate(router)
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+ render()
+ expect(screen.getByRole('status')).toHaveTextContent('Missing page pending')
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(99)
+ })
+ expect(screen.getByRole('status')).toHaveTextContent('Missing page pending')
+ expect(screen.queryByText('Missing page')).not.toBeInTheDocument()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5)
+ })
+ expect(screen.getByText('Missing page')).toBeInTheDocument()
+ })
})
diff --git a/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx b/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx
index ef85b2f2358..11e2c150cc9 100644
--- a/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx
+++ b/packages/react-router/tests/issue-4467-lazy-route-pending.test.tsx
@@ -13,7 +13,10 @@ import {
createRouter,
} from '../src'
-afterEach(cleanup)
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
// https://github.com/TanStack/router/issues/4467
test('default pending component renders while lazy route options load', async () => {
@@ -142,3 +145,78 @@ test('a lazy pending component is offered while the eager loader is still pendin
expect(screen.getByRole('heading', { name: 'Page' })).toBeInTheDocument()
})
+
+test('a lazy pending component does not restart an acknowledged minimum', async () => {
+ const loader = createControlledPromise()
+ const lazyPageOptions = createLazyRoute('/page')({
+ pendingComponent: () => Loading lazy page
,
+ component: () => Page
,
+ })
+ const lazyOptions = createControlledPromise()
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index page
,
+ })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ loader: () => loader,
+ }).lazy(() => lazyOptions)
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, pageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ defaultPendingMs: 0,
+ defaultPendingMinMs: 100,
+ defaultPendingComponent: () => Loading default
,
+ })
+
+ render()
+ expect(
+ await screen.findByRole('heading', { name: 'Index page' }),
+ ).toBeInTheDocument()
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ const navigation = router.navigate({ to: '/page' })
+ let settled = false
+ void navigation.then(() => {
+ settled = true
+ })
+ try {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0)
+ })
+ expect(screen.getByRole('status')).toHaveTextContent('Loading default')
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(25)
+ lazyOptions.resolve(lazyPageOptions)
+ loader.resolve()
+ await vi.advanceTimersByTimeAsync(0)
+ })
+ expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page')
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(74)
+ })
+ expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page')
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5)
+ await Promise.resolve()
+ })
+ expect(settled).toBe(true)
+ await navigation
+ expect(screen.getByRole('heading', { name: 'Page' })).toBeInTheDocument()
+ expect(Date.now()).toBeLessThan(125)
+ } finally {
+ lazyOptions.resolve(lazyPageOptions)
+ loader.resolve()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000)
+ await navigation
+ })
+ }
+})
diff --git a/packages/react-router/tests/issue-7367-pending-min-redirect.test.tsx b/packages/react-router/tests/issue-7367-pending-min-redirect.test.tsx
index 666f1d3e4bc..8edf32c1331 100644
--- a/packages/react-router/tests/issue-7367-pending-min-redirect.test.tsx
+++ b/packages/react-router/tests/issue-7367-pending-min-redirect.test.tsx
@@ -1,5 +1,6 @@
import * as React from 'react'
-import { cleanup, render, screen } from '@testing-library/react'
+import { act, cleanup, render, screen } from '@testing-library/react'
+import { createControlledPromise } from '@tanstack/router-core'
import { afterEach, expect, test, vi } from 'vitest'
import {
@@ -16,6 +17,7 @@ import { sleep } from './utils'
afterEach(() => {
vi.restoreAllMocks()
cleanup()
+ vi.useRealTimers()
})
// https://github.com/TanStack/router/issues/7367
@@ -76,3 +78,126 @@ test('immediate pending spinner (pendingMs: 0 + pendingMinMs) with root beforeLo
expect(router.state.location.pathname).toBe('/welcome')
expect(consoleError).not.toHaveBeenCalled()
})
+
+test('a compatible SPA redirect preserves the acknowledged pending minimum', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+ const redirectReady = createControlledPromise()
+ let shouldRedirect = true
+
+ const rootRoute = createRootRoute({
+ component: Outlet,
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => loading
,
+ beforeLoad: async () => {
+ if (shouldRedirect) {
+ shouldRedirect = false
+ await redirectReady
+ throw redirect({ to: '/welcome', replace: true })
+ }
+ },
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index
,
+ })
+ const welcomeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/welcome',
+ component: () => Welcome
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, welcomeRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ try {
+ render()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0)
+ })
+ expect(screen.getByTestId('pending')).toBeInTheDocument()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(25)
+ redirectReady.resolve()
+ await vi.advanceTimersByTimeAsync(74)
+ })
+ expect(screen.getByTestId('pending')).toBeInTheDocument()
+ expect(screen.queryByTestId('welcome-page')).not.toBeInTheDocument()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5)
+ })
+ expect(screen.getByTestId('welcome-page')).toBeInTheDocument()
+ } finally {
+ redirectReady.resolve()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000)
+ })
+ vi.useRealTimers()
+ }
+})
+
+test('an incompatible SPA redirect does not inherit the pending minimum', async () => {
+ const redirectReady = createControlledPromise()
+ const rootRoute = createRootRoute({ component: Outlet })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index
,
+ })
+ const sourceRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/source',
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => loading
,
+ beforeLoad: async () => {
+ await redirectReady
+ throw redirect({ to: '/welcome', replace: true })
+ },
+ })
+ const welcomeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/welcome',
+ component: () => Welcome
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, sourceRoute, welcomeRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+ expect(await screen.findByTestId('index-page')).toBeVisible()
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ const navigation = router.navigate({ to: '/source' })
+ try {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0)
+ })
+ expect(screen.getByTestId('pending')).toBeVisible()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(25)
+ redirectReady.resolve()
+ await vi.advanceTimersByTimeAsync(5)
+ await navigation
+ })
+
+ expect(Date.now()).toBeLessThan(100)
+ expect(screen.getByTestId('welcome-page')).toBeVisible()
+ expect(screen.queryByTestId('index-page')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
+ } finally {
+ redirectReady.resolve()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000)
+ await navigation
+ })
+ }
+})
diff --git a/packages/react-router/tests/issue-7986-retained-pending.test.tsx b/packages/react-router/tests/issue-7986-retained-pending.test.tsx
index 1d2dfdf5c8b..04d762df64e 100644
--- a/packages/react-router/tests/issue-7986-retained-pending.test.tsx
+++ b/packages/react-router/tests/issue-7986-retained-pending.test.tsx
@@ -3,6 +3,7 @@ import { afterEach, expect, test, vi } from 'vitest'
import {
Outlet,
RouterProvider,
+ createControlledPromise,
createLazyRoute,
createMemoryHistory,
createRootRoute,
@@ -437,10 +438,21 @@ test('a success hidden below an error boundary retries through pending UI', asyn
expect(screen.getByTestId('content')).toHaveTextContent('reloaded child')
})
-test('a global not-found destination does not retain the mounted root success', async () => {
+test('a global not-found destination keeps pending until its terminal component is ready', async () => {
const missingStarted = deferred()
const missingLoader = deferred()
+ const terminalStarted = deferred()
+ const terminalReady = deferred()
let loaderCalls = 0
+ const Missing = Object.assign(
+ () => Missing
,
+ {
+ preload: () => {
+ terminalStarted.resolve()
+ return terminalReady.promise
+ },
+ },
+ )
const rootRoute = createRootRoute({
shouldReload: true,
@@ -456,7 +468,7 @@ test('a global not-found destination does not retain the mounted root success',
},
component: Outlet,
pendingComponent: () => Pending root
,
- notFoundComponent: () => Missing
,
+ notFoundComponent: Missing,
})
const pageRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -482,8 +494,20 @@ test('a global not-found destination does not retain the mounted root success',
expect(await screen.findByTestId('pending')).toBeVisible()
expect(screen.getByTestId('content')).not.toBeVisible()
+ let settled = false
+ void navigation.then(() => {
+ settled = true
+ })
await act(async () => {
missingLoader.resolve()
+ await terminalStarted.promise
+ })
+ expect(screen.getByTestId('pending')).toBeVisible()
+ expect(screen.queryByTestId('missing')).not.toBeInTheDocument()
+ expect(settled).toBe(false)
+
+ await act(async () => {
+ terminalReady.resolve()
await navigation
})
@@ -491,6 +515,39 @@ test('a global not-found destination does not retain the mounted root success',
expect(screen.getByTestId('missing')).toBeVisible()
})
+test('a cold global not-found presents pending only while its terminal component loads', async () => {
+ const terminalStarted = deferred()
+ const terminalReady = deferred()
+ const Missing = Object.assign(
+ () => Missing
,
+ {
+ preload: () => {
+ terminalStarted.resolve()
+ return terminalReady.promise
+ },
+ },
+ )
+ const rootRoute = createRootRoute({
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => Pending root
,
+ notFoundComponent: Missing,
+ })
+ const router = createRouter({
+ routeTree: rootRoute,
+ history: createMemoryHistory({ initialEntries: ['/missing'] }),
+ })
+
+ render()
+ await terminalStarted.promise
+ expect(await screen.findByTestId('pending')).toBeVisible()
+ expect(screen.queryByTestId('missing')).not.toBeInTheDocument()
+
+ terminalReady.resolve()
+ expect(await screen.findByTestId('missing')).toBeVisible()
+ expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
+})
+
test('lazy fuzzy-boundary relocation retains the mounted parent', async () => {
const lazyStarted = deferred()
const lazyRoute = deferred()
@@ -656,3 +713,307 @@ test('a superseding navigation replaces an unrelated pending presentation', asyn
expect(screen.getByTestId('content')).toBeVisible()
expect(screen.getByTestId('content')).toHaveTextContent('reloaded page')
})
+
+test('a retained prefix exposes one fresh context chain before descendant pending', async () => {
+ const retainedStarted = deferred()
+ const retainedReady = deferred()
+ const pendingStarted = deferred()
+ const pendingReady = deferred()
+ let retainedLoads = 0
+
+ const rootRoute = createRootRoute({
+ beforeLoad: () => ({ rootReady: true }),
+ component: Outlet,
+ })
+ const aRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'a',
+ validateSearch: (search: Record): { user: string } => ({
+ user: typeof search.user === 'string' ? search.user : 'unknown',
+ }),
+ beforeLoad: async ({ search }) => {
+ if (++retainedLoads > 1) {
+ retainedStarted.resolve()
+ await retainedReady.promise
+ }
+ return { user: search.user }
+ },
+ component: () => (
+
+
{aRoute.useRouteContext().user}
+
+
+ ),
+ })
+ const bRoute = createRoute({
+ getParentRoute: () => aRoute,
+ path: 'b',
+ component: Outlet,
+ })
+ const cRoute = createRoute({
+ getParentRoute: () => bRoute,
+ path: 'c',
+ component: Outlet,
+ })
+ const dRoute = createRoute({
+ getParentRoute: () => cRoute,
+ path: 'd',
+ component: () => Source
,
+ })
+ const eRoute = createRoute({
+ getParentRoute: () => aRoute,
+ path: 'e',
+ component: () => (
+
+
{eRoute.useRouteContext().user}
+
+
+ ),
+ })
+ const fRoute = createRoute({
+ getParentRoute: () => eRoute,
+ path: 'f',
+ loader: async () => {
+ pendingStarted.resolve()
+ await pendingReady.promise
+ },
+ pendingComponent: () => (
+
+ F pending for {fRoute.useRouteContext().user}
+
+ ),
+ component: Outlet,
+ })
+ const gRoute = createRoute({
+ getParentRoute: () => fRoute,
+ path: 'g',
+ component: () => G
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ aRoute.addChildren([
+ bRoute.addChildren([cRoute.addChildren([dRoute])]),
+ eRoute.addChildren([fRoute.addChildren([gRoute])]),
+ ]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/a/b/c/d?user=Ada'] }),
+ defaultPendingMs: 0,
+ defaultPendingMinMs: 0,
+ })
+
+ render()
+ expect(await screen.findByTestId('source')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Ada')
+
+ let navigation!: Promise
+ try {
+ await act(async () => {
+ navigation = router.navigate({
+ to: '/a/e/f/g',
+ search: { user: 'Grace' },
+ })
+ await retainedStarted.promise
+ })
+
+ expect(screen.getByTestId('source')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Ada')
+ expect(screen.queryByTestId('f-pending')).not.toBeInTheDocument()
+
+ await act(async () => {
+ retainedReady.resolve()
+ await pendingStarted.promise
+ })
+
+ expect(await screen.findByTestId('f-pending')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Grace')
+ expect(screen.getByTestId('e-user')).toHaveTextContent('Grace')
+ expect(screen.getByTestId('f-pending')).toHaveTextContent('Grace')
+ expect(screen.queryByTestId('source')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('hidden-g')).not.toBeInTheDocument()
+ expect(router.state.matches.map((match) => match.routeId)).toContain(
+ gRoute.id,
+ )
+ } finally {
+ retainedReady.resolve()
+ pendingReady.resolve()
+ await act(async () => {
+ await Promise.allSettled(navigation ? [navigation] : [])
+ })
+ }
+})
+
+test.each([false, true])(
+ 'retained loader and component work does not own the child fallback (parent pending: %s)',
+ async (parentHasPending) => {
+ const parentReloadStarted = createControlledPromise()
+ const parentReload = createControlledPromise()
+ const parentComponent = createControlledPromise()
+ const childLoader = createControlledPromise()
+ let parentLoads = 0
+ let parentPreloads = 0
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const Parent = Object.assign(
+ () => (
+
+ {parentRoute.useLoaderData()}
+
+
+ ),
+ {
+ preload: () => (++parentPreloads === 1 ? undefined : parentComponent),
+ },
+ )
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'parent',
+ shouldReload: true,
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: async () => {
+ if (++parentLoads === 1) {
+ return 'initial parent'
+ }
+ parentReloadStarted.resolve()
+ await parentReload
+ return 'reloaded parent'
+ },
+ },
+ component: Parent,
+ ...(parentHasPending
+ ? {
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => (
+ Parent pending
+ ),
+ }
+ : {}),
+ })
+ const sourceRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: 'source',
+ component: () => Source
,
+ })
+ const childOptions = createLazyRoute('/parent/child')({
+ pendingComponent: () => (
+ Child pending
+ ),
+ component: () => Child
,
+ })
+ const childLazy = createControlledPromise()
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: 'child',
+ pendingMs: 0,
+ pendingMinMs: 0,
+ loader: () => childLoader,
+ }).lazy(() => childLazy)
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ parentRoute.addChildren([sourceRoute, childRoute]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/parent/source'] }),
+ })
+
+ render()
+ expect(await screen.findByTestId('source')).toBeVisible()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ const navigation = router.navigate({ to: '/parent/child' })
+ try {
+ await parentReloadStarted
+ parentReload.resolve()
+ await waitFor(() => {
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id)
+ ?.isFetching,
+ ).toBe(false)
+ })
+
+ childLazy.resolve(childOptions)
+ expect(await screen.findByTestId('child-pending')).toBeVisible()
+ expect(screen.getByTestId('parent-content')).toBeVisible()
+ expect(screen.queryByTestId('parent-pending')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('source')).not.toBeInTheDocument()
+ } finally {
+ parentReload.resolve()
+ parentComponent.resolve()
+ childLazy.resolve(childOptions)
+ childLoader.resolve()
+ await navigation
+ }
+
+ expect(screen.getByTestId('child')).toBeVisible()
+ },
+)
+
+test('a failure in the last retained guard suppresses descendant pending', async () => {
+ const guardStarted = createControlledPromise()
+ const guardReady = createControlledPromise()
+ const childReady = createControlledPromise()
+ let guardLoads = 0
+ let childLoads = 0
+
+ const rootRoute = createRootRoute({
+ beforeLoad: () => ({ rootReady: true }),
+ component: Outlet,
+ })
+ const layoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ id: 'layout',
+ beforeLoad: async () => {
+ if (++guardLoads > 1) {
+ guardStarted.resolve()
+ await guardReady
+ throw new Error('blocked')
+ }
+ },
+ component: Outlet,
+ errorComponent: () => Guard error
,
+ })
+ const sourceRoute = createRoute({
+ getParentRoute: () => layoutRoute,
+ path: '/source',
+ component: () => Source
,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => layoutRoute,
+ path: '/child',
+ loader: async () => {
+ childLoads++
+ await childReady
+ },
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => Pending
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ layoutRoute.addChildren([sourceRoute, childRoute]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/source'] }),
+ })
+
+ render()
+ expect(await screen.findByTestId('source')).toBeVisible()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ const navigation = router.navigate({ to: '/child' })
+ try {
+ await guardStarted
+ expect(screen.getByTestId('source')).toBeVisible()
+ expect(screen.queryByTestId('child-pending')).not.toBeInTheDocument()
+ expect(childLoads).toBe(0)
+
+ guardReady.resolve()
+ await navigation
+ expect(await screen.findByTestId('guard-error')).toBeVisible()
+ expect(screen.queryByTestId('child-pending')).not.toBeInTheDocument()
+ expect(childLoads).toBe(0)
+ } finally {
+ guardReady.resolve()
+ childReady.resolve()
+ await navigation
+ }
+})
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 862fffc93f9..644c4887e65 100644
--- a/packages/react-router/tests/public-presentation-lane-contract.test.tsx
+++ b/packages/react-router/tests/public-presentation-lane-contract.test.tsx
@@ -8,6 +8,7 @@ import {
createRootRoute,
createRoute,
createRouter,
+ notFound,
} from '../src'
afterEach(() => {
@@ -78,6 +79,60 @@ describe('public presentation lane contracts', () => {
expect(router.state.status).toBe('idle')
})
+ test('a plain load retry presents pending UI over a committed error', async () => {
+ const retryStarted = createControlledPromise()
+ const retry = createControlledPromise()
+ let attempt = 0
+
+ const rootRoute = createRootRoute({ component: Outlet })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => Retrying page
,
+ loader: () => {
+ if (!attempt++) {
+ throw new Error('Initial failure')
+ }
+ retryStarted.resolve()
+ return retry
+ },
+ errorComponent: () => Page failed
,
+ component: () => {pageRoute.useLoaderData()}
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([pageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/page'] }),
+ })
+ const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ render()
+ expect(await screen.findByText('Page failed')).toBeInTheDocument()
+
+ let retryLoad!: Promise
+ try {
+ await act(async () => {
+ retryLoad = router.load()
+ await retryStarted
+ })
+ expect(screen.getByText('Retrying page')).toBeInTheDocument()
+ expect(screen.getByText('Page failed')).not.toBeVisible()
+
+ await act(async () => {
+ retry.resolve('Page recovered')
+ await retryLoad
+ })
+ expect(screen.getByText('Page recovered')).toBeInTheDocument()
+ } finally {
+ retry.resolve('Page recovered')
+ await act(async () => {
+ await retryLoad
+ })
+ consoleWarn.mockRestore()
+ }
+ })
+
test('same-boundary takeover republishes successor search without restarting pendingMinMs', async () => {
const firstGate = createControlledPromise()
const secondGate = createControlledPromise()
@@ -162,7 +217,7 @@ describe('public presentation lane contracts', () => {
expect(screen.getByText('Loading page')).toBeInTheDocument()
await act(async () => {
- await vi.advanceTimersByTimeAsync(1)
+ await vi.advanceTimersByTimeAsync(5)
await Promise.resolve()
})
@@ -188,6 +243,319 @@ describe('public presentation lane contracts', () => {
expect(screen.queryByText('Loading page')).not.toBeInTheDocument()
})
+ test('an earlier pending-ineligible boundary retires a deeper pending minimum', async () => {
+ const childReloadStarted = createControlledPromise()
+ const childReload = createControlledPromise()
+ const parentReloadStarted = createControlledPromise()
+ const parentReload = createControlledPromise()
+ let childLoads = 0
+
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record) => ({
+ revision: Number(search.revision),
+ }),
+ component: Outlet,
+ })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loaderDeps: ({ search }) => ({ revision: search.revision }),
+ beforeLoad: ({ search }) => {
+ if (search.revision === 2) {
+ parentReloadStarted.resolve()
+ return parentReload
+ }
+ return undefined
+ },
+ component: Outlet,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => Loading child
,
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: () => {
+ if (childLoads++) {
+ childReloadStarted.resolve()
+ return childReload
+ }
+ return undefined
+ },
+ },
+ component: () => (
+ Child revision {childRoute.useSearch().revision}
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?revision=1'],
+ }),
+ })
+
+ render()
+ expect(await screen.findByText('Child revision 1')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ let firstNavigation: Promise | undefined
+ let secondNavigation: Promise | undefined
+ let settledBeforeOldMinimum = false
+ let renderedBeforeOldMinimum = false
+ try {
+ await act(async () => {
+ firstNavigation = router.invalidate({
+ filter: (match) => match.routeId === childRoute.id,
+ forcePending: true,
+ })
+ await childReloadStarted
+ await vi.advanceTimersByTimeAsync(0)
+ })
+ expect(screen.getByText('Loading child')).toBeInTheDocument()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(25)
+ secondNavigation = router.navigate({
+ to: '/parent/child',
+ search: { revision: 2 },
+ })
+ await parentReloadStarted
+ })
+ expect(screen.getByText('Loading child')).toBeInTheDocument()
+
+ childReload.resolve()
+
+ const successor = secondNavigation
+ if (!successor) {
+ throw new Error('Expected the successor navigation to start')
+ }
+ void successor.then(() => {
+ settledBeforeOldMinimum = true
+ })
+ await act(async () => {
+ parentReload.resolve()
+ await vi.advanceTimersByTimeAsync(5)
+ })
+ renderedBeforeOldMinimum = screen.queryByText('Child revision 2') !== null
+ } finally {
+ childReload.resolve()
+ parentReload.resolve()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000)
+ await Promise.allSettled(
+ [firstNavigation, secondNavigation].filter(
+ (navigation): navigation is Promise => !!navigation,
+ ),
+ )
+ })
+ }
+
+ expect({
+ settled: settledBeforeOldMinimum,
+ rendered: renderedBeforeOldMinimum,
+ }).toEqual({ settled: true, rendered: true })
+ })
+
+ test('same-boundary timing survives a private retained-context barrier', async () => {
+ const retainedStarted = createControlledPromise()
+ const retainedReady = createControlledPromise()
+ const firstPage = createControlledPromise()
+ const secondPageStarted = createControlledPromise()
+ const secondPage = createControlledPromise()
+
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record) => ({
+ revision: Number(search.revision) || 0,
+ }),
+ beforeLoad: ({ search }) => {
+ if (search.revision === 2) {
+ retainedStarted.resolve()
+ return retainedReady.then(() => ({ rootRevision: 2 }))
+ }
+ return { rootRevision: search.revision }
+ },
+ component: () => (
+
+
Root revision {rootRoute.useRouteContext().rootRevision}
+
+
+ ),
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+ })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => Loading page
,
+ beforeLoad: ({ search }) => {
+ if (search.revision === 1) {
+ return firstPage
+ }
+ secondPageStarted.resolve()
+ return secondPage
+ },
+ component: () => (
+ Page revision {pageRoute.useSearch().revision}
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, pageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+ expect(await screen.findByText('Home')).toBeInTheDocument()
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ let firstNavigation: Promise | undefined
+ let secondNavigation: Promise | undefined
+ try {
+ await act(async () => {
+ firstNavigation = router.navigate({
+ to: '/page',
+ search: { revision: 1 },
+ })
+ await vi.advanceTimersByTimeAsync(0)
+ })
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(screen.getByText('Root revision 1')).toBeInTheDocument()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(25)
+ secondNavigation = router.navigate({
+ to: '/page',
+ search: { revision: 2 },
+ })
+ await retainedStarted
+ })
+
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(screen.getByText('Root revision 1')).toBeInTheDocument()
+
+ await act(async () => {
+ retainedReady.resolve()
+ await secondPageStarted
+ })
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(screen.getByText('Root revision 2')).toBeInTheDocument()
+
+ let settled = false
+ const successor = secondNavigation
+ if (!successor) {
+ throw new Error('Expected the successor navigation to start')
+ }
+ void successor.then(() => {
+ settled = true
+ })
+ await act(async () => {
+ secondPage.resolve()
+ await vi.advanceTimersByTimeAsync(74)
+ })
+ expect(settled).toBe(false)
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(5)
+ await Promise.all([firstNavigation, successor])
+ })
+ expect(screen.getByText('Page revision 2')).toBeInTheDocument()
+ } finally {
+ retainedReady.resolve()
+ firstPage.resolve()
+ secondPage.resolve()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000)
+ await Promise.allSettled(
+ [firstNavigation, secondNavigation].filter(
+ (navigation): navigation is Promise => !!navigation,
+ ),
+ )
+ })
+ }
+ })
+
+ test('an exact-boundary terminal result supersedes an unrendered pending offer', async () => {
+ const pendingRenderStarted = createControlledPromise()
+ const pendingRender = createControlledPromise()
+ const terminalLoadStarted = createControlledPromise()
+ const terminalLoad = createControlledPromise()
+
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record) => ({
+ terminal: search.terminal === true,
+ }),
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => {
+ pendingRenderStarted.resolve()
+ throw pendingRender
+ },
+ beforeLoad: async ({ search }) => {
+ if (search.terminal) {
+ terminalLoadStarted.resolve()
+ await terminalLoad
+ throw notFound()
+ }
+ },
+ notFoundComponent: () => Root not found
,
+ component: Outlet,
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/?terminal=false'] }),
+ })
+
+ render()
+ expect(await screen.findByText('Home')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ let navigation: Promise | undefined
+ try {
+ await act(async () => {
+ navigation = router.navigate({
+ to: '/',
+ search: { terminal: true },
+ })
+ await terminalLoadStarted
+ await vi.advanceTimersByTimeAsync(0)
+ await pendingRenderStarted
+ })
+ expect(screen.getByText('Home')).toBeInTheDocument()
+
+ await act(async () => {
+ terminalLoad.resolve()
+ await navigation
+ })
+
+ expect(screen.getByText('Root not found')).toBeInTheDocument()
+ expect(Date.now()).toBe(0)
+ } finally {
+ terminalLoad.resolve()
+ pendingRender.resolve()
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1_000)
+ await Promise.allSettled(navigation ? [navigation] : [])
+ })
+ }
+ })
+
test('a reentrant navigation from onResolved suppresses the stale onRendered event', async () => {
const rootRoute = createRootRoute({ component: Outlet })
const indexRoute = createRoute({
diff --git a/packages/router-core/INTERNALS.md b/packages/router-core/INTERNALS.md
index 2fd9472cec2..ec119aa09c3 100644
--- a/packages/router-core/INTERNALS.md
+++ b/packages/router-core/INTERNALS.md
@@ -800,15 +800,23 @@ the current turn instead of introducing a `setTimeout(0)` race.
publication rendered. A superseded publication that never rendered creates no
minimum-visible obligation.
+Core considers actual non-success matches and an exact pending presentation in
+route order. An earlier ineligible successor boundary therefore retires the old
+clock without replacing the painted fallback, and a later occurrence of that
+old boundary in the same transaction cannot resurrect its minimum.
+
Hydration or a redirect can leave an already visible pending presentation
-without a pending session that owns its original acknowledgement. On takeover,
-core conservatively treats that presentation as rendered and starts its minimum
-from the takeover time instead of delaying its reveal again.
+without a pending session that owns its original acknowledgement. When there is
+no competing semantic boundary, core conservatively treats that presentation as
+rendered and starts its minimum from the takeover time instead of delaying its
+reveal again.
-A successor may take over timing only when the boundary index and match ID are
-the same. It keeps the existing deadline but republishes a full snapshot from
+A successor may take over timing only when its selected boundary has the same
+match ID. It keeps the existing deadline but republishes a full snapshot from
the successor, so pending UI cannot show stale search, params, or context from
-the superseded navigation. Changing the boundary discards the old session.
+the superseded navigation. Normal route matches preserve position with their
+IDs; the deprecated relocatable `notFoundRoute` is not given separate positional
+session identity. Changing the selected boundary discards the old session.
## Exact framework acknowledgement
@@ -820,6 +828,11 @@ the superseded navigation. Changing the boundary discards the old session.
- `false` means core must finish without emitting `onRendered` or starting a
pending minimum based on that publication.
+A rejected acknowledgement aborts the transaction that owns the exact pending
+session. The transaction remains responsible for restoring the committed lane
+and releasing resources; stale acknowledgement failures cannot abort a
+successor.
+
React cannot await `React.startTransition` directly. Its adapter keeps one
router-owned acknowledgement tuple, and `Matches` settles that tuple from a
layout effect. A new expected publication first settles the previous receipt as
diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts
index 08084cc70de..ffc6855e2b3 100644
--- a/packages/router-core/src/load-client.ts
+++ b/packages/router-core/src/load-client.ts
@@ -215,12 +215,12 @@ export type LoadTransaction = [
]
export type PendingSession = [
- owner: LoadTransaction,
- boundary: number,
+ generation: LoadTransaction,
+ boundaryId: string,
/** Pending reveal time until acknowledged, then minimum-visible-until time. */
deadline: number,
- timer?: ReturnType,
- ack?: Promise,
+ revealTimer?: ReturnType,
+ ack?: Promise | true,
component?: unknown,
]
@@ -434,11 +434,10 @@ async function contextualize(
}
const previousStatus = match.status
- // Retain only a success that is mounted through the same valid prefix.
- if (previousStatus === 'success' && index >= retainedEnd) {
+ if (index >= retainedEnd) {
match.status = 'pending'
+ options[8 /* onReady */]?.()
}
- options[8 /* onReady */]?.()
try {
setFetching(router, match, 'beforeLoad', options[0 /* controller */])
const result = await waitFor(beforeLoad(beforeLoadContext), signal)
@@ -459,8 +458,8 @@ async function contextualize(
releaseFlight(router, match)
return [index, normalizeLaneError(route, cause, options)]
} finally {
- if (previousStatus === 'success' && match.status === 'pending') {
- match.status = 'success'
+ if (match.status === 'pending') {
+ match.status = previousStatus
}
setFetching(router, match, false, options[0 /* controller */])
}
@@ -837,8 +836,8 @@ function createLoaderTask(
const loaded = reload && (!preload || route.options.preload !== false)
const blocking =
loaded && !background && (match.status !== 'success' || !!routeLoader)
- const onLazyReady =
- route.lazyFn && route._lazy !== true ? options[8 /* onReady */] : undefined
+ const onReady = index >= retainedEnd ? options[8 /* onReady */] : undefined
+ const onLazyReady = route.lazyFn && route._lazy !== true ? onReady : undefined
if (loaded && !routeLoader) {
match.invalid = false
match.updatedAt = Date.now()
@@ -850,12 +849,12 @@ function createLoaderTask(
const acceptedFlight = match._flight
match._flight = donor
releaseOwnedFlight(router, match, acceptedFlight)?.abort()
- // A successful route without a loader has no blocking work to present. A
- // mounted success likewise remains renderable while its loader revalidates.
- if (match.status === 'success' && index >= retainedEnd) {
+ // A mounted success remains renderable while its loader revalidates. Every
+ // non-retained blocking generation presents pending state.
+ if (index >= retainedEnd) {
match.status = 'pending'
}
- options[8 /* onReady */]?.()
+ onReady?.()
}
if (!loaded) {
match.isFetching = false
@@ -893,7 +892,9 @@ function createLoaderTask(
}
// A route is renderable only after both its data and normal component
// chunk are ready. Its loader data is already available to descendants.
- match.status = 'pending'
+ if (index >= retainedEnd) {
+ match.status = 'pending'
+ }
}
}
return result
@@ -919,7 +920,7 @@ function createLoaderTask(
options[2 /* isCurrent */]()
) {
match.status = 'success'
- options[8 /* onReady */]?.()
+ onReady?.()
}
return failure
}),
@@ -1148,6 +1149,9 @@ async function reduceLane(
}
}
install()
+ if (!outcome) {
+ onReady?.()
+ }
const route = getRoute(router, match)
try {
await waitFor(
@@ -1172,7 +1176,6 @@ async function reduceLane(
}
if (!outcome) {
match.status = 'success'
- onReady?.()
} else if (redirectLimitExceeded) {
controller.abort()
await Promise.all([
@@ -1392,127 +1395,148 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void {
if (router._tx !== tx) {
return
}
- let session = router._pending
- let tookOver = false
- const sessionMatchId =
- session?.[0 /* owner */][3 /* matches */][session[1 /* boundary */]]?.id
- if (session?.[0 /* owner */] !== tx) {
- if (
- session &&
- tx[3 /* matches */][session[1 /* boundary */]]?.id === sessionMatchId
- ) {
- session[0 /* owner */] = tx
- tookOver = true
- } else {
- clearTimeout(session?.[3 /* timer */])
- router._pending = session = undefined
- }
- }
const matches = tx[3 /* matches */]
const presented = router.stores.matches.get()
- let boundary = -1
- let delay: number | undefined
- let min!: number
- let component: unknown
- let presentedPending = false
+ let session = router._pending
for (let index = 0; index < matches.length; index++) {
const match = matches[index]!
- const success = match.status === 'success'
- presentedPending =
+ const success = match.status === 'success' && !match._notFound
+ const presentedPending =
presented[index]?.id === match.id &&
presented[index]?.status === 'pending'
if (success && !presentedPending) {
continue
}
const route = getRoute(router, match as WorkMatch)
- delay =
+ const delay =
(success && presentedPending) || match.invalid
? 0
: (route.options.pendingMs ?? router.options.defaultPendingMs)
- component =
+ const component =
route.options.pendingComponent ??
(router.options as any).defaultPendingComponent
if (!component || typeof delay !== 'number' || delay === Infinity) {
+ if (session) {
+ session[0 /* generation */] = tx
+ session[2 /* deadline */] = 0
+ session[4 /* ack */] = true
+ }
return
}
- boundary = index
- min = route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0
- break
- }
- if (boundary < 0) {
- return
- }
- const matchId = matches[boundary]!.id
- if (
- !session ||
- session[1 /* boundary */] !== boundary ||
- sessionMatchId !== matchId
- ) {
- // Hydration and redirects can preserve pending presentation without a session.
- // Do not delay it again; conservatively start pendingMinMs from now.
- clearTimeout(session?.[3 /* timer */])
- router._pending = session = [
- tx,
- boundary,
- presentedPending ? Date.now() + min : tx[4 /* startedAt */] + delay!,
- undefined,
- presentedPending ? Promise.resolve(true) : undefined,
- component,
- ]
- }
- if (
- session[4 /* ack */] &&
- !tookOver &&
- session[5 /* component */] === component
- ) {
- return
- }
- session[5 /* component */] = component
- if (!session[4 /* ack */]) {
- clearTimeout(session[3 /* timer */])
- const remaining = session[2 /* deadline */] - Date.now()
- if (remaining > 0) {
- session[3 /* timer */] = setTimeout(
- () => offerPending(router, tx),
- remaining,
- )
+ 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 {
+ 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 = [
+ tx,
+ match.id,
+ presentedPending ? Date.now() + min : tx[4 /* startedAt */] + delay,
+ undefined,
+ presentedPending || undefined,
+ component,
+ ]
+ }
+ if (
+ session[4 /* ack */] &&
+ !tookOver &&
+ session[5 /* component */] === component
+ ) {
return
}
- session[2 /* deadline */] = 0
- }
- const offered = matches.map((match) => ({
- ...match,
- _flight: undefined,
- }))
- offered[boundary]!.status = 'pending'
- const ack = router
- .startTransition(() => router.stores.setMatches(offered), offered)
- .then((rendered) => {
- if (
- rendered &&
- router._pending === session &&
- session[4 /* ack */] === ack &&
- !session[2 /* deadline */]
- ) {
- session[2 /* deadline */] = Date.now() + min
+ session[5 /* component */] = component
+ if (!session[4 /* ack */]) {
+ clearTimeout(session[3 /* revealTimer */])
+ const remaining = session[2 /* deadline */] - Date.now()
+ if (remaining > 0) {
+ session[3 /* revealTimer */] = setTimeout(
+ () => offerPending(router, tx),
+ remaining,
+ )
+ return
}
- return rendered
- })
- session[4 /* ack */] = ack
+ session[2 /* deadline */] = 0
+ }
+ const offered = matches.map((match) => ({
+ ...match,
+ _flight: undefined,
+ }))
+ offered[index]!.status = 'pending'
+ const ack = (session[4 /* ack */] = router
+ .startTransition(() => router.stores.setMatches(offered), offered)
+ .then(
+ (rendered) => {
+ if (
+ rendered &&
+ router._pending === session &&
+ session![4 /* ack */] === ack &&
+ !session![2 /* deadline */]
+ ) {
+ session![2 /* deadline */] = Date.now() + min
+ }
+ return rendered
+ },
+ () => {
+ if (router._pending?.[4 /* ack */] === ack) {
+ tx[0 /* controller */].abort()
+ }
+ return false
+ },
+ ))
+ return
+ }
}
/**
- * Cancels pending UI timing when its load ends. The ownership check prevents
- * an older, superseded load from clearing pending UI that a newer load took over.
+ * Cancels pending UI timing when the current load replaces its presentation.
+ * An obsolete load cannot clear the fallback that remains painted above it.
*/
function finishPending(router: CoordinatorRouter, tx: LoadTransaction): void {
- const session = router._pending
- if (session?.[0 /* owner */] === tx) {
- clearTimeout(session[3 /* timer */])
+ if (router._tx === tx) {
+ clearTimeout(router._pending?.[3 /* revealTimer */])
router._pending = undefined
}
}
+async function awaitPendingMinimum(
+ router: CoordinatorRouter,
+ tx: LoadTransaction,
+): Promise {
+ const session = router._pending
+ if (!session) {
+ return
+ }
+ clearTimeout(session[3 /* revealTimer */])
+ const remaining = session[2 /* deadline */] - Date.now()
+ if (
+ !session[4 /* ack */] ||
+ remaining <= 0 ||
+ !_getRenderedMatches(tx[3 /* matches */]).some(
+ (match) => match.id === session[1 /* boundaryId */],
+ )
+ ) {
+ return
+ }
+ let timer: ReturnType | undefined
+ try {
+ await waitFor(
+ new Promise((resolve) => {
+ timer = setTimeout(resolve, remaining)
+ }),
+ tx[0 /* controller */].signal,
+ )
+ } catch {}
+ clearTimeout(timer)
+}
+
function publishMatches(
router: CoordinatorRouter,
matches: Array,
@@ -1880,7 +1904,9 @@ async function runClientTransaction(
if (isControl(result)) {
if (result[0 /* kind */] === REDIRECTED && router._tx === tx) {
- finishPending(router, tx)
+ if (result[1 /* redirect */].options.reloadDocument) {
+ finishPending(router, tx)
+ }
transferMatchResources(router, tx[3 /* matches */])
tx[3 /* matches */] = []
if (router._tx === tx) {
@@ -1894,47 +1920,14 @@ async function runClientTransaction(
}
return
}
- const pending = router._pending
- if (pending?.[0 /* owner */] === tx) {
- /**
- * Loading finished, so cancel any pending reveal. If the fallback rendered,
- * wait out the rest of `pendingMinMs` before replacing it. If it never
- * rendered, there is no minimum wait; if another load took it over, that
- * load owns the deadline.
- */
- clearTimeout(pending[3 /* timer */])
- if (pending[4 /* ack */]) {
- const signal = tx[0 /* controller */].signal
- let rendered = false
- try {
- rendered = await waitFor(pending[4 /* ack */], signal)
- } catch (cause) {
- if (cause !== signal) {
- throw cause
- }
- }
- if (
- rendered &&
- router._pending === pending &&
- pending[0 /* owner */] === tx
- ) {
- const remaining = pending[2 /* deadline */] - Date.now()
- if (remaining > 0) {
- try {
- await waitFor(
- new Promise((resolve) => {
- pending[3 /* timer */] = setTimeout(resolve, remaining)
- }),
- signal,
- )
- } catch {}
- clearTimeout(pending[3 /* timer */])
- }
- }
- }
+ if (router._tx !== tx) {
+ discardLane(router, result)
+ return
}
+ // Only an acknowledged fallback owns a minimum. Recheck at the commit
+ // boundary because native view transitions can defer their update callback.
+ await awaitPendingMinimum(router, tx)
if (router._tx !== tx) {
- finishPending(router, tx)
discardLane(router, result)
return
}
@@ -1945,6 +1938,11 @@ async function runClientTransaction(
)
const background = result[2 /* background */]
await router.startViewTransition(async () => {
+ if (router._tx !== tx) {
+ discardLane(router, result)
+ return
+ }
+ await awaitPendingMinimum(router, tx)
if (router._tx !== tx) {
discardLane(router, result)
return
@@ -2159,7 +2157,10 @@ export async function loadClientRoute(
})
// Cold loads have no committed UI to retain, but provisional not-found
// matches must wait for lazy routes to place the final boundary.
- if (!resolvedLocation && !matches.some((match) => match._notFound)) {
+ if (
+ resolvedPrefix ||
+ (!router._committed.length && !matches.some((match) => match._notFound))
+ ) {
offerPending(router, tx)
}
try {
diff --git a/packages/router-core/tests/public-client-loading-contract.test.ts b/packages/router-core/tests/public-client-loading-contract.test.ts
index c3cb0c75774..d9c9f9366d6 100644
--- a/packages/router-core/tests/public-client-loading-contract.test.ts
+++ b/packages/router-core/tests/public-client-loading-contract.test.ts
@@ -55,6 +55,146 @@ describe('public client loading contracts', () => {
})
})
+ test('a successor retains committed UI until its pending context is ready', async () => {
+ const initialPublished = createControlledPromise()
+ const initialRenderAck = createControlledPromise()
+ const successorStarted = createControlledPromise()
+ const successorLoader = createControlledPromise()
+ const rootRoute = new BaseRootRoute({})
+ const initialRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const successorRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/successor',
+ context: () => ({ destinationContext: 'ready' }),
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => null,
+ loader: () => {
+ successorStarted.resolve()
+ return successorLoader
+ },
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([initialRoute, successorRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+ const startTransition = router.startTransition
+ let waitForInitialRender = true
+ router.startTransition = (fn, expected) => {
+ fn()
+ if (
+ waitForInitialRender &&
+ expected?.at(-1)?.routeId === initialRoute.id &&
+ expected.at(-1)?.status === 'success'
+ ) {
+ waitForInitialRender = false
+ initialPublished.resolve()
+ return initialRenderAck
+ }
+ return Promise.resolve(true)
+ }
+
+ const initialLoad = router.load()
+ let navigation: Promise | undefined
+ try {
+ await initialPublished
+ expect(router._committed.at(-1)?.routeId).toBe(initialRoute.id)
+ expect(router.state.resolvedLocation).toBeUndefined()
+ expect(initialRenderAck.status).toBe('pending')
+
+ navigation = router.navigate({ to: '/successor' })
+ await successorStarted
+
+ expect(router.state.matches.at(-1)).toMatchObject({
+ routeId: successorRoute.id,
+ status: 'pending',
+ context: { destinationContext: 'ready' },
+ })
+
+ successorLoader.resolve('successor data')
+ initialRenderAck.resolve(true)
+ await Promise.all([initialLoad, navigation])
+ } finally {
+ successorLoader.resolve('successor data')
+ initialRenderAck.resolve(true)
+ await Promise.allSettled([initialLoad, navigation])
+ router.startTransition = startTransition
+ }
+ })
+
+ test('a rejected pending publication restores the committed lane', async () => {
+ const loaderStarted = createControlledPromise()
+ const loaderGate = createControlledPromise()
+ let loaderSignal: AbortSignal | undefined
+ const publicationError = new Error('pending publication failed')
+
+ const rootRoute = new BaseRootRoute({})
+ const indexRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ })
+ const targetRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/target',
+ pendingMs: 0,
+ pendingComponent: () => null,
+ loader: ({ abortController }) => {
+ loaderSignal = abortController.signal
+ loaderStarted.resolve()
+ return loaderGate
+ },
+ })
+ const recoveryRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/recovery',
+ })
+ const router = createTestRouter({
+ routeTree: rootRoute.addChildren([
+ indexRoute,
+ targetRoute,
+ recoveryRoute,
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ await router.load()
+ const startTransition = router.startTransition
+ let rejectPending = true
+ router.startTransition = (fn, expected) => {
+ if (
+ rejectPending &&
+ expected?.some((match) => match.status === 'pending')
+ ) {
+ rejectPending = false
+ fn()
+ return Promise.reject(publicationError)
+ }
+ return startTransition(fn, expected)
+ }
+
+ try {
+ const navigation = router.navigate({ to: '/target' })
+ await loaderStarted
+ await navigation
+
+ expect(loaderSignal?.aborted).toBe(true)
+ expect(router.state.status).toBe('idle')
+ expect(router.state.matches.at(-1)?.routeId).toBe(indexRoute.id)
+
+ await router.navigate({ to: '/recovery' })
+ expect(router.state.matches.at(-1)).toMatchObject({
+ routeId: recoveryRoute.id,
+ status: 'success',
+ })
+ } finally {
+ loaderGate.resolve('late target data')
+ router.startTransition = startTransition
+ }
+ })
+
test('background loading is observable while retaining committed data', async () => {
const reloadGate = createControlledPromise<{ generation: number }>()
let loaderCalls = 0
diff --git a/packages/solid-router/src/Transitioner.tsx b/packages/solid-router/src/Transitioner.tsx
index 8bd5719df8e..332a1fc7228 100644
--- a/packages/solid-router/src/Transitioner.tsx
+++ b/packages/solid-router/src/Transitioner.tsx
@@ -2,7 +2,6 @@ import * as Solid from 'solid-js'
import { getLocationChangeInfo, trimPathRight } from '@tanstack/router-core'
import { isServer } from '@tanstack/router-core/isServer'
import { useRouter } from './useRouter'
-import type { AnyRouteMatch } from '@tanstack/router-core'
function getResolvedLocation(router: ReturnType) {
const resolvedLocation = router.stores.resolvedLocation.get()
@@ -22,13 +21,44 @@ export function Transitioner() {
return null
}
- let transitionOwner: Array | undefined
- router.startTransition = async (fn, expected) => {
- transitionOwner = expected
- await Solid.startTransition(fn)
- return transitionOwner === expected
+ let settleCurrent: ((rendered: boolean) => void) | undefined
+ router.startTransition = (fn) => {
+ settleCurrent?.(false)
+
+ return new Promise((resolve, reject) => {
+ const settle = (rendered: boolean) => {
+ if (settleCurrent !== settle) {
+ return
+ }
+ settleCurrent = undefined
+ resolve(rendered)
+ }
+ const fail = (cause: unknown) => {
+ if (settleCurrent !== settle) {
+ return
+ }
+ settleCurrent = undefined
+ reject(cause)
+ }
+ settleCurrent = settle
+
+ void Solid.startTransition(() => {
+ // A newer publication may supersede this deferred callback.
+ if (settleCurrent === settle) {
+ try {
+ fn()
+ } catch (cause) {
+ fail(cause)
+ }
+ }
+ }).then(() => settle(true), fail)
+ })
}
+ Solid.onCleanup(() => {
+ settleCurrent?.(false)
+ })
+
// Subscribe to location changes
// and try to load the new location
Solid.onMount(() => {
diff --git a/packages/solid-router/tests/hydration-terminal-lane.test.tsx b/packages/solid-router/tests/hydration-terminal-lane.test.tsx
new file mode 100644
index 00000000000..e7ee9b0f4da
--- /dev/null
+++ b/packages/solid-router/tests/hydration-terminal-lane.test.tsx
@@ -0,0 +1,87 @@
+import { cleanup, render, screen } from '@solidjs/testing-library'
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import { hydrate } from '@tanstack/router-core/ssr/client'
+import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id'
+import {
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+} from '../src'
+import type { AnyRouteMatch } from '@tanstack/router-core'
+import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client'
+
+function bootstrap(
+ matches: Array<{
+ match: AnyRouteMatch
+ status: AnyRouteMatch['status']
+ ssr: AnyRouteMatch['ssr']
+ data?: unknown
+ error?: unknown
+ notFound?: boolean
+ }>,
+): void {
+ window.$_TSR = {
+ router: {
+ manifest: undefined,
+ matches: matches.map(({ match, status, ssr, data, error, notFound }) => ({
+ i: dehydrateSsrMatchId(match.id),
+ l: data,
+ e: error,
+ s: status,
+ ssr,
+ u: Date.now(),
+ ...(notFound ? { g: true } : {}),
+ })),
+ },
+ h: vi.fn(),
+ e: vi.fn(),
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: [],
+ } as TsrSsrGlobal
+}
+
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ delete window.$_TSR
+})
+
+describe('hydration terminal lane', () => {
+ test('keeps a hydrated pending fallback through its minimum before a terminal result', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+ const rootRoute = createRootRoute({
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => Missing page pending
,
+ notFoundComponent: () => Missing page
,
+ })
+ const router = createRouter({
+ history: createMemoryHistory({ initialEntries: ['/missing'] }),
+ routeTree: rootRoute,
+ })
+ const matches = router.matchRoutes(router.state.location)
+ expect(matches[0]?._notFound).toBe(true)
+ bootstrap([
+ {
+ match: matches[0]!,
+ status: 'pending',
+ ssr: false,
+ notFound: true,
+ },
+ ])
+
+ await hydrate(router)
+ render(() => )
+ expect(screen.getByRole('status')).toHaveTextContent('Missing page pending')
+
+ await vi.advanceTimersByTimeAsync(99)
+ expect(screen.getByRole('status')).toHaveTextContent('Missing page pending')
+ expect(screen.queryByText('Missing page')).not.toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(5)
+ expect(screen.getByText('Missing page')).toBeInTheDocument()
+ })
+})
diff --git a/packages/solid-router/tests/issue-4467-lazy-route-pending.test.tsx b/packages/solid-router/tests/issue-4467-lazy-route-pending.test.tsx
new file mode 100644
index 00000000000..b49de23a583
--- /dev/null
+++ b/packages/solid-router/tests/issue-4467-lazy-route-pending.test.tsx
@@ -0,0 +1,82 @@
+import { cleanup, render, screen } from '@solidjs/testing-library'
+import { afterEach, expect, test, vi } from 'vitest'
+import { createControlledPromise } from '@tanstack/router-core'
+import {
+ Outlet,
+ RouterProvider,
+ createLazyRoute,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
+
+test('a lazy pending component does not restart an acknowledged minimum', async () => {
+ const loader = createControlledPromise()
+ const lazyPageOptions = createLazyRoute('/page')({
+ pendingComponent: () => Loading lazy page
,
+ component: () => Page
,
+ })
+ const lazyOptions = createControlledPromise()
+ const rootRoute = createRootRoute({ component: () => })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index page
,
+ })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ loader: () => loader,
+ }).lazy(() => lazyOptions)
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, pageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ defaultPendingMs: 0,
+ defaultPendingMinMs: 100,
+ defaultPendingComponent: () => Loading default
,
+ })
+
+ render(() => )
+ expect(
+ await screen.findByRole('heading', { name: 'Index page' }),
+ ).toBeInTheDocument()
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ const navigation = router.navigate({ to: '/page' })
+ let settled = false
+ void navigation.then(() => {
+ settled = true
+ })
+ try {
+ await vi.advanceTimersByTimeAsync(0)
+ expect(screen.getByRole('status')).toHaveTextContent('Loading default')
+
+ await vi.advanceTimersByTimeAsync(25)
+ lazyOptions.resolve(lazyPageOptions)
+ loader.resolve()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page')
+
+ await vi.advanceTimersByTimeAsync(74)
+ expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page')
+
+ await vi.advanceTimersByTimeAsync(5)
+ await Promise.resolve()
+ expect(settled).toBe(true)
+ await navigation
+ expect(screen.getByRole('heading', { name: 'Page' })).toBeInTheDocument()
+ expect(Date.now()).toBeLessThan(125)
+ } finally {
+ lazyOptions.resolve(lazyPageOptions)
+ loader.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await navigation
+ }
+})
diff --git a/packages/solid-router/tests/issue-7367-pending-min-redirect.test.tsx b/packages/solid-router/tests/issue-7367-pending-min-redirect.test.tsx
new file mode 100644
index 00000000000..d96b01352f1
--- /dev/null
+++ b/packages/solid-router/tests/issue-7367-pending-min-redirect.test.tsx
@@ -0,0 +1,127 @@
+import { cleanup, render, screen } from '@solidjs/testing-library'
+import { createControlledPromise } from '@tanstack/router-core'
+import { afterEach, expect, test, vi } from 'vitest'
+import {
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ redirect,
+} from '../src'
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ cleanup()
+ vi.useRealTimers()
+})
+
+test('a compatible SPA redirect preserves the acknowledged pending minimum', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+ const redirectReady = createControlledPromise()
+ let shouldRedirect = true
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => loading
,
+ beforeLoad: async () => {
+ if (shouldRedirect) {
+ shouldRedirect = false
+ await redirectReady
+ throw redirect({ to: '/welcome', replace: true })
+ }
+ },
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index
,
+ })
+ const welcomeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/welcome',
+ component: () => Welcome
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, welcomeRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ try {
+ render(() => )
+ await vi.advanceTimersByTimeAsync(0)
+ expect(screen.getByTestId('pending')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(25)
+ redirectReady.resolve()
+ await vi.advanceTimersByTimeAsync(74)
+ expect(screen.getByTestId('pending')).toBeInTheDocument()
+ expect(screen.queryByTestId('welcome-page')).not.toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(5)
+ expect(screen.getByTestId('welcome-page')).toBeInTheDocument()
+ } finally {
+ redirectReady.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ vi.useRealTimers()
+ }
+})
+
+test('an incompatible SPA redirect does not inherit the pending minimum', async () => {
+ const redirectReady = createControlledPromise()
+ const rootRoute = createRootRoute({ component: () => })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index
,
+ })
+ const sourceRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/source',
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => loading
,
+ beforeLoad: async () => {
+ await redirectReady
+ throw redirect({ to: '/welcome', replace: true })
+ },
+ })
+ const welcomeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/welcome',
+ component: () => Welcome
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, sourceRoute, welcomeRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render(() => )
+ expect(await screen.findByTestId('index-page')).toBeVisible()
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ const navigation = router.navigate({ to: '/source' })
+ try {
+ await vi.advanceTimersByTimeAsync(0)
+ expect(screen.getByTestId('pending')).toBeVisible()
+
+ await vi.advanceTimersByTimeAsync(25)
+ redirectReady.resolve()
+ await vi.advanceTimersByTimeAsync(5)
+ await navigation
+
+ expect(Date.now()).toBeLessThan(100)
+ expect(screen.getByTestId('welcome-page')).toBeVisible()
+ expect(screen.queryByTestId('index-page')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
+ } finally {
+ redirectReady.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await navigation
+ }
+})
diff --git a/packages/solid-router/tests/issue-7986-retained-pending.test.tsx b/packages/solid-router/tests/issue-7986-retained-pending.test.tsx
index db0092610c0..e4051afbbfe 100644
--- a/packages/solid-router/tests/issue-7986-retained-pending.test.tsx
+++ b/packages/solid-router/tests/issue-7986-retained-pending.test.tsx
@@ -428,10 +428,21 @@ test('a success hidden below an error boundary retries through pending UI', asyn
expect(screen.getByTestId('content')).toHaveTextContent('reloaded child')
})
-test('a global not-found destination does not retain the mounted root success', async () => {
+test('a global not-found destination keeps pending until its terminal component is ready', async () => {
const missingStarted = controlled()
const missingLoader = controlled()
+ const terminalStarted = controlled()
+ const terminalReady = controlled()
let loaderCalls = 0
+ const Missing = Object.assign(
+ () => Missing
,
+ {
+ preload: () => {
+ terminalStarted.resolve()
+ return terminalReady
+ },
+ },
+ )
const rootRoute = createRootRoute({
shouldReload: true,
@@ -447,7 +458,7 @@ test('a global not-found destination does not retain the mounted root success',
},
component: () => ,
pendingComponent: () => Pending root
,
- notFoundComponent: () => Missing
,
+ notFoundComponent: Missing,
})
const pageRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -470,13 +481,56 @@ test('a global not-found destination does not retain the mounted root success',
expect(await screen.findByTestId('pending')).toBeVisible()
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
+ let settled = false
+ void navigation.then(() => {
+ settled = true
+ })
missingLoader.resolve()
+ await terminalStarted
+ expect(screen.getByTestId('pending')).toBeVisible()
+ expect(screen.queryByTestId('missing')).not.toBeInTheDocument()
+ expect(settled).toBe(false)
+
+ terminalReady.resolve()
await navigation
expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
expect(screen.getByTestId('missing')).toBeVisible()
})
+test('a cold global not-found presents pending only while its terminal component loads', async () => {
+ const terminalStarted = controlled()
+ const terminalReady = controlled()
+ const Missing = Object.assign(
+ () => Missing
,
+ {
+ preload: () => {
+ terminalStarted.resolve()
+ return terminalReady
+ },
+ },
+ )
+ const rootRoute = createRootRoute({
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => Pending root
,
+ notFoundComponent: Missing,
+ })
+ const router = createRouter({
+ routeTree: rootRoute,
+ history: createMemoryHistory({ initialEntries: ['/missing'] }),
+ })
+
+ render(() => )
+ await terminalStarted
+ expect(await screen.findByTestId('pending')).toBeVisible()
+ expect(screen.queryByTestId('missing')).not.toBeInTheDocument()
+
+ terminalReady.resolve()
+ expect(await screen.findByTestId('missing')).toBeVisible()
+ expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
+})
+
test('lazy fuzzy-boundary relocation retains the mounted parent', async () => {
const lazyStarted = controlled()
const lazyRoute = controlled()
@@ -628,3 +682,374 @@ test('a superseding navigation replaces an unrelated pending presentation', asyn
expect(screen.getByTestId('content')).toBeVisible()
expect(screen.getByTestId('content')).toHaveTextContent('reloaded page')
})
+
+test('a retained root publishes fresh context with a child fallback', async () => {
+ const retainedStarted = controlled()
+ const retainedReady = controlled()
+ const childStarted = controlled()
+ const childReady = controlled()
+ let retainedLoads = 0
+
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record): { user: string } => ({
+ user: typeof search.user === 'string' ? search.user : 'unknown',
+ }),
+ beforeLoad: async ({ search }) => {
+ if (++retainedLoads > 1) {
+ retainedStarted.resolve()
+ await retainedReady
+ }
+ return { user: search.user }
+ },
+ component: () => (
+
+
{rootRoute.useRouteContext()().user}
+
+
+ ),
+ })
+ const sourceRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/source',
+ component: () => Source
,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/child',
+ loader: async () => {
+ childStarted.resolve()
+ await childReady
+ },
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => Pending
,
+ component: () => Child
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([sourceRoute, childRoute]),
+ history: createMemoryHistory({ initialEntries: ['/source?user=Ada'] }),
+ })
+
+ render(() => )
+ expect(await screen.findByTestId('source')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Ada')
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ const navigation = track(
+ router.navigate({ to: '/child', search: { user: 'Grace' } }),
+ )
+ await retainedStarted
+ expect(screen.getByTestId('source')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Ada')
+ expect(screen.queryByTestId('child-pending')).not.toBeInTheDocument()
+
+ retainedReady.resolve()
+ await childStarted
+ expect(await screen.findByTestId('child-pending')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Grace')
+
+ childReady.resolve()
+ await navigation
+ expect(screen.getByTestId('child')).toBeVisible()
+})
+
+test('a retained prefix exposes one fresh context chain before descendant pending', async () => {
+ const retainedStarted = controlled()
+ const retainedReady = controlled()
+ const pendingStarted = controlled()
+ const pendingReady = controlled()
+ let retainedLoads = 0
+
+ const rootRoute = createRootRoute({
+ beforeLoad: () => ({ rootReady: true }),
+ component: () => ,
+ })
+ const aRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'a',
+ validateSearch: (search: Record): { user: string } => ({
+ user: typeof search.user === 'string' ? search.user : 'unknown',
+ }),
+ beforeLoad: async ({ search }) => {
+ if (++retainedLoads > 1) {
+ retainedStarted.resolve()
+ await retainedReady
+ }
+ return { user: search.user }
+ },
+ component: () => {
+ const user = aRoute.useRouteContext({
+ select: (context) => context.user,
+ })
+ return (
+
+ )
+ },
+ })
+ const bRoute = createRoute({
+ getParentRoute: () => aRoute,
+ path: 'b',
+ component: () => ,
+ })
+ const cRoute = createRoute({
+ getParentRoute: () => bRoute,
+ path: 'c',
+ component: () => ,
+ })
+ const dRoute = createRoute({
+ getParentRoute: () => cRoute,
+ path: 'd',
+ component: () => Source
,
+ })
+ const eRoute = createRoute({
+ getParentRoute: () => aRoute,
+ path: 'e',
+ component: () => {
+ const user = eRoute.useRouteContext({
+ select: (context) => context.user,
+ })
+ return (
+
+ )
+ },
+ })
+ const fRoute = createRoute({
+ getParentRoute: () => eRoute,
+ path: 'f',
+ loader: async () => {
+ pendingStarted.resolve()
+ await pendingReady
+ },
+ pendingComponent: () => {
+ const user = fRoute.useRouteContext({
+ select: (context) => context.user,
+ })
+ return F pending for {user()}
+ },
+ component: () => ,
+ })
+ const gRoute = createRoute({
+ getParentRoute: () => fRoute,
+ path: 'g',
+ component: () => G
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ aRoute.addChildren([
+ bRoute.addChildren([cRoute.addChildren([dRoute])]),
+ eRoute.addChildren([fRoute.addChildren([gRoute])]),
+ ]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/a/b/c/d?user=Ada'] }),
+ defaultPendingMs: 0,
+ defaultPendingMinMs: 0,
+ })
+
+ render(() => )
+ expect(await screen.findByTestId('source')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Ada')
+
+ const navigation = track(
+ router.navigate({
+ to: '/a/e/f/g',
+ search: { user: 'Grace' },
+ }),
+ )
+ await retainedStarted
+
+ expect(screen.getByTestId('source')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Ada')
+ expect(screen.queryByTestId('f-pending')).not.toBeInTheDocument()
+
+ retainedReady.resolve()
+ await pendingStarted
+
+ expect(await screen.findByTestId('f-pending')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Grace')
+ expect(screen.getByTestId('e-user')).toHaveTextContent('Grace')
+ expect(screen.getByTestId('f-pending')).toHaveTextContent('Grace')
+ expect(screen.queryByTestId('source')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('hidden-g')).not.toBeInTheDocument()
+ expect(router.state.matches.map((match) => match.routeId)).toContain(
+ gRoute.id,
+ )
+
+ pendingReady.resolve()
+ await navigation
+})
+
+test.each([false, true])(
+ 'retained loader and component work does not own the child fallback (parent pending: %s)',
+ async (parentHasPending) => {
+ const parentReloadStarted = controlled()
+ const parentReload = controlled()
+ const parentComponent = controlled()
+ const childLoader = controlled()
+ let parentLoads = 0
+ let parentPreloads = 0
+
+ const rootRoute = createRootRoute({ component: () => })
+ const Parent = Object.assign(
+ () => (
+
+ {parentRoute.useLoaderData()()}
+
+
+ ),
+ {
+ preload: () => (++parentPreloads === 1 ? undefined : parentComponent),
+ },
+ )
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'parent',
+ shouldReload: true,
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: async () => {
+ if (++parentLoads === 1) {
+ return 'initial parent'
+ }
+ parentReloadStarted.resolve()
+ await parentReload
+ return 'reloaded parent'
+ },
+ },
+ component: Parent,
+ ...(parentHasPending
+ ? {
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => (
+ Parent pending
+ ),
+ }
+ : {}),
+ })
+ const sourceRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: 'source',
+ component: () => Source
,
+ })
+ const childOptions = createLazyRoute('/parent/child')({
+ pendingComponent: () => (
+ Child pending
+ ),
+ component: () => Child
,
+ })
+ const childLazy = createControlledPromise()
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: 'child',
+ pendingMs: 0,
+ pendingMinMs: 0,
+ loader: () => childLoader,
+ }).lazy(() => childLazy)
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ parentRoute.addChildren([sourceRoute, childRoute]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/parent/source'] }),
+ })
+
+ render(() => )
+ expect(await screen.findByTestId('source')).toBeVisible()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ const navigation = track(router.navigate({ to: '/parent/child' }))
+ try {
+ await parentReloadStarted
+ parentReload.resolve()
+ await waitFor(() => {
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id)
+ ?.isFetching,
+ ).toBe(false)
+ })
+
+ childLazy.resolve(childOptions)
+ expect(await screen.findByTestId('child-pending')).toBeVisible()
+ expect(screen.getByTestId('parent-content')).toBeVisible()
+ expect(screen.queryByTestId('parent-pending')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('source')).not.toBeInTheDocument()
+ } finally {
+ parentReload.resolve()
+ parentComponent.resolve()
+ childLazy.resolve(childOptions)
+ childLoader.resolve()
+ await navigation
+ }
+
+ expect(screen.getByTestId('child')).toBeVisible()
+ },
+)
+
+test('a failure in the last retained guard suppresses descendant pending', async () => {
+ const guardStarted = controlled()
+ const guardReady = controlled()
+ const childReady = controlled()
+ let guardLoads = 0
+ let childLoads = 0
+
+ const rootRoute = createRootRoute({
+ beforeLoad: () => ({ rootReady: true }),
+ component: () => ,
+ })
+ const layoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ id: 'layout',
+ beforeLoad: async () => {
+ if (++guardLoads > 1) {
+ guardStarted.resolve()
+ await guardReady
+ throw new Error('blocked')
+ }
+ },
+ component: () => ,
+ errorComponent: () => Guard error
,
+ })
+ const sourceRoute = createRoute({
+ getParentRoute: () => layoutRoute,
+ path: '/source',
+ component: () => Source
,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => layoutRoute,
+ path: '/child',
+ loader: async () => {
+ childLoads++
+ await childReady
+ },
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => Pending
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ layoutRoute.addChildren([sourceRoute, childRoute]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/source'] }),
+ })
+
+ render(() => )
+ expect(await screen.findByTestId('source')).toBeVisible()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ const navigation = track(router.navigate({ to: '/child' }))
+ await guardStarted
+ expect(screen.getByTestId('source')).toBeVisible()
+ expect(screen.queryByTestId('child-pending')).not.toBeInTheDocument()
+ expect(childLoads).toBe(0)
+
+ guardReady.resolve()
+ await navigation
+ expect(await screen.findByTestId('guard-error')).toBeVisible()
+ expect(screen.queryByTestId('child-pending')).not.toBeInTheDocument()
+ expect(childLoads).toBe(0)
+})
diff --git a/packages/solid-router/tests/public-presentation-lane-contract.test.tsx b/packages/solid-router/tests/public-presentation-lane-contract.test.tsx
new file mode 100644
index 00000000000..267f0049c8e
--- /dev/null
+++ b/packages/solid-router/tests/public-presentation-lane-contract.test.tsx
@@ -0,0 +1,459 @@
+import * as Solid from 'solid-js'
+import { cleanup, render, screen, waitFor } from '@solidjs/testing-library'
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import { createControlledPromise } from '@tanstack/router-core'
+import {
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
+
+describe('public presentation lane contracts', () => {
+ test('a plain load retry presents pending UI over a committed error', async () => {
+ const retryStarted = createControlledPromise()
+ const retry = createControlledPromise()
+ let attempt = 0
+
+ const rootRoute = createRootRoute({ component: () => })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => Retrying page
,
+ loader: () => {
+ if (!attempt++) {
+ throw new Error('Initial failure')
+ }
+ retryStarted.resolve()
+ return retry
+ },
+ errorComponent: () => Page failed
,
+ component: () => {pageRoute.useLoaderData()()}
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([pageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/page'] }),
+ })
+ const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ render(() => )
+ expect(await screen.findByText('Page failed')).toBeInTheDocument()
+
+ let retryLoad!: Promise
+ try {
+ retryLoad = router.load()
+ await retryStarted
+ expect(await screen.findByText('Retrying page')).toBeInTheDocument()
+ expect(screen.queryByText('Page failed')).not.toBeInTheDocument()
+
+ retry.resolve('Page recovered')
+ await retryLoad
+ expect(screen.getByText('Page recovered')).toBeInTheDocument()
+ } finally {
+ retry.resolve('Page recovered')
+ await retryLoad
+ consoleWarn.mockRestore()
+ }
+ })
+
+ test('same-boundary takeover republishes successor search without restarting pendingMinMs', async () => {
+ const firstGate = createControlledPromise()
+ const secondGate = createControlledPromise()
+
+ const rootRoute = createRootRoute({ component: () => })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+ })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ validateSearch: (search: Record) => ({
+ revision: Number(search.revision),
+ }),
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => Loading page
,
+ beforeLoad: ({ search }) =>
+ search.revision === 1 ? firstGate : secondGate,
+ component: () => {
+ const search = pageRoute.useSearch()
+ return Page revision {search().revision}
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, 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 successorSettled = false
+ let settledAtOriginalDeadline = false
+ let renderedAtOriginalDeadline = false
+ try {
+ void router.navigate({
+ to: '/page',
+ search: { revision: 1 },
+ })
+ await vi.advanceTimersByTimeAsync(0)
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(router.state.matches.at(-1)?.search).toMatchObject({ revision: 1 })
+
+ await vi.advanceTimersByTimeAsync(25)
+
+ const secondNavigation = router.navigate({
+ to: '/page',
+ search: { revision: 2 },
+ })
+ await vi.advanceTimersByTimeAsync(0)
+
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(router.state.location.search).toMatchObject({ revision: 2 })
+ expect(router.state.matches.at(-1)?.search).toMatchObject({ revision: 2 })
+
+ void secondNavigation.then(() => {
+ successorSettled = true
+ })
+ secondGate.resolve()
+ await Promise.resolve()
+
+ await vi.advanceTimersByTimeAsync(74)
+ expect(successorSettled).toBe(false)
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(5)
+ await Promise.resolve()
+
+ settledAtOriginalDeadline = successorSettled
+ renderedAtOriginalDeadline =
+ screen.queryByText('Page revision 2') !== null
+ } finally {
+ firstGate.resolve()
+ secondGate.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await Promise.resolve()
+ }
+
+ expect({
+ settled: settledAtOriginalDeadline,
+ rendered: renderedAtOriginalDeadline,
+ }).toEqual({ settled: true, rendered: true })
+ expect(screen.getByText('Page revision 2')).toBeInTheDocument()
+ expect(screen.queryByText('Loading page')).not.toBeInTheDocument()
+ })
+
+ test('an earlier pending-ineligible boundary retires a deeper pending minimum', async () => {
+ const childReloadStarted = createControlledPromise()
+ const childReload = createControlledPromise()
+ const parentReloadStarted = createControlledPromise()
+ const parentReload = createControlledPromise()
+ let childLoads = 0
+
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record) => ({
+ revision: Number(search.revision),
+ }),
+ component: () => ,
+ })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loaderDeps: ({ search }) => ({ revision: search.revision }),
+ beforeLoad: ({ search }) => {
+ if (search.revision === 2) {
+ parentReloadStarted.resolve()
+ return parentReload
+ }
+ return undefined
+ },
+ component: () => ,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => Loading child
,
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: () => {
+ if (childLoads++) {
+ childReloadStarted.resolve()
+ return childReload
+ }
+ return undefined
+ },
+ },
+ component: () => (
+ Child revision {childRoute.useSearch()().revision}
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?revision=1'],
+ }),
+ })
+
+ render(() => )
+ expect(await screen.findByText('Child revision 1')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ let firstNavigation: Promise | undefined
+ let secondNavigation: Promise | undefined
+ let settledBeforeOldMinimum = false
+ let renderedBeforeOldMinimum = false
+ try {
+ firstNavigation = router.invalidate({
+ filter: (match) => match.routeId === childRoute.id,
+ forcePending: true,
+ })
+ await childReloadStarted
+ await vi.advanceTimersByTimeAsync(0)
+ expect(screen.getByText('Loading child')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(25)
+ secondNavigation = router.navigate({
+ to: '/parent/child',
+ search: { revision: 2 },
+ })
+ await parentReloadStarted
+ expect(screen.getByText('Loading child')).toBeInTheDocument()
+
+ childReload.resolve()
+
+ const successor = secondNavigation
+ void successor.then(() => {
+ settledBeforeOldMinimum = true
+ })
+ parentReload.resolve()
+ await vi.advanceTimersByTimeAsync(5)
+ renderedBeforeOldMinimum = screen.queryByText('Child revision 2') !== null
+ } finally {
+ childReload.resolve()
+ parentReload.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await Promise.allSettled(
+ [firstNavigation, secondNavigation].filter(
+ (navigation): navigation is Promise => !!navigation,
+ ),
+ )
+ }
+
+ expect({
+ settled: settledBeforeOldMinimum,
+ rendered: renderedBeforeOldMinimum,
+ }).toEqual({ settled: true, rendered: true })
+ })
+
+ test('same-boundary timing survives a private retained-context barrier', async () => {
+ const retainedStarted = createControlledPromise()
+ const retainedReady = createControlledPromise()
+ const firstPage = createControlledPromise()
+ const secondPageStarted = createControlledPromise()
+ const secondPage = createControlledPromise()
+
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record) => ({
+ revision: Number(search.revision) || 0,
+ }),
+ beforeLoad: ({ search }) => {
+ if (search.revision === 2) {
+ retainedStarted.resolve()
+ return retainedReady.then(() => ({ rootRevision: 2 }))
+ }
+ return { rootRevision: search.revision }
+ },
+ component: () => {
+ const rootRevision = rootRoute.useRouteContext({
+ select: (context) => context.rootRevision,
+ })
+ return (
+
+
Root revision {rootRevision()}
+
+
+ )
+ },
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+ })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => Loading page
,
+ beforeLoad: ({ search }) => {
+ if (search.revision === 1) {
+ return firstPage
+ }
+ secondPageStarted.resolve()
+ return secondPage
+ },
+ component: () => (
+ Page revision {pageRoute.useSearch()().revision}
+ ),
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, pageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render(() => )
+ expect(await screen.findByText('Home')).toBeInTheDocument()
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ let firstNavigation: Promise | undefined
+ let secondNavigation: Promise | undefined
+ try {
+ firstNavigation = router.navigate({
+ to: '/page',
+ search: { revision: 1 },
+ })
+ await vi.advanceTimersByTimeAsync(0)
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(screen.getByText('Root revision 1')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(25)
+ secondNavigation = router.navigate({
+ to: '/page',
+ search: { revision: 2 },
+ })
+ await retainedStarted
+
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(screen.getByText('Root revision 1')).toBeInTheDocument()
+
+ retainedReady.resolve()
+ await secondPageStarted
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(screen.getByText('Root revision 2')).toBeInTheDocument()
+
+ let settled = false
+ const successor = secondNavigation
+ void successor.then(() => {
+ settled = true
+ })
+ secondPage.resolve()
+ await vi.advanceTimersByTimeAsync(74)
+ expect(settled).toBe(false)
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(5)
+ await Promise.all([firstNavigation, successor])
+ expect(screen.getByText('Page revision 2')).toBeInTheDocument()
+ } finally {
+ retainedReady.resolve()
+ firstPage.resolve()
+ secondPage.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await Promise.allSettled(
+ [firstNavigation, secondNavigation].filter(
+ (navigation): navigation is Promise => !!navigation,
+ ),
+ )
+ }
+ })
+
+ test('a successor supersedes the previous receipt while joining its transition', async () => {
+ const firstRenderStarted = createControlledPromise()
+ const firstRender = createControlledPromise()
+ const secondRenderStarted = createControlledPromise()
+ const secondRender = createControlledPromise()
+ let setRevision!: Solid.Setter
+
+ const Revision = (props: { revision: number }) => {
+ const [rendered] = Solid.createResource(async () => {
+ if (props.revision === 2) {
+ firstRenderStarted.resolve()
+ await firstRender
+ } else if (props.revision === 3) {
+ secondRenderStarted.resolve()
+ await secondRender
+ }
+ return props.revision
+ })
+ return Revision {rendered()}
+ }
+ const rootRoute = createRootRoute({
+ component: () => {
+ const [revision, set] = Solid.createSignal(1)
+ setRevision = set
+ return (
+
+ {(value) => }
+
+ )
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute,
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render(() => )
+ expect(await screen.findByText('Revision 1')).toBeInTheDocument()
+
+ const expected = router.state.matches
+ const firstAcknowledgement = router.startTransition(
+ () => setRevision(2),
+ expected,
+ )
+ const acknowledgements = [firstAcknowledgement]
+ try {
+ await firstRenderStarted
+ const secondAcknowledgement = router.startTransition(
+ () => setRevision(3),
+ expected,
+ )
+ acknowledgements.push(secondAcknowledgement)
+
+ await expect(firstAcknowledgement).resolves.toBe(false)
+ await secondRenderStarted
+ expect(screen.getByText('Revision 1')).toBeInTheDocument()
+
+ let secondSettled = false
+ void secondAcknowledgement.then(() => {
+ secondSettled = true
+ })
+ secondRender.resolve()
+ await Promise.resolve()
+ await Promise.resolve()
+
+ expect(secondSettled).toBe(false)
+ expect(screen.queryByText('Revision 3')).not.toBeInTheDocument()
+
+ firstRender.resolve()
+ await expect(secondAcknowledgement).resolves.toBe(true)
+ expect(await screen.findByText('Revision 3')).toBeInTheDocument()
+ expect(screen.queryByText('Revision 2')).not.toBeInTheDocument()
+ } finally {
+ firstRender.resolve()
+ secondRender.resolve()
+ await Promise.allSettled(acknowledgements)
+ }
+ })
+})
diff --git a/packages/solid-router/tests/transitioner-render-ack.test.tsx b/packages/solid-router/tests/transitioner-render-ack.test.tsx
index 634eec52daf..c766bf157a5 100644
--- a/packages/solid-router/tests/transitioner-render-ack.test.tsx
+++ b/packages/solid-router/tests/transitioner-render-ack.test.tsx
@@ -273,6 +273,92 @@ test('a superseded suspended generation does not emit onRendered', async () => {
expect(renderedRevisions).toEqual([2])
})
+test('a suspending all-success successor ignores stale resource resolution', async () => {
+ const firstRenderStarted = createControlledPromise()
+ const firstRenderGate = createControlledPromise()
+ const secondRenderStarted = createControlledPromise()
+ const secondRenderGate = createControlledPromise()
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record) => ({
+ revision: Number(search.revision ?? 0),
+ }),
+ component: () => {
+ const search = rootRoute.useSearch()
+ const [revision] = Solid.createResource(
+ () => search().revision,
+ async (nextRevision) => {
+ if (nextRevision === 1) {
+ firstRenderStarted.resolve()
+ await firstRenderGate
+ } else if (nextRevision === 2) {
+ secondRenderStarted.resolve()
+ await secondRenderGate
+ }
+ return nextRevision
+ },
+ )
+ return Root revision {revision()}
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute,
+ history: createMemoryHistory({ initialEntries: ['/?revision=0'] }),
+ })
+
+ render(() => )
+ expect(await screen.findByText('Root revision 0')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ const renderedRevisions: Array = []
+ const unsubscribe = router.subscribe('onRendered', (event) => {
+ renderedRevisions.push(
+ Number((event.toLocation.search as Record).revision),
+ )
+ })
+ const navigations: Array> = []
+ onTestFinished(async () => {
+ unsubscribe()
+ firstRenderGate.resolve()
+ secondRenderGate.resolve()
+ await Promise.allSettled(navigations)
+ })
+
+ const firstNavigation = router.navigate({
+ to: '/',
+ search: { revision: 1 },
+ })
+ navigations.push(firstNavigation)
+ await firstRenderStarted
+
+ const secondNavigation = router.navigate({
+ to: '/',
+ search: { revision: 2 },
+ })
+ navigations.push(secondNavigation)
+ await secondRenderStarted
+
+ let successorSettled = false
+ void secondNavigation.then(() => {
+ successorSettled = true
+ })
+ await Promise.resolve()
+ expect(successorSettled).toBe(false)
+ expect(renderedRevisions).toEqual([])
+
+ firstRenderGate.resolve()
+ await Promise.resolve()
+ expect(successorSettled).toBe(false)
+ expect(screen.queryByText('Root revision 1')).not.toBeInTheDocument()
+ expect(renderedRevisions).toEqual([])
+
+ secondRenderGate.resolve()
+ await Promise.all([firstNavigation, secondNavigation])
+
+ expect(await screen.findByText('Root revision 2')).toBeInTheDocument()
+ expect(screen.queryByText('Root revision 1')).not.toBeInTheDocument()
+ expect(renderedRevisions).toEqual([2])
+})
+
test('an older rendered destination cannot resolve a superseding navigation', async () => {
const nextLoader = createControlledPromise()
const rootRoute = createRootRoute({ component: () => })
diff --git a/packages/vue-router/tests/hydration-terminal-lane.test.tsx b/packages/vue-router/tests/hydration-terminal-lane.test.tsx
new file mode 100644
index 00000000000..f24b3f2ac78
--- /dev/null
+++ b/packages/vue-router/tests/hydration-terminal-lane.test.tsx
@@ -0,0 +1,91 @@
+import { cleanup, render, screen } from '@testing-library/vue'
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import { nextTick } from 'vue'
+import { hydrate } from '@tanstack/router-core/ssr/client'
+import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id'
+import {
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+} from '../src'
+import type { AnyRouteMatch } from '@tanstack/router-core'
+import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client'
+
+function bootstrap(
+ matches: Array<{
+ match: AnyRouteMatch
+ status: AnyRouteMatch['status']
+ ssr: AnyRouteMatch['ssr']
+ data?: unknown
+ error?: unknown
+ notFound?: boolean
+ }>,
+): void {
+ window.$_TSR = {
+ router: {
+ manifest: undefined,
+ matches: matches.map(({ match, status, ssr, data, error, notFound }) => ({
+ i: dehydrateSsrMatchId(match.id),
+ l: data,
+ e: error,
+ s: status,
+ ssr,
+ u: Date.now(),
+ ...(notFound ? { g: true } : {}),
+ })),
+ },
+ h: vi.fn(),
+ e: vi.fn(),
+ c: vi.fn(),
+ p: vi.fn(),
+ buffer: [],
+ } as TsrSsrGlobal
+}
+
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ delete window.$_TSR
+})
+
+describe('hydration terminal lane', () => {
+ test('keeps a hydrated pending fallback through its minimum before a terminal result', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+ const rootRoute = createRootRoute({
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => Missing page pending
,
+ notFoundComponent: () => Missing page
,
+ })
+ const router = createRouter({
+ history: createMemoryHistory({ initialEntries: ['/missing'] }),
+ routeTree: rootRoute,
+ })
+ const matches = router.matchRoutes(router.state.location)
+ expect(matches[0]?._notFound).toBe(true)
+ bootstrap([
+ {
+ match: matches[0]!,
+ status: 'pending',
+ ssr: false,
+ notFound: true,
+ },
+ ])
+
+ await hydrate(router)
+ render()
+ await nextTick()
+ expect(screen.getByRole('status')).toHaveTextContent('Missing page pending')
+
+ await vi.advanceTimersByTimeAsync(99)
+ await nextTick()
+ expect(screen.getByRole('status')).toHaveTextContent('Missing page pending')
+ expect(screen.queryByText('Missing page')).not.toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(5)
+ await nextTick()
+ expect(screen.getByText('Missing page')).toBeInTheDocument()
+ })
+})
diff --git a/packages/vue-router/tests/issue-4467-lazy-route-pending.test.tsx b/packages/vue-router/tests/issue-4467-lazy-route-pending.test.tsx
new file mode 100644
index 00000000000..7c5c131783b
--- /dev/null
+++ b/packages/vue-router/tests/issue-4467-lazy-route-pending.test.tsx
@@ -0,0 +1,87 @@
+import { cleanup, render, screen } from '@testing-library/vue'
+import { afterEach, expect, test, vi } from 'vitest'
+import { nextTick } from 'vue'
+import { createControlledPromise } from '@tanstack/router-core'
+import {
+ Outlet,
+ RouterProvider,
+ createLazyRoute,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+} from '../src'
+
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+})
+
+test('a lazy pending component does not restart an acknowledged minimum', async () => {
+ const loader = createControlledPromise()
+ const lazyPageOptions = createLazyRoute('/page')({
+ pendingComponent: () => Loading lazy page
,
+ component: () => Page
,
+ })
+ const lazyOptions = createControlledPromise()
+ const rootRoute = createRootRoute({ component: () => })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index page
,
+ })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ loader: () => loader,
+ }).lazy(() => lazyOptions)
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, pageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ defaultPendingMs: 0,
+ defaultPendingMinMs: 100,
+ defaultPendingComponent: () => Loading default
,
+ })
+
+ render()
+ expect(
+ await screen.findByRole('heading', { name: 'Index page' }),
+ ).toBeInTheDocument()
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ const navigation = router.navigate({ to: '/page' })
+ let settled = false
+ void navigation.then(() => {
+ settled = true
+ })
+ try {
+ await vi.advanceTimersByTimeAsync(0)
+ await nextTick()
+ expect(screen.getByRole('status')).toHaveTextContent('Loading default')
+
+ await vi.advanceTimersByTimeAsync(25)
+ lazyOptions.resolve(lazyPageOptions)
+ loader.resolve()
+ await vi.advanceTimersByTimeAsync(0)
+ await nextTick()
+ expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page')
+
+ await vi.advanceTimersByTimeAsync(74)
+ await nextTick()
+ expect(screen.getByRole('status')).toHaveTextContent('Loading lazy page')
+
+ await vi.advanceTimersByTimeAsync(5)
+ await Promise.resolve()
+ await nextTick()
+ expect(settled).toBe(true)
+ await navigation
+ expect(screen.getByRole('heading', { name: 'Page' })).toBeInTheDocument()
+ expect(Date.now()).toBeLessThan(125)
+ } finally {
+ lazyOptions.resolve(lazyPageOptions)
+ loader.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await navigation
+ }
+})
diff --git a/packages/vue-router/tests/issue-7367-pending-min-redirect.test.tsx b/packages/vue-router/tests/issue-7367-pending-min-redirect.test.tsx
new file mode 100644
index 00000000000..f664a083032
--- /dev/null
+++ b/packages/vue-router/tests/issue-7367-pending-min-redirect.test.tsx
@@ -0,0 +1,133 @@
+import { cleanup, render, screen } from '@testing-library/vue'
+import { afterEach, expect, test, vi } from 'vitest'
+import { nextTick } from 'vue'
+import { createControlledPromise } from '@tanstack/router-core'
+import {
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ redirect,
+} from '../src'
+
+afterEach(() => {
+ vi.restoreAllMocks()
+ cleanup()
+ vi.useRealTimers()
+})
+
+test('a compatible SPA redirect preserves the acknowledged pending minimum', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+ const redirectReady = createControlledPromise()
+ let shouldRedirect = true
+
+ const rootRoute = createRootRoute({
+ component: () => ,
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => loading
,
+ beforeLoad: async () => {
+ if (shouldRedirect) {
+ shouldRedirect = false
+ await redirectReady
+ throw redirect({ to: '/welcome', replace: true })
+ }
+ },
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index
,
+ })
+ const welcomeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/welcome',
+ component: () => Welcome
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, welcomeRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ try {
+ render()
+ await vi.advanceTimersByTimeAsync(0)
+ await nextTick()
+ expect(screen.getByTestId('pending')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(25)
+ redirectReady.resolve()
+ await vi.advanceTimersByTimeAsync(74)
+ await nextTick()
+ expect(screen.getByTestId('pending')).toBeInTheDocument()
+ expect(screen.queryByTestId('welcome-page')).not.toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(5)
+ await nextTick()
+ expect(screen.getByTestId('welcome-page')).toBeInTheDocument()
+ } finally {
+ redirectReady.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await nextTick()
+ }
+})
+
+test('an incompatible SPA redirect does not inherit the pending minimum', async () => {
+ const redirectReady = createControlledPromise()
+ const rootRoute = createRootRoute({ component: () => })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Index
,
+ })
+ const sourceRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/source',
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => loading
,
+ beforeLoad: async () => {
+ await redirectReady
+ throw redirect({ to: '/welcome', replace: true })
+ },
+ })
+ const welcomeRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/welcome',
+ component: () => Welcome
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, sourceRoute, welcomeRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+ expect(await screen.findByTestId('index-page')).toBeVisible()
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ const navigation = router.navigate({ to: '/source' })
+ try {
+ await vi.advanceTimersByTimeAsync(0)
+ await nextTick()
+ expect(screen.getByTestId('pending')).toBeVisible()
+
+ await vi.advanceTimersByTimeAsync(25)
+ redirectReady.resolve()
+ await vi.advanceTimersByTimeAsync(5)
+ await navigation
+ await nextTick()
+
+ expect(Date.now()).toBeLessThan(100)
+ expect(screen.getByTestId('welcome-page')).toBeVisible()
+ expect(screen.queryByTestId('index-page')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
+ } finally {
+ redirectReady.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await navigation
+ }
+})
diff --git a/packages/vue-router/tests/issue-7986-retained-pending.test.tsx b/packages/vue-router/tests/issue-7986-retained-pending.test.tsx
index 57e94e2ad03..0cbfaddc27f 100644
--- a/packages/vue-router/tests/issue-7986-retained-pending.test.tsx
+++ b/packages/vue-router/tests/issue-7986-retained-pending.test.tsx
@@ -447,10 +447,21 @@ test('a success hidden below an error boundary retries through pending UI', asyn
expect(screen.getByTestId('content')).toHaveTextContent('reloaded child')
})
-test('a global not-found destination does not retain the mounted root success', async () => {
+test('a global not-found destination keeps pending until its terminal component is ready', async () => {
const missingStarted = controlled()
const missingLoader = controlled()
+ const terminalStarted = controlled()
+ const terminalReady = controlled()
let loaderCalls = 0
+ const Missing = Object.assign(
+ () => Missing
,
+ {
+ preload: () => {
+ terminalStarted.resolve()
+ return terminalReady
+ },
+ },
+ )
const rootRoute = createRootRoute({
shouldReload: true,
@@ -466,7 +477,7 @@ test('a global not-found destination does not retain the mounted root success',
},
component: () => ,
pendingComponent: () => Pending root
,
- notFoundComponent: () => Missing
,
+ notFoundComponent: Missing,
})
const pageRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -490,7 +501,18 @@ test('a global not-found destination does not retain the mounted root success',
expect(await screen.findByTestId('pending')).toBeVisible()
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
+ let settled = false
+ void navigation.then(() => {
+ settled = true
+ })
missingLoader.resolve()
+ await terminalStarted
+ await nextTick()
+ expect(screen.getByTestId('pending')).toBeVisible()
+ expect(screen.queryByTestId('missing')).not.toBeInTheDocument()
+ expect(settled).toBe(false)
+
+ terminalReady.resolve()
await navigation
await nextTick()
@@ -498,6 +520,39 @@ test('a global not-found destination does not retain the mounted root success',
expect(screen.getByTestId('missing')).toBeVisible()
})
+test('a cold global not-found presents pending only while its terminal component loads', async () => {
+ const terminalStarted = controlled()
+ const terminalReady = controlled()
+ const Missing = Object.assign(
+ () => Missing
,
+ {
+ preload: () => {
+ terminalStarted.resolve()
+ return terminalReady
+ },
+ },
+ )
+ const rootRoute = createRootRoute({
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => Pending root
,
+ notFoundComponent: Missing,
+ })
+ const router = createRouter({
+ routeTree: rootRoute,
+ history: createMemoryHistory({ initialEntries: ['/missing'] }),
+ })
+
+ render()
+ await terminalStarted
+ expect(await screen.findByTestId('pending')).toBeVisible()
+ expect(screen.queryByTestId('missing')).not.toBeInTheDocument()
+
+ terminalReady.resolve()
+ expect(await screen.findByTestId('missing')).toBeVisible()
+ expect(screen.queryByTestId('pending')).not.toBeInTheDocument()
+})
+
test('lazy fuzzy-boundary relocation retains the mounted parent', async () => {
const lazyStarted = controlled()
const lazyRoute = controlled()
@@ -658,3 +713,382 @@ test('a superseding navigation replaces an unrelated pending presentation', asyn
expect(screen.getByTestId('content')).toBeVisible()
expect(screen.getByTestId('content')).toHaveTextContent('reloaded page')
})
+
+test('a retained root publishes fresh context with a child fallback', async () => {
+ const retainedStarted = controlled()
+ const retainedReady = controlled()
+ const childStarted = controlled()
+ const childReady = controlled()
+ let retainedLoads = 0
+
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record): { user: string } => ({
+ user: typeof search.user === 'string' ? search.user : 'unknown',
+ }),
+ beforeLoad: async ({ search }) => {
+ if (++retainedLoads > 1) {
+ retainedStarted.resolve()
+ await retainedReady
+ }
+ return { user: search.user }
+ },
+ component: () => {
+ const context = rootRoute.useRouteContext()
+ return (
+
+
{context.value.user}
+
+
+ )
+ },
+ })
+ const sourceRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/source',
+ component: () => Source
,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/child',
+ loader: async () => {
+ childStarted.resolve()
+ await childReady
+ },
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => Pending
,
+ component: () => Child
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([sourceRoute, childRoute]),
+ history: createMemoryHistory({ initialEntries: ['/source?user=Ada'] }),
+ })
+
+ render()
+ expect(await screen.findByTestId('source')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Ada')
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ const navigation = track(
+ router.navigate({ to: '/child', search: { user: 'Grace' } }),
+ )
+ await retainedStarted
+ await nextTick()
+ expect(screen.getByTestId('source')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Ada')
+ expect(screen.queryByTestId('child-pending')).not.toBeInTheDocument()
+
+ retainedReady.resolve()
+ await childStarted
+ await nextTick()
+ expect(await screen.findByTestId('child-pending')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Grace')
+
+ childReady.resolve()
+ await navigation
+ await nextTick()
+ expect(screen.getByTestId('child')).toBeVisible()
+})
+
+test('a retained prefix exposes one fresh context chain before descendant pending', async () => {
+ const retainedStarted = controlled()
+ const retainedReady = controlled()
+ const pendingStarted = controlled()
+ const pendingReady = controlled()
+ let retainedLoads = 0
+
+ const rootRoute = createRootRoute({
+ beforeLoad: () => ({ rootReady: true }),
+ component: () => ,
+ })
+ const aRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'a',
+ validateSearch: (search: Record): { user: string } => ({
+ user: typeof search.user === 'string' ? search.user : 'unknown',
+ }),
+ beforeLoad: async ({ search }) => {
+ if (++retainedLoads > 1) {
+ retainedStarted.resolve()
+ await retainedReady
+ }
+ return { user: search.user }
+ },
+ component: () => {
+ const context = aRoute.useRouteContext()
+ return (
+
+
{context.value.user}
+
+
+ )
+ },
+ })
+ const bRoute = createRoute({
+ getParentRoute: () => aRoute,
+ path: 'b',
+ component: () => ,
+ })
+ const cRoute = createRoute({
+ getParentRoute: () => bRoute,
+ path: 'c',
+ component: () => ,
+ })
+ const dRoute = createRoute({
+ getParentRoute: () => cRoute,
+ path: 'd',
+ component: () => Source
,
+ })
+ const eRoute = createRoute({
+ getParentRoute: () => aRoute,
+ path: 'e',
+ component: () => {
+ const context = eRoute.useRouteContext()
+ return (
+
+
{context.value.user}
+
+
+ )
+ },
+ })
+ const fRoute = createRoute({
+ getParentRoute: () => eRoute,
+ path: 'f',
+ loader: async () => {
+ pendingStarted.resolve()
+ await pendingReady
+ },
+ pendingComponent: () => {
+ const context = fRoute.useRouteContext()
+ return (
+ F pending for {context.value.user}
+ )
+ },
+ component: () => ,
+ })
+ const gRoute = createRoute({
+ getParentRoute: () => fRoute,
+ path: 'g',
+ component: () => G
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ aRoute.addChildren([
+ bRoute.addChildren([cRoute.addChildren([dRoute])]),
+ eRoute.addChildren([fRoute.addChildren([gRoute])]),
+ ]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/a/b/c/d?user=Ada'] }),
+ defaultPendingMs: 0,
+ defaultPendingMinMs: 0,
+ })
+
+ render()
+ expect(await screen.findByTestId('source')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Ada')
+
+ const navigation = track(
+ router.navigate({
+ to: '/a/e/f/g',
+ search: { user: 'Grace' },
+ }),
+ )
+ await retainedStarted
+ await nextTick()
+
+ expect(screen.getByTestId('source')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Ada')
+ expect(screen.queryByTestId('f-pending')).not.toBeInTheDocument()
+
+ retainedReady.resolve()
+ await pendingStarted
+ await nextTick()
+
+ expect(await screen.findByTestId('f-pending')).toBeVisible()
+ expect(screen.getByTestId('user')).toHaveTextContent('Grace')
+ expect(screen.getByTestId('e-user')).toHaveTextContent('Grace')
+ expect(screen.getByTestId('f-pending')).toHaveTextContent('Grace')
+ expect(screen.queryByTestId('source')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('hidden-g')).not.toBeInTheDocument()
+ expect(router.state.matches.map((match) => match.routeId)).toContain(
+ gRoute.id,
+ )
+
+ pendingReady.resolve()
+ await navigation
+})
+
+test.each([false, true])(
+ 'retained loader and component work does not own the child fallback (parent pending: %s)',
+ async (parentHasPending) => {
+ const parentReloadStarted = controlled()
+ const parentReload = controlled()
+ const parentComponent = controlled()
+ const childLoader = controlled()
+ let parentLoads = 0
+ let parentPreloads = 0
+
+ const rootRoute = createRootRoute({ component: () => })
+ const Parent = Object.assign(
+ () => {
+ const loaderData = parentRoute.useLoaderData()
+ return (
+
+ {loaderData.value}
+
+
+ )
+ },
+ {
+ preload: () => (++parentPreloads === 1 ? undefined : parentComponent),
+ },
+ )
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'parent',
+ shouldReload: true,
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: async () => {
+ if (++parentLoads === 1) {
+ return 'initial parent'
+ }
+ parentReloadStarted.resolve()
+ await parentReload
+ return 'reloaded parent'
+ },
+ },
+ component: Parent,
+ ...(parentHasPending
+ ? {
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => (
+ Parent pending
+ ),
+ }
+ : {}),
+ })
+ const sourceRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: 'source',
+ component: () => Source
,
+ })
+ const childOptions = createLazyRoute('/parent/child')({
+ pendingComponent: () => (
+ Child pending
+ ),
+ component: () => Child
,
+ })
+ const childLazy = createControlledPromise()
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: 'child',
+ pendingMs: 0,
+ pendingMinMs: 0,
+ loader: () => childLoader,
+ }).lazy(() => childLazy)
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ parentRoute.addChildren([sourceRoute, childRoute]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/parent/source'] }),
+ })
+
+ render()
+ expect(await screen.findByTestId('source')).toBeVisible()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ const navigation = track(router.navigate({ to: '/parent/child' }))
+ try {
+ await parentReloadStarted
+ parentReload.resolve()
+ await waitFor(() => {
+ expect(
+ router.state.matches.find((match) => match.routeId === parentRoute.id)
+ ?.isFetching,
+ ).toBe(false)
+ })
+
+ childLazy.resolve(childOptions)
+ expect(await screen.findByTestId('child-pending')).toBeVisible()
+ expect(screen.getByTestId('parent-content')).toBeVisible()
+ expect(screen.queryByTestId('parent-pending')).not.toBeInTheDocument()
+ expect(screen.queryByTestId('source')).not.toBeInTheDocument()
+ } finally {
+ parentReload.resolve()
+ parentComponent.resolve()
+ childLazy.resolve(childOptions)
+ childLoader.resolve()
+ await navigation
+ }
+
+ expect(await screen.findByTestId('child')).toBeVisible()
+ },
+)
+
+test('a failure in the last retained guard suppresses descendant pending', async () => {
+ const guardStarted = controlled()
+ const guardReady = controlled()
+ const childReady = controlled()
+ let guardLoads = 0
+ let childLoads = 0
+
+ const rootRoute = createRootRoute({
+ beforeLoad: () => ({ rootReady: true }),
+ component: () => ,
+ })
+ const layoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ id: 'layout',
+ beforeLoad: async () => {
+ if (++guardLoads > 1) {
+ guardStarted.resolve()
+ await guardReady
+ throw new Error('blocked')
+ }
+ },
+ component: () => ,
+ errorComponent: () => Guard error
,
+ })
+ const sourceRoute = createRoute({
+ getParentRoute: () => layoutRoute,
+ path: '/source',
+ component: () => Source
,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => layoutRoute,
+ path: '/child',
+ loader: async () => {
+ childLoads++
+ await childReady
+ },
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => Pending
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([
+ layoutRoute.addChildren([sourceRoute, childRoute]),
+ ]),
+ history: createMemoryHistory({ initialEntries: ['/source'] }),
+ })
+
+ render()
+ expect(await screen.findByTestId('source')).toBeVisible()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+
+ const navigation = track(router.navigate({ to: '/child' }))
+ await guardStarted
+ await nextTick()
+ expect(screen.getByTestId('source')).toBeVisible()
+ expect(screen.queryByTestId('child-pending')).not.toBeInTheDocument()
+ expect(childLoads).toBe(0)
+
+ guardReady.resolve()
+ await navigation
+ expect(await screen.findByTestId('guard-error')).toBeVisible()
+ expect(screen.queryByTestId('child-pending')).not.toBeInTheDocument()
+ expect(childLoads).toBe(0)
+})
diff --git a/packages/vue-router/tests/public-presentation-lane-contract.test.tsx b/packages/vue-router/tests/public-presentation-lane-contract.test.tsx
new file mode 100644
index 00000000000..9a539139b0d
--- /dev/null
+++ b/packages/vue-router/tests/public-presentation-lane-contract.test.tsx
@@ -0,0 +1,467 @@
+import { cleanup, render, screen, waitFor } from '@testing-library/vue'
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import { defineComponent, nextTick } from 'vue'
+import { createControlledPromise } from '@tanstack/router-core'
+import {
+ Outlet,
+ RouterProvider,
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ notFound,
+} from '../src'
+
+afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+})
+
+describe('public presentation lane contracts', () => {
+ test('a plain load retry presents pending UI over a committed error', async () => {
+ const retryStarted = createControlledPromise()
+ const retry = createControlledPromise()
+ let attempt = 0
+
+ const rootRoute = createRootRoute({ component: () => })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ pendingMs: 0,
+ pendingMinMs: 0,
+ pendingComponent: () => Retrying page
,
+ loader: () => {
+ if (!attempt++) {
+ throw new Error('Initial failure')
+ }
+ retryStarted.resolve()
+ return retry
+ },
+ errorComponent: () => Page failed
,
+ component: () => {
+ const loaderData = pageRoute.useLoaderData()
+ return {loaderData.value}
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([pageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/page'] }),
+ })
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ render()
+ expect(await screen.findByText('Page failed')).toBeInTheDocument()
+
+ let retryLoad!: Promise
+ try {
+ retryLoad = router.load()
+ await retryStarted
+ await nextTick()
+ expect(screen.getByText('Retrying page')).toBeInTheDocument()
+ expect(screen.queryByText('Page failed')).not.toBeInTheDocument()
+
+ retry.resolve('Page recovered')
+ await retryLoad
+ await nextTick()
+ expect(screen.getByText('Page recovered')).toBeInTheDocument()
+ } finally {
+ retry.resolve('Page recovered')
+ await retryLoad
+ }
+ })
+
+ test('same-boundary takeover republishes successor search without restarting pendingMinMs', async () => {
+ const firstGate = createControlledPromise()
+ const secondGate = createControlledPromise()
+
+ const rootRoute = createRootRoute({ component: () => })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+ })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ validateSearch: (search: Record) => ({
+ revision: Number(search.revision),
+ }),
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => Loading page
,
+ beforeLoad: ({ search }) =>
+ search.revision === 1 ? firstGate : secondGate,
+ component: () => {
+ const search = pageRoute.useSearch()
+ return Page revision {search.value.revision}
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, 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 successorSettled = false
+ let settledAtOriginalDeadline = false
+ let renderedAtOriginalDeadline = false
+ try {
+ void router.navigate({
+ to: '/page',
+ search: { revision: 1 },
+ })
+ await vi.advanceTimersByTimeAsync(0)
+ await nextTick()
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(router.state.matches.at(-1)?.search).toMatchObject({ revision: 1 })
+
+ await vi.advanceTimersByTimeAsync(25)
+
+ const secondNavigation = router.navigate({
+ to: '/page',
+ search: { revision: 2 },
+ })
+ await vi.advanceTimersByTimeAsync(0)
+ await nextTick()
+
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(router.state.location.search).toMatchObject({ revision: 2 })
+ expect(router.state.matches.at(-1)?.search).toMatchObject({ revision: 2 })
+
+ void secondNavigation.then(() => {
+ successorSettled = true
+ })
+ secondGate.resolve()
+ await Promise.resolve()
+
+ await vi.advanceTimersByTimeAsync(74)
+ await nextTick()
+ expect(successorSettled).toBe(false)
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(5)
+ await Promise.resolve()
+ await nextTick()
+
+ settledAtOriginalDeadline = successorSettled
+ renderedAtOriginalDeadline =
+ screen.queryByText('Page revision 2') !== null
+ } finally {
+ firstGate.resolve()
+ secondGate.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await nextTick()
+ }
+
+ expect({
+ settled: settledAtOriginalDeadline,
+ rendered: renderedAtOriginalDeadline,
+ }).toEqual({ settled: true, rendered: true })
+ expect(screen.getByText('Page revision 2')).toBeInTheDocument()
+ expect(screen.queryByText('Loading page')).not.toBeInTheDocument()
+ })
+
+ test('an earlier pending-ineligible boundary retires a deeper pending minimum', async () => {
+ const childReloadStarted = createControlledPromise()
+ const childReload = createControlledPromise()
+ const parentReloadStarted = createControlledPromise()
+ const parentReload = createControlledPromise()
+ let childLoads = 0
+
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record) => ({
+ revision: Number(search.revision),
+ }),
+ component: () => ,
+ })
+ const parentRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/parent',
+ loaderDeps: ({ search }) => ({ revision: search.revision }),
+ beforeLoad: ({ search }) => {
+ if (search.revision === 2) {
+ parentReloadStarted.resolve()
+ return parentReload
+ }
+ return undefined
+ },
+ component: () => ,
+ })
+ const childRoute = createRoute({
+ getParentRoute: () => parentRoute,
+ path: '/child',
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => Loading child
,
+ loader: {
+ staleReloadMode: 'blocking',
+ handler: () => {
+ if (childLoads++) {
+ childReloadStarted.resolve()
+ return childReload
+ }
+ return undefined
+ },
+ },
+ component: () => {
+ const search = childRoute.useSearch()
+ return Child revision {search.value.revision}
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]),
+ history: createMemoryHistory({
+ initialEntries: ['/parent/child?revision=1'],
+ }),
+ })
+
+ render()
+ expect(await screen.findByText('Child revision 1')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ let firstNavigation: Promise | undefined
+ let secondNavigation: Promise | undefined
+ let settledBeforeOldMinimum = false
+ let renderedBeforeOldMinimum = false
+ try {
+ firstNavigation = router.invalidate({
+ filter: (match) => match.routeId === childRoute.id,
+ forcePending: true,
+ })
+ await childReloadStarted
+ await vi.advanceTimersByTimeAsync(0)
+ await nextTick()
+ expect(screen.getByText('Loading child')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(25)
+ secondNavigation = router.navigate({
+ to: '/parent/child',
+ search: { revision: 2 },
+ })
+ await parentReloadStarted
+ expect(screen.getByText('Loading child')).toBeInTheDocument()
+
+ childReload.resolve()
+ const successor = secondNavigation
+ void successor.then(() => {
+ settledBeforeOldMinimum = true
+ })
+ parentReload.resolve()
+ await vi.advanceTimersByTimeAsync(5)
+ await nextTick()
+ renderedBeforeOldMinimum = screen.queryByText('Child revision 2') !== null
+ } finally {
+ childReload.resolve()
+ parentReload.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await Promise.allSettled(
+ [firstNavigation, secondNavigation].filter(
+ (navigation): navigation is Promise => !!navigation,
+ ),
+ )
+ }
+
+ expect({
+ settled: settledBeforeOldMinimum,
+ rendered: renderedBeforeOldMinimum,
+ }).toEqual({ settled: true, rendered: true })
+ })
+
+ test('same-boundary timing survives a private retained-context barrier', async () => {
+ const retainedStarted = createControlledPromise()
+ const retainedReady = createControlledPromise()
+ const firstPage = createControlledPromise()
+ const secondPageStarted = createControlledPromise()
+ const secondPage = createControlledPromise()
+
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record) => ({
+ revision: Number(search.revision) || 0,
+ }),
+ beforeLoad: ({ search }) => {
+ if (search.revision === 2) {
+ retainedStarted.resolve()
+ return retainedReady.then(() => ({ rootRevision: 2 }))
+ }
+ return { rootRevision: search.revision }
+ },
+ component: () => {
+ const context = rootRoute.useRouteContext()
+ return (
+
+
Root revision {context.value.rootRevision}
+
+
+ )
+ },
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+ })
+ const pageRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: () => Loading page
,
+ beforeLoad: ({ search }) => {
+ if (search.revision === 1) {
+ return firstPage
+ }
+ secondPageStarted.resolve()
+ return secondPage
+ },
+ component: () => {
+ const search = pageRoute.useSearch()
+ return Page revision {search.value.revision}
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute, pageRoute]),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+ })
+
+ render()
+ expect(await screen.findByText('Home')).toBeInTheDocument()
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ let firstNavigation: Promise | undefined
+ let secondNavigation: Promise | undefined
+ try {
+ firstNavigation = router.navigate({
+ to: '/page',
+ search: { revision: 1 },
+ })
+ await vi.advanceTimersByTimeAsync(0)
+ await nextTick()
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(screen.getByText('Root revision 1')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(25)
+ secondNavigation = router.navigate({
+ to: '/page',
+ search: { revision: 2 },
+ })
+ await retainedStarted
+ await nextTick()
+
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(screen.getByText('Root revision 1')).toBeInTheDocument()
+
+ retainedReady.resolve()
+ await secondPageStarted
+ await nextTick()
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+ expect(screen.getByText('Root revision 2')).toBeInTheDocument()
+
+ let settled = false
+ const successor = secondNavigation
+ void successor.then(() => {
+ settled = true
+ })
+ secondPage.resolve()
+ await vi.advanceTimersByTimeAsync(74)
+ await nextTick()
+ expect(settled).toBe(false)
+ expect(screen.getByText('Loading page')).toBeInTheDocument()
+
+ await vi.advanceTimersByTimeAsync(5)
+ await Promise.all([firstNavigation, successor])
+ await nextTick()
+ expect(screen.getByText('Page revision 2')).toBeInTheDocument()
+ } finally {
+ retainedReady.resolve()
+ firstPage.resolve()
+ secondPage.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await Promise.allSettled(
+ [firstNavigation, secondNavigation].filter(
+ (navigation): navigation is Promise => !!navigation,
+ ),
+ )
+ }
+ })
+
+ test('an exact-boundary terminal result supersedes an unrendered pending offer', async () => {
+ const pendingRenderStarted = createControlledPromise()
+ const pendingRender = createControlledPromise()
+ const terminalLoadStarted = createControlledPromise()
+ const terminalLoad = createControlledPromise()
+ const Pending = defineComponent({
+ async setup() {
+ pendingRenderStarted.resolve()
+ await pendingRender
+ return () => Root pending
+ },
+ })
+
+ const rootRoute = createRootRoute({
+ validateSearch: (search: Record) => ({
+ terminal: search.terminal === true,
+ }),
+ pendingMs: 0,
+ pendingMinMs: 100,
+ pendingComponent: Pending,
+ beforeLoad: async ({ search }) => {
+ if (search.terminal) {
+ terminalLoadStarted.resolve()
+ await terminalLoad
+ throw notFound()
+ }
+ },
+ notFoundComponent: () => Root not found
,
+ component: () => ,
+ })
+ const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: () => Home
,
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([indexRoute]),
+ history: createMemoryHistory({ initialEntries: ['/?terminal=false'] }),
+ })
+
+ render()
+ expect(await screen.findByText('Home')).toBeInTheDocument()
+ await waitFor(() => expect(router.state.status).toBe('idle'))
+ vi.useFakeTimers()
+ vi.setSystemTime(0)
+
+ let navigation: Promise | undefined
+ try {
+ navigation = router.navigate({
+ to: '/',
+ search: { terminal: true },
+ })
+ await terminalLoadStarted
+ await vi.advanceTimersByTimeAsync(0)
+ await pendingRenderStarted
+ await nextTick()
+ expect(screen.getByText('Home')).toBeInTheDocument()
+
+ terminalLoad.resolve()
+ await navigation
+ await nextTick()
+
+ expect(screen.getByText('Root not found')).toBeInTheDocument()
+ expect(Date.now()).toBe(0)
+ } finally {
+ terminalLoad.resolve()
+ pendingRender.resolve()
+ await vi.advanceTimersByTimeAsync(1_000)
+ await Promise.allSettled(navigation ? [navigation] : [])
+ }
+ })
+})