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 ( +
+
{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 user = eRoute.useRouteContext({ + select: (context) => context.user, + }) + return ( +
+
{user()}
+ +
+ ) + }, + }) + 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] : []) + } + }) +})