diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 6bc11976d81..ffc6855e2b3 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -2159,7 +2159,7 @@ export async function loadClientRoute( // matches must wait for lazy routes to place the final boundary. if ( resolvedPrefix || - (!resolvedLocation && !matches.some((match) => match._notFound)) + (!router._committed.length && !matches.some((match) => match._notFound)) ) { offerPending(router, tx) } 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 b726af3acf6..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,76 @@ 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() 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..fa4747565aa --- /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 () => { + 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 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 bf4987f0f9c..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() @@ -698,3 +752,304 @@ test('a retained root publishes fresh context with a child fallback', async () = 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..2f7fbe3fc3b --- /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 () => { + 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() + 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 5dd3f872867..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() @@ -734,3 +789,306 @@ test('a retained root publishes fresh context with a child fallback', async () = 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] : []) + } + }) +})