diff --git a/.changeset/wide-pants-beam.md b/.changeset/wide-pants-beam.md new file mode 100644 index 00000000000..07efd536181 --- /dev/null +++ b/.changeset/wide-pants-beam.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-router': patch +'@tanstack/router-core': patch +--- + +retain mounted UI during revalidation diff --git a/packages/react-router/tests/issue-7986-retained-pending.test.tsx b/packages/react-router/tests/issue-7986-retained-pending.test.tsx new file mode 100644 index 00000000000..1d2dfdf5c8b --- /dev/null +++ b/packages/react-router/tests/issue-7986-retained-pending.test.tsx @@ -0,0 +1,658 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + Outlet, + RouterProvider, + createLazyRoute, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +const navigationDelay = 100 + +function delayNavigation() { + return new Promise((resolve) => setTimeout(resolve, navigationDelay)) +} + +function deferred() { + let resolve!: () => void + const promise = new Promise((resolver) => { + resolve = resolver + }) + return { promise, resolve } +} + +function setup() { + const navigationBeforeLoadStarted = deferred() + let beforeLoadCalls = 0 + + const rootRoute = createRootRoute({ component: Outlet }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: 'app', + beforeLoad: async () => { + if (++beforeLoadCalls > 1) { + navigationBeforeLoadStarted.resolve() + await delayNavigation() + } + return { user: 'test' } + }, + component: Outlet, + }) + const projectRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/projects/$projectId', + validateSearch: (search: Record): { tab?: string } => + typeof search.tab === 'string' ? { tab: search.tab } : {}, + component: Project, + }) + + function Project() { + const { projectId } = projectRoute.useParams() + const { tab } = projectRoute.useSearch() + return ( +
+ project={projectId} tab={tab ?? 'default'} +
+ ) + } + + const router = createRouter({ + routeTree: rootRoute.addChildren([layoutRoute.addChildren([projectRoute])]), + history: createMemoryHistory({ initialEntries: ['/projects/p1'] }), + defaultPendingComponent: () =>
Pending
, + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + return { + router, + navigationBeforeLoadStarted, + } +} + +test('a search-only navigation retains successful UI while beforeLoad reruns', async () => { + const { router, navigationBeforeLoadStarted } = setup() + render() + expect(await screen.findByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ + to: '/projects/$projectId', + params: { projectId: 'p1' }, + search: { tab: 'files' }, + }) + await navigationBeforeLoadStarted.promise + }) + + const contentWhileLoading = screen.getByTestId('content') + expect(contentWhileLoading).toBeVisible() + expect(contentWhileLoading).toHaveTextContent('project=p1 tab=default') + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(navigationDelay) + await navigation + }) + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=files', + ) +}) + +test('a path-param navigation retains successful UI while beforeLoad reruns', async () => { + const { router, navigationBeforeLoadStarted } = setup() + render() + expect(await screen.findByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ + to: '/projects/$projectId', + params: { projectId: 'p2' }, + }) + await navigationBeforeLoadStarted.promise + }) + + const contentWhileLoading = screen.getByTestId('content') + expect(contentWhileLoading).toBeVisible() + expect(contentWhileLoading).toHaveTextContent('project=p1 tab=default') + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(navigationDelay) + await navigation + }) + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p2 tab=default', + ) +}) + +test('a blocking reload retains the exact successful match', async () => { + const reloadStarted = deferred() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ component: Outlet }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + validateSearch: (search: Record): { tab?: string } => + typeof search.tab === 'string' ? { tab: search.tab } : {}, + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (++loaderCalls === 1) { + return 'initial' + } + reloadStarted.resolve() + return delayNavigation().then(() => 'reloaded') + }, + }, + component: () => ( +
+ {pageRoute.useLoaderData()} tab= + {pageRoute.useSearch().tab ?? 'default'} +
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingComponent: () =>
Pending
, + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('content')).toHaveTextContent( + 'initial tab=default', + ) + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ + to: '/page', + search: { tab: 'files' }, + }) + await reloadStarted.promise + }) + + const contentWhileLoading = screen.getByTestId('content') + expect(contentWhileLoading).toBeVisible() + expect(contentWhileLoading).toHaveTextContent('initial tab=default') + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('initial tab=default') + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await act(async () => { + await vi.advanceTimersByTimeAsync(navigationDelay) + await navigation + }) + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent('reloaded tab=files') +}) + +test('a cached success retries through pending UI when an error is mounted', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const retryStarted = deferred() + const retry = deferred() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ component: Outlet }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + loaderCalls++ + if (loaderCalls === 1) { + throw new Error('initial load failed') + } + if (loaderCalls === 2) { + return 'preloaded' + } + retryStarted.resolve() + return retry.promise.then(() => 'retried') + }, + }, + component: () => ( +
{pageRoute.useLoaderData()}
+ ), + pendingComponent: () =>
Pending
, + errorComponent: () =>
Failed
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('error')).toBeInTheDocument() + + await router.preloadRoute({ to: '/page' }) + expect(loaderCalls).toBe(2) + expect(screen.getByTestId('error')).toBeInTheDocument() + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/page' }) + await retryStarted.promise + }) + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.getByTestId('error')).not.toBeVisible() + + await act(async () => { + retry.resolve() + await navigation + }) + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('retried') +}) + +test('a cache-only success retries through pending UI over mounted success', async () => { + const retryStarted = deferred() + const retry = deferred() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ component: Outlet }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + const generation = ++loaderCalls + if (generation === 3) { + retryStarted.resolve() + return retry.promise.then(() => `generation ${generation}`) + } + return `generation ${generation}` + }, + }, + component: () => ( +
{pageRoute.useLoaderData()}
+ ), + pendingComponent: () =>
Pending
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('content')).toHaveTextContent('generation 1') + + await router.preloadRoute({ to: '/page' }) + expect(loaderCalls).toBe(2) + expect(screen.getByTestId('content')).toHaveTextContent('generation 1') + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/page' }) + await retryStarted.promise + }) + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.getByTestId('content')).not.toBeVisible() + + await act(async () => { + retry.resolve() + await navigation + }) + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent('generation 3') +}) + +test('a success hidden below an error boundary retries through pending UI', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const childReloadStarted = deferred() + const childReload = deferred() + let parentFails = false + let childReloads = false + + const rootRoute = createRootRoute({ component: Outlet }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + shouldReload: true, + loader: () => { + if (parentFails) { + throw new Error('parent failed') + } + return 'parent data' + }, + component: Outlet, + errorComponent: () =>
Parent failed
, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + shouldReload: () => childReloads, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (childReloads) { + childReloadStarted.resolve() + return childReload.promise.then(() => 'reloaded child') + } + return 'initial child' + }, + }, + component: () => ( +
{childRoute.useLoaderData()}
+ ), + pendingComponent: () =>
Pending child
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('content')).toHaveTextContent( + 'initial child', + ) + + parentFails = true + await act(() => router.navigate({ to: '/parent/child' })) + expect(await screen.findByTestId('error')).toBeInTheDocument() + + parentFails = false + childReloads = true + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/parent/child' }) + await childReloadStarted.promise + }) + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.queryByTestId('error')).not.toBeInTheDocument() + + await act(async () => { + childReload.resolve() + await navigation + }) + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('reloaded child') +}) + +test('a global not-found destination does not retain the mounted root success', async () => { + const missingStarted = deferred() + const missingLoader = deferred() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (++loaderCalls === 1) { + return 'initial root' + } + missingStarted.resolve() + return missingLoader.promise + }, + }, + component: Outlet, + pendingComponent: () =>
Pending root
, + notFoundComponent: () =>
Missing
, + }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: () =>
Page
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('content')).toBeInTheDocument() + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/missing' } as any) + await missingStarted.promise + }) + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.getByTestId('content')).not.toBeVisible() + + await act(async () => { + missingLoader.resolve() + await navigation + }) + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('missing')).toBeVisible() +}) + +test('lazy fuzzy-boundary relocation retains the mounted parent', async () => { + const lazyStarted = deferred() + const lazyRoute = deferred() + const parentReloadStarted = deferred() + const parentReload = deferred() + let parentLoads = 0 + + const rootRoute = createRootRoute({ + component: Outlet, + notFoundComponent: () =>
Root missing
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: async () => { + if (++parentLoads > 1) { + parentReloadStarted.resolve() + await parentReload.promise + } + }, + component: () => ( +
+
Parent content
+ +
+ ), + pendingComponent: () => ( +
Parent pending
+ ), + notFoundComponent: () => ( +
Parent missing
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + }).lazy(async () => { + lazyStarted.resolve() + await lazyRoute.promise + return createLazyRoute('/parent/child')({ + notFoundComponent: () => ( +
Child missing
+ ), + }) + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent'] }), + defaultPendingComponent: () => ( +
Pending
+ ), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('parent-content')).toBeVisible() + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/parent/child/missing' as any }) + await lazyStarted.promise + }) + + expect(screen.getByTestId('parent-content')).toBeVisible() + expect(screen.queryByTestId('parent-pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('parent-missing')).not.toBeInTheDocument() + + await act(async () => { + lazyRoute.resolve() + await parentReloadStarted.promise + }) + + expect(screen.getByTestId('parent-content')).toBeVisible() + expect(screen.queryByTestId('parent-pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('parent-missing')).not.toBeInTheDocument() + + await act(async () => { + parentReload.resolve() + await navigation + }) + + expect(screen.getByTestId('child-missing')).toBeVisible() + expect(screen.queryByTestId('root-missing')).not.toBeInTheDocument() + expect(screen.queryByTestId('parent-missing')).not.toBeInTheDocument() +}) + +test('a superseding navigation replaces an unrelated pending presentation', async () => { + const otherStarted = deferred() + const otherLoader = deferred() + const pageReloadStarted = deferred() + const pageReload = deferred() + let pageLoads = 0 + + const rootRoute = createRootRoute({ component: Outlet }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (++pageLoads === 1) { + return 'initial page' + } + pageReloadStarted.resolve() + return pageReload.promise.then(() => 'reloaded page') + }, + }, + component: () => ( +
{pageRoute.useLoaderData()}
+ ), + pendingComponent: () =>
Page pending
, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + loader: async () => { + otherStarted.resolve() + await otherLoader.promise + }, + pendingComponent: () => ( +
Other pending
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('content')).toHaveTextContent('initial page') + + await act(async () => { + void router.navigate({ to: '/other' }) + await otherStarted.promise + }) + expect(await screen.findByTestId('other-pending')).toBeVisible() + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/page' }) + await pageReloadStarted.promise + }) + + await waitFor(() => { + expect(screen.getByTestId('page-pending')).toBeVisible() + expect(screen.queryByTestId('other-pending')).not.toBeInTheDocument() + }) + + await act(async () => { + pageReload.resolve() + await navigation + }) + await act(async () => { + otherLoader.resolve() + await Promise.resolve() + }) + + expect(screen.queryByTestId('page-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('reloaded page') +}) diff --git a/packages/router-core/INTERNALS.md b/packages/router-core/INTERNALS.md index 0335da541aa..4983f360150 100644 --- a/packages/router-core/INTERNALS.md +++ b/packages/router-core/INTERNALS.md @@ -722,6 +722,19 @@ executing them. Pending UI is presentation, not partial semantic commit. +An exact successful match remains successful while `beforeLoad` or a blocking +loader revalidates it only when the same successful, non-not-found prefix is in +both the transaction's committed base and its starting presentation. +`isFetching` exposes that work without replacing its rendered UI. A cache-only +success, a different match ID, or a success hidden below a terminal boundary has +no retained presentation and remains eligible for pending UI. Explicit +force-pending invalidation already changes the committed generation to pending +and therefore overrides retention. + +The initial pending offer for a fuzzy not-found lane waits until lazy route +options resolve its boundary. This prevents a provisional ancestor boundary +from replacing the mounted presentation before ownership moves to a lazy child. + The first unresolved boundary is the only pending candidate. Its route or the router default must provide a pending component, and its effective `pendingMs` must allow presentation. Core does not skip an ineligible ancestor to expose an diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 83a76974995..f0024720b8c 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -357,6 +357,7 @@ async function contextualize( options: ExecuteLaneOptions, end: number, planSuccessfulLane: () => void, + retainedEnd: number, ): Promise { const [location, matches] = lane const signal = options[0 /* controller */].signal @@ -433,7 +434,8 @@ async function contextualize( } const previousStatus = match.status - if (previousStatus === 'success') { + // Retain only a success that is mounted through the same valid prefix. + if (previousStatus === 'success' && index >= retainedEnd) { match.status = 'pending' } options[8 /* onReady */]?.() @@ -511,40 +513,21 @@ function transferMatchResources( router: AnyRouter, previous: Array, next?: Array, + deferSameIdFlight?: true, ): void { const abort: Array = [] for (const match of previous as Array) { if (!next?.includes(match)) { - const flight = match._flight - match._flight = undefined - const controller = releaseOwnedFlight(router, match, flight) - if (controller) { - abort.push(controller) - } - } - } - for (const controller of abort) { - controller.abort() - } -} - -function transferPredecessorResources( - router: AnyRouter, - previous: Array, - next: Array, -): void { - const abort: Array = [] - for (const match of previous as Array) { - if (!next.includes(match)) { const flight = match._flight match._flight = undefined if ( + deferSameIdFlight && flight?.[2 /* leases */] === 1 && router._flights?.get(match.id) === flight && !( process.env.NODE_ENV !== 'production' && router._tx?.[6 /* refresh */] ) && - next.some((candidate) => candidate.id === match.id) + next?.some((candidate) => candidate.id === match.id) ) { // The successor has not made its same-ID reload decision yet. flight[2 /* leases */] = 0 @@ -561,19 +544,6 @@ function transferPredecessorResources( } } -function releaseUnownedFlights(router: AnyRouter): void { - const abort: Array = [] - for (const [id, flight] of router._flights ?? []) { - if (!flight[2 /* leases */]) { - router._flights!.delete(id) - abort.push(flight[1 /* controller */]) - } - } - for (const controller of abort) { - controller.abort() - } -} - function acquireMatchResources(matches: Array): void { for (const match of matches as Array) { const flight = match._flight @@ -775,6 +745,7 @@ function createLoaderTask( tasks: Array, semanticParent: Promise | undefined, options: ExecuteLaneOptions, + retainedEnd: number, ): Promise { const match = lane[1 /* matches */][index]! const route = getRoute(router, match) @@ -879,11 +850,9 @@ 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. It - // still gets a task so its chunk and derived assets participate in the - // lane, but putting it back into pending would hide an already-rendered - // ancestor while only a descendant is loading. - if (match.status === 'success') { + // 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) { match.status = 'pending' } options[8 /* onReady */]?.() @@ -1173,18 +1142,19 @@ async function reduceLane( } } install() + const route = getRoute(router, match) try { await waitFor( outcome ? Promise.resolve().then(() => loadRouteChunk( - getRoute(router, match), + route, kind === ERROR ? 'errorComponent' : 'notFoundComponent', ), ) : Promise.all([ - loadRouteChunk(getRoute(router, match)), - loadRouteChunk(getRoute(router, match), 'notFoundComponent'), + loadRouteChunk(route), + loadRouteChunk(route, 'notFoundComponent'), ]), controller.signal, ) @@ -1268,6 +1238,7 @@ async function executeClientLane( options: ExecuteLaneOptions, ): Promise { const matched = [location, matches as Array] as MatchedLane + const presented = router.stores.matches.get() let plannedBoundary = matches.findIndex((match) => match._notFound) if (router.options.notFoundMode !== 'root' && plannedBoundary >= 0) { const boundary = await getNotFoundBoundary( @@ -1284,6 +1255,24 @@ async function executeClientLane( plannedBoundary = boundary } let end = plannedBoundary < 0 ? matches.length : plannedBoundary + 1 + let retainedEnd = 0 + while (retainedEnd < end && retainedEnd !== plannedBoundary) { + const match = matches[retainedEnd]! + const committed = options[3 /* base */][retainedEnd] + const visible = presented[retainedEnd] + if ( + committed?.id !== match.id || + committed.status !== 'success' || + committed._notFound || + match.preload || + visible?.id !== match.id || + visible.status !== 'success' || + visible._notFound + ) { + break + } + retainedEnd++ + } const tasks: Array = [] const start = options[7 /* resolvedPrefix */] ?? 0 let semanticParent = start @@ -1301,6 +1290,7 @@ async function executeClientLane( tasks, semanticParent, options, + retainedEnd, ) } } @@ -1313,6 +1303,7 @@ async function executeClientLane( options, end, planSuccessfulLane, + retainedEnd, ) if (failure) { options[5 /* sync */] = true @@ -1331,7 +1322,16 @@ async function executeClientLane( planSuccessfulLane() } if (options[2 /* isCurrent */]() && !options[4 /* preload */]) { - releaseUnownedFlights(router) + const abort: Array = [] + for (const [id, flight] of router._flights ?? []) { + if (!flight[2 /* leases */]) { + router._flights!.delete(id) + abort.push(flight[1 /* controller */]) + } + } + for (const controller of abort) { + controller.abort() + } } let reduced: ReducedLane | ControlOutcome try { @@ -1376,49 +1376,6 @@ async function executeClientLane( ) } -/** - * Finds the first route that should show pending UI and its two timing values. - * A fallback already on screen remains selected after its route loads, so we - * do not jump to a child fallback. Matches put back into pending by invalidation - * skip pendingMs, and a route without a usable fallback blocks pending UI for deeper routes. - */ -function pendingConfig( - router: AnyRouter, - matches: Array, -): - | [delay: number, boundary: number, min: number, component: unknown] - | undefined - | void { - const presented = router.stores.matches.get() - for (let index = 0; index < matches.length; index++) { - const match = matches[index]! - const success = match.status === 'success' - const visible = - success && - presented[index]?.id === match.id && - presented[index]?.status === 'pending' - if (success && !visible) { - continue - } - const route = getRoute(router, match as WorkMatch) - const delay = - visible || match.invalid - ? 0 - : (route.options.pendingMs ?? router.options.defaultPendingMs) - const component = - route.options.pendingComponent ?? - (router.options as any).defaultPendingComponent - return component && typeof delay === 'number' && delay !== Infinity - ? [ - delay, - index, - route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0, - component, - ] - : undefined - } -} - /** * Waits for `pendingMs`, then presents the complete lane. Rendering applies the * selected boundary cutoff while retaining every match's structural state. @@ -1445,12 +1402,41 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { router._pending = session = undefined } } - const config = pendingConfig(router, tx[3 /* matches */]) - if (!config) { + 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 + for (let index = 0; index < matches.length; index++) { + const match = matches[index]! + const success = match.status === 'success' + presentedPending = + presented[index]?.id === match.id && + presented[index]?.status === 'pending' + if (success && !presentedPending) { + continue + } + const route = getRoute(router, match as WorkMatch) + delay = + (success && presentedPending) || match.invalid + ? 0 + : (route.options.pendingMs ?? router.options.defaultPendingMs) + component = + route.options.pendingComponent ?? + (router.options as any).defaultPendingComponent + if (!component || typeof delay !== 'number' || delay === Infinity) { + return + } + boundary = index + min = route.options.pendingMinMs ?? router.options.defaultPendingMinMs ?? 0 + break + } + if (boundary < 0) { return } - const [delay, boundary, min, component] = config - const matchId = tx[3 /* matches */][boundary]!.id + const matchId = matches[boundary]!.id if ( !session || session[1 /* boundary */] !== boundary || @@ -1459,14 +1445,12 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { // Hydration and redirects can preserve pending presentation without a session. // Do not delay it again; conservatively start pendingMinMs from now. clearTimeout(session?.[3 /* timer */]) - const presented = router.stores.matches.get()[boundary] - const visible = presented?.id === matchId && presented.status === 'pending' router._pending = session = [ tx, boundary, - visible ? Date.now() + min : tx[4 /* startedAt */] + delay, + presentedPending ? Date.now() + min : tx[4 /* startedAt */] + delay!, undefined, - visible ? Promise.resolve(true) : undefined, + presentedPending ? Promise.resolve(true) : undefined, component, ] } @@ -1482,14 +1466,15 @@ function offerPending(router: CoordinatorRouter, tx: LoadTransaction): void { clearTimeout(session[3 /* timer */]) const remaining = session[2 /* deadline */] - Date.now() if (remaining > 0) { - session[3 /* timer */] = setTimeout(() => { - offerPending(router, tx) - }, remaining) + session[3 /* timer */] = setTimeout( + () => offerPending(router, tx), + remaining, + ) return } session[2 /* deadline */] = 0 } - const offered = tx[3 /* matches */].map((match) => ({ + const offered = matches.map((match) => ({ ...match, _flight: undefined, })) @@ -2149,10 +2134,11 @@ export async function loadClientRoute( } } previousOwner[0 /* controller */].abort() - transferPredecessorResources( + transferMatchResources( router, previousOwner[3 /* matches */], tx[3 /* matches */], + true, ) } if (router._tx !== tx) { @@ -2165,7 +2151,11 @@ export async function loadClientRoute( router.stores.status.set('pending') router.stores.location.set(location) }) - offerPending(router, tx) + // 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)) { + offerPending(router, tx) + } try { await tx[5 /* done */] } finally { diff --git a/packages/router-core/tests/loader-architecture-regressions.test.ts b/packages/router-core/tests/loader-architecture-regressions.test.ts index d8493052446..97561a9f727 100644 --- a/packages/router-core/tests/loader-architecture-regressions.test.ts +++ b/packages/router-core/tests/loader-architecture-regressions.test.ts @@ -154,6 +154,7 @@ test('superseding a load clears fetching state from the still-presented lane', a await vi.waitFor(() => expect(router.state.matches.at(-1)).toMatchObject({ routeId: pageRoute.id, + status: 'success', isFetching: 'beforeLoad', }), ) diff --git a/packages/solid-router/tests/issue-7986-retained-pending.test.tsx b/packages/solid-router/tests/issue-7986-retained-pending.test.tsx new file mode 100644 index 00000000000..db0092610c0 --- /dev/null +++ b/packages/solid-router/tests/issue-7986-retained-pending.test.tsx @@ -0,0 +1,630 @@ +import { cleanup, render, screen, waitFor } 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' +import type { ControlledPromise } from '@tanstack/router-core' + +const controlledPromises = new Set>() +const pendingOperations = new Set>() + +afterEach(async () => { + try { + for (const promise of controlledPromises) { + if (promise.status === 'pending') { + promise.resolve() + } + } + if (vi.isFakeTimers()) { + await vi.runAllTimersAsync() + } + await Promise.allSettled(pendingOperations) + } finally { + controlledPromises.clear() + pendingOperations.clear() + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + } +}) + +const navigationDelay = 100 + +function delayNavigation() { + return new Promise((resolve) => setTimeout(resolve, navigationDelay)) +} + +function controlled() { + const promise = createControlledPromise() + controlledPromises.add(promise) + return promise +} + +function track(operation: Promise) { + pendingOperations.add(operation) + return operation +} + +function setup() { + const navigationBeforeLoadStarted = controlled() + let beforeLoadCalls = 0 + + const rootRoute = createRootRoute({ component: () => }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: 'app', + beforeLoad: async () => { + if (++beforeLoadCalls > 1) { + navigationBeforeLoadStarted.resolve() + await delayNavigation() + } + return { user: 'test' } + }, + component: () => , + }) + const projectRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/projects/$projectId', + validateSearch: (search: Record): { tab?: string } => + typeof search.tab === 'string' ? { tab: search.tab } : {}, + component: Project, + }) + + function Project() { + const params = projectRoute.useParams() + const search = projectRoute.useSearch() + return ( +
+ project={params().projectId} tab={search().tab ?? 'default'} +
+ ) + } + + const router = createRouter({ + routeTree: rootRoute.addChildren([layoutRoute.addChildren([projectRoute])]), + history: createMemoryHistory({ initialEntries: ['/projects/p1'] }), + defaultPendingComponent: () =>
Pending
, + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + return { router, navigationBeforeLoadStarted } +} + +test('a search-only navigation retains successful UI while beforeLoad reruns', async () => { + const { router, navigationBeforeLoadStarted } = setup() + render(() => ) + expect(await screen.findByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const navigation = track( + router.navigate({ + to: '/projects/$projectId', + params: { projectId: 'p1' }, + search: { tab: 'files' }, + }), + ) + await navigationBeforeLoadStarted + + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(navigationDelay) + await navigation + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=files', + ) +}) + +test('a path-param navigation retains successful UI while beforeLoad reruns', async () => { + const { router, navigationBeforeLoadStarted } = setup() + render(() => ) + expect(await screen.findByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const navigation = track( + router.navigate({ + to: '/projects/$projectId', + params: { projectId: 'p2' }, + }), + ) + await navigationBeforeLoadStarted + + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(navigationDelay) + await navigation + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p2 tab=default', + ) +}) + +test('a blocking reload retains the exact successful match', async () => { + const reloadStarted = controlled() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + validateSearch: (search: Record): { tab?: string } => + typeof search.tab === 'string' ? { tab: search.tab } : {}, + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (++loaderCalls === 1) { + return 'initial' + } + reloadStarted.resolve() + return delayNavigation().then(() => 'reloaded') + }, + }, + component: () => ( +
+ {pageRoute.useLoaderData()()} tab= + {pageRoute.useSearch()().tab ?? 'default'} +
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingComponent: () =>
Pending
, + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render(() => ) + expect(await screen.findByTestId('content')).toHaveTextContent( + 'initial tab=default', + ) + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const navigation = track( + router.navigate({ + to: '/page', + search: { tab: 'files' }, + }), + ) + await reloadStarted + + const contentWhileLoading = screen.getByTestId('content') + expect(contentWhileLoading).toBeVisible() + expect(contentWhileLoading).toHaveTextContent('initial tab=default') + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(0) + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('initial tab=default') + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(navigationDelay) + await navigation + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent('reloaded tab=files') +}) + +test('a cached success retries through pending UI when an error is mounted', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const retryStarted = controlled() + const retry = controlled() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + loaderCalls++ + if (loaderCalls === 1) { + throw new Error('initial load failed') + } + if (loaderCalls === 2) { + return 'preloaded' + } + retryStarted.resolve() + return retry.then(() => 'retried') + }, + }, + component: () => ( +
{pageRoute.useLoaderData()()}
+ ), + pendingComponent: () =>
Pending
, + errorComponent: () =>
Failed
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render(() => ) + expect(await screen.findByTestId('error')).toBeInTheDocument() + + await router.preloadRoute({ to: '/page' }) + expect(loaderCalls).toBe(2) + expect(screen.getByTestId('error')).toBeInTheDocument() + + const navigation = track(router.navigate({ to: '/page' })) + await retryStarted + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.queryByTestId('error')).not.toBeInTheDocument() + + retry.resolve() + await navigation + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('retried') +}) + +test('a cache-only success retries through pending UI over mounted success', async () => { + const retryStarted = controlled() + const retry = controlled() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + const generation = ++loaderCalls + if (generation === 3) { + retryStarted.resolve() + return retry.then(() => `generation ${generation}`) + } + return `generation ${generation}` + }, + }, + component: () => ( +
{pageRoute.useLoaderData()()}
+ ), + pendingComponent: () =>
Pending
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render(() => ) + expect(await screen.findByTestId('content')).toHaveTextContent('generation 1') + + await router.preloadRoute({ to: '/page' }) + expect(loaderCalls).toBe(2) + expect(screen.getByTestId('content')).toHaveTextContent('generation 1') + + const navigation = track(router.navigate({ to: '/page' })) + await retryStarted + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.queryByTestId('content')).not.toBeInTheDocument() + + retry.resolve() + await navigation + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent('generation 3') +}) + +test('a success hidden below an error boundary retries through pending UI', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const childReloadStarted = controlled() + const childReload = controlled() + let parentFails = false + let childReloads = false + + const rootRoute = createRootRoute({ component: () => }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + shouldReload: true, + loader: () => { + if (parentFails) { + throw new Error('parent failed') + } + return 'parent data' + }, + component: () => , + errorComponent: () =>
Parent failed
, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + shouldReload: () => childReloads, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (childReloads) { + childReloadStarted.resolve() + return childReload.then(() => 'reloaded child') + } + return 'initial child' + }, + }, + component: () => ( +
{childRoute.useLoaderData()()}
+ ), + pendingComponent: () =>
Pending child
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render(() => ) + expect(await screen.findByTestId('content')).toHaveTextContent( + 'initial child', + ) + + parentFails = true + await track(router.navigate({ to: '/parent/child' })) + expect(await screen.findByTestId('error')).toBeInTheDocument() + + parentFails = false + childReloads = true + const navigation = track(router.navigate({ to: '/parent/child' })) + await childReloadStarted + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.queryByTestId('error')).not.toBeInTheDocument() + + childReload.resolve() + await navigation + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('reloaded child') +}) + +test('a global not-found destination does not retain the mounted root success', async () => { + const missingStarted = controlled() + const missingLoader = controlled() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (++loaderCalls === 1) { + return 'initial root' + } + missingStarted.resolve() + return missingLoader + }, + }, + component: () => , + pendingComponent: () =>
Pending root
, + notFoundComponent: () =>
Missing
, + }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: () =>
Page
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render(() => ) + expect(await screen.findByTestId('content')).toBeInTheDocument() + + const navigation = track(router.navigate({ to: '/missing' } as any)) + await missingStarted + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.queryByTestId('content')).not.toBeInTheDocument() + + missingLoader.resolve() + await navigation + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('missing')).toBeVisible() +}) + +test('lazy fuzzy-boundary relocation retains the mounted parent', async () => { + const lazyStarted = controlled() + const lazyRoute = controlled() + const parentReloadStarted = controlled() + const parentReload = controlled() + let parentLoads = 0 + + const rootRoute = createRootRoute({ + component: () => , + notFoundComponent: () =>
Root missing
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: async () => { + if (++parentLoads > 1) { + parentReloadStarted.resolve() + await parentReload + } + }, + component: () => ( +
+
Parent content
+ +
+ ), + pendingComponent: () => ( +
Parent pending
+ ), + notFoundComponent: () => ( +
Parent missing
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + }).lazy(async () => { + lazyStarted.resolve() + await lazyRoute + return createLazyRoute('/parent/child')({ + notFoundComponent: () => ( +
Child missing
+ ), + }) + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent'] }), + defaultPendingComponent: () => ( +
Pending
+ ), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render(() => ) + expect(await screen.findByTestId('parent-content')).toBeVisible() + + const navigation = track( + router.navigate({ to: '/parent/child/missing' as any }), + ) + await lazyStarted + + expect(screen.getByTestId('parent-content')).toBeVisible() + expect(screen.queryByTestId('parent-pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('parent-missing')).not.toBeInTheDocument() + + lazyRoute.resolve() + await parentReloadStarted + + expect(screen.getByTestId('parent-content')).toBeVisible() + expect(screen.queryByTestId('parent-pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('parent-missing')).not.toBeInTheDocument() + + parentReload.resolve() + await navigation + + expect(screen.getByTestId('child-missing')).toBeVisible() + expect(screen.queryByTestId('root-missing')).not.toBeInTheDocument() + expect(screen.queryByTestId('parent-missing')).not.toBeInTheDocument() +}) + +test('a superseding navigation replaces an unrelated pending presentation', async () => { + const otherStarted = controlled() + const otherLoader = controlled() + const pageReloadStarted = controlled() + const pageReload = controlled() + let pageLoads = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (++pageLoads === 1) { + return 'initial page' + } + pageReloadStarted.resolve() + return pageReload.then(() => 'reloaded page') + }, + }, + component: () => ( +
{pageRoute.useLoaderData()()}
+ ), + pendingComponent: () =>
Page pending
, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + loader: async () => { + otherStarted.resolve() + await otherLoader + }, + pendingComponent: () => ( +
Other pending
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render(() => ) + expect(await screen.findByTestId('content')).toHaveTextContent('initial page') + + const otherNavigation = track(router.navigate({ to: '/other' })) + await otherStarted + expect(await screen.findByTestId('other-pending')).toBeVisible() + + const navigation = track(router.navigate({ to: '/page' })) + await pageReloadStarted + + await waitFor(() => { + expect(screen.getByTestId('page-pending')).toBeVisible() + expect(screen.queryByTestId('other-pending')).not.toBeInTheDocument() + }) + + pageReload.resolve() + await navigation + otherLoader.resolve() + await otherNavigation + + expect(screen.queryByTestId('page-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('reloaded page') +}) diff --git a/packages/vue-router/tests/issue-7986-retained-pending.test.tsx b/packages/vue-router/tests/issue-7986-retained-pending.test.tsx new file mode 100644 index 00000000000..57e94e2ad03 --- /dev/null +++ b/packages/vue-router/tests/issue-7986-retained-pending.test.tsx @@ -0,0 +1,660 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/vue' +import { afterEach, expect, test, vi } from 'vitest' +import { createControlledPromise } from '@tanstack/router-core' +import { nextTick } from 'vue' +import { + Outlet, + RouterProvider, + createLazyRoute, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { ControlledPromise } from '@tanstack/router-core' + +const controlledPromises = new Set>() +const pendingOperations = new Set>() + +afterEach(async () => { + try { + for (const promise of controlledPromises) { + if (promise.status === 'pending') { + promise.resolve() + } + } + if (vi.isFakeTimers()) { + await vi.runAllTimersAsync() + } + await Promise.allSettled(pendingOperations) + } finally { + controlledPromises.clear() + pendingOperations.clear() + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + } +}) + +const navigationDelay = 100 + +function controlled() { + const promise = createControlledPromise() + controlledPromises.add(promise) + return promise +} + +function track(operation: Promise) { + pendingOperations.add(operation) + return operation +} + +function setup() { + const navigationBeforeLoadStarted = controlled() + let beforeLoadCalls = 0 + + const rootRoute = createRootRoute({ component: () => }) + const layoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: 'app', + beforeLoad: async () => { + if (++beforeLoadCalls > 1) { + navigationBeforeLoadStarted.resolve() + await new Promise((resolve) => + setTimeout(resolve, navigationDelay), + ) + } + return { user: 'test' } + }, + component: () => , + }) + const projectRoute = createRoute({ + getParentRoute: () => layoutRoute, + path: '/projects/$projectId', + validateSearch: (search: Record): { tab?: string } => + typeof search.tab === 'string' ? { tab: search.tab } : {}, + component: Project, + }) + + function Project() { + const params = projectRoute.useParams() + const search = projectRoute.useSearch() + return ( +
+ project={params.value.projectId} tab={search.value.tab ?? 'default'} +
+ ) + } + + const router = createRouter({ + routeTree: rootRoute.addChildren([layoutRoute.addChildren([projectRoute])]), + history: createMemoryHistory({ initialEntries: ['/projects/p1'] }), + defaultPendingComponent: () =>
Pending
, + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + return { router, navigationBeforeLoadStarted } +} + +test('a search-only navigation retains successful UI while beforeLoad reruns', async () => { + const { router, navigationBeforeLoadStarted } = setup() + render() + expect(await screen.findByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const navigation = track( + router.navigate({ + to: '/projects/$projectId', + params: { projectId: 'p1' }, + search: { tab: 'files' }, + }), + ) + await navigationBeforeLoadStarted + + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(0) + await nextTick() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(navigationDelay) + await navigation + await nextTick() + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=files', + ) +}) + +test('a path-param navigation retains successful UI while beforeLoad reruns', async () => { + const { router, navigationBeforeLoadStarted } = setup() + render() + expect(await screen.findByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const navigation = track( + router.navigate({ + to: '/projects/$projectId', + params: { projectId: 'p2' }, + }), + ) + await navigationBeforeLoadStarted + + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(0) + await nextTick() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p1 tab=default', + ) + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(navigationDelay) + await navigation + await nextTick() + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent( + 'project=p2 tab=default', + ) +}) + +test('a blocking reload retains the exact successful match', async () => { + const reloadStarted = controlled() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + validateSearch: (search: Record): { tab?: string } => + typeof search.tab === 'string' ? { tab: search.tab } : {}, + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (++loaderCalls === 1) { + return 'initial' + } + reloadStarted.resolve() + return new Promise((resolve) => + setTimeout(() => resolve('reloaded'), navigationDelay), + ) + }, + }, + component: () => { + const loaderData = pageRoute.useLoaderData() + const search = pageRoute.useSearch() + return ( +
+ {loaderData.value} tab={search.value.tab ?? 'default'} +
+ ) + }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingComponent: () =>
Pending
, + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('content')).toHaveTextContent( + 'initial tab=default', + ) + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const navigation = track( + router.navigate({ + to: '/page', + search: { tab: 'files' }, + }), + ) + await reloadStarted + + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('initial tab=default') + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(0) + await nextTick() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('initial tab=default') + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + + await vi.advanceTimersByTimeAsync(navigationDelay) + await navigation + await nextTick() + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent('reloaded tab=files') +}) + +test('a cached success retries through pending UI when an error is mounted', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const retryStarted = controlled() + const retry = controlled() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + loaderCalls++ + if (loaderCalls === 1) { + throw new Error('initial load failed') + } + if (loaderCalls === 2) { + return 'preloaded' + } + retryStarted.resolve() + return retry.then(() => 'retried') + }, + }, + component: () => { + const loaderData = pageRoute.useLoaderData() + return
{loaderData.value}
+ }, + pendingComponent: () =>
Pending
, + errorComponent: () =>
Failed
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('error')).toBeInTheDocument() + + await router.preloadRoute({ to: '/page' }) + expect(loaderCalls).toBe(2) + expect(screen.getByTestId('error')).toBeInTheDocument() + + const navigation = track(router.navigate({ to: '/page' })) + await retryStarted + await nextTick() + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.queryByTestId('error')).not.toBeInTheDocument() + + retry.resolve() + await navigation + await nextTick() + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('retried') +}) + +test('a cache-only success retries through pending UI over mounted success', async () => { + const retryStarted = controlled() + const retry = controlled() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + const generation = ++loaderCalls + if (generation === 3) { + retryStarted.resolve() + return retry.then(() => `generation ${generation}`) + } + return `generation ${generation}` + }, + }, + component: () => { + const loaderData = pageRoute.useLoaderData() + return
{loaderData.value}
+ }, + pendingComponent: () =>
Pending
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('content')).toHaveTextContent('generation 1') + + await router.preloadRoute({ to: '/page' }) + expect(loaderCalls).toBe(2) + expect(screen.getByTestId('content')).toHaveTextContent('generation 1') + + const navigation = track(router.navigate({ to: '/page' })) + await retryStarted + await nextTick() + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.queryByTestId('content')).not.toBeInTheDocument() + + retry.resolve() + await navigation + await nextTick() + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toHaveTextContent('generation 3') +}) + +test('a success hidden below an error boundary retries through pending UI', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const childReloadStarted = controlled() + const childReload = controlled() + let parentFails = false + let childReloads = false + + const rootRoute = createRootRoute({ component: () => }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + shouldReload: true, + loader: () => { + if (parentFails) { + throw new Error('parent failed') + } + return 'parent data' + }, + component: () => , + errorComponent: () =>
Parent failed
, + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + shouldReload: () => childReloads, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (childReloads) { + childReloadStarted.resolve() + return childReload.then(() => 'reloaded child') + } + return 'initial child' + }, + }, + component: () => { + const loaderData = childRoute.useLoaderData() + return
{loaderData.value}
+ }, + pendingComponent: () =>
Pending child
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent/child'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('content')).toHaveTextContent( + 'initial child', + ) + + parentFails = true + await track(router.navigate({ to: '/parent/child' })) + await nextTick() + expect(await screen.findByTestId('error')).toBeInTheDocument() + + parentFails = false + childReloads = true + const navigation = track(router.navigate({ to: '/parent/child' })) + await childReloadStarted + await nextTick() + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.queryByTestId('error')).not.toBeInTheDocument() + + childReload.resolve() + await navigation + await nextTick() + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('reloaded child') +}) + +test('a global not-found destination does not retain the mounted root success', async () => { + const missingStarted = controlled() + const missingLoader = controlled() + let loaderCalls = 0 + + const rootRoute = createRootRoute({ + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (++loaderCalls === 1) { + return 'initial root' + } + missingStarted.resolve() + return missingLoader + }, + }, + component: () => , + pendingComponent: () =>
Pending root
, + notFoundComponent: () =>
Missing
, + }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + component: () =>
Page
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('content')).toBeInTheDocument() + + const navigation = track(router.navigate({ to: '/missing' } as any)) + await missingStarted + await nextTick() + + expect(await screen.findByTestId('pending')).toBeVisible() + expect(screen.queryByTestId('content')).not.toBeInTheDocument() + + missingLoader.resolve() + await navigation + await nextTick() + + expect(screen.queryByTestId('pending')).not.toBeInTheDocument() + expect(screen.getByTestId('missing')).toBeVisible() +}) + +test('lazy fuzzy-boundary relocation retains the mounted parent', async () => { + const lazyStarted = controlled() + const lazyRoute = controlled() + const parentReloadStarted = controlled() + const parentReload = controlled() + let parentLoads = 0 + + const rootRoute = createRootRoute({ + component: () => , + notFoundComponent: () =>
Root missing
, + }) + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + beforeLoad: async () => { + if (++parentLoads > 1) { + parentReloadStarted.resolve() + await parentReload + } + }, + component: () => ( +
+
Parent content
+ +
+ ), + pendingComponent: () => ( +
Parent pending
+ ), + notFoundComponent: () => ( +
Parent missing
+ ), + }) + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + }).lazy(async () => { + lazyStarted.resolve() + await lazyRoute + return createLazyRoute('/parent/child')({ + notFoundComponent: () => ( +
Child missing
+ ), + }) + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history: createMemoryHistory({ initialEntries: ['/parent'] }), + defaultPendingComponent: () => ( +
Pending
+ ), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('parent-content')).toBeVisible() + + const navigation = track( + router.navigate({ + to: '/parent/child/missing' as any, + }), + ) + await lazyStarted + await nextTick() + + expect(screen.getByTestId('parent-content')).toBeVisible() + expect(screen.queryByTestId('parent-pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('parent-missing')).not.toBeInTheDocument() + + lazyRoute.resolve() + await parentReloadStarted + await nextTick() + + expect(screen.getByTestId('parent-content')).toBeVisible() + expect(screen.queryByTestId('parent-pending')).not.toBeInTheDocument() + expect(screen.queryByTestId('parent-missing')).not.toBeInTheDocument() + + parentReload.resolve() + await navigation + await nextTick() + + expect(screen.getByTestId('child-missing')).toBeVisible() + expect(screen.queryByTestId('root-missing')).not.toBeInTheDocument() + expect(screen.queryByTestId('parent-missing')).not.toBeInTheDocument() +}) + +test('a superseding navigation replaces an unrelated pending presentation', async () => { + const otherStarted = controlled() + const otherLoader = controlled() + const pageReloadStarted = controlled() + const pageReload = controlled() + let pageLoads = 0 + + const rootRoute = createRootRoute({ component: () => }) + const pageRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/page', + shouldReload: true, + loader: { + staleReloadMode: 'blocking', + handler: () => { + if (++pageLoads === 1) { + return 'initial page' + } + pageReloadStarted.resolve() + return pageReload.then(() => 'reloaded page') + }, + }, + component: () => { + const loaderData = pageRoute.useLoaderData() + return
{loaderData.value}
+ }, + pendingComponent: () =>
Page pending
, + }) + const otherRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/other', + loader: async () => { + otherStarted.resolve() + await otherLoader + }, + pendingComponent: () => ( +
Other pending
+ ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([pageRoute, otherRoute]), + history: createMemoryHistory({ initialEntries: ['/page'] }), + defaultPendingMs: 0, + defaultPendingMinMs: 1, + }) + + render() + expect(await screen.findByTestId('content')).toHaveTextContent('initial page') + + const otherNavigation = track(router.navigate({ to: '/other' })) + await otherStarted + await nextTick() + expect(await screen.findByTestId('other-pending')).toBeVisible() + + const navigation = track(router.navigate({ to: '/page' })) + await pageReloadStarted + await nextTick() + + await waitFor(() => { + expect(screen.getByTestId('page-pending')).toBeVisible() + expect(screen.queryByTestId('other-pending')).not.toBeInTheDocument() + }) + + pageReload.resolve() + await navigation + otherLoader.resolve() + await Promise.allSettled([otherNavigation]) + await nextTick() + + expect(screen.queryByTestId('page-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('content')).toBeVisible() + expect(screen.getByTestId('content')).toHaveTextContent('reloaded page') +})