diff --git a/.changeset/breezy-chicken-care.md b/.changeset/breezy-chicken-care.md new file mode 100644 index 00000000000..30cee73493d --- /dev/null +++ b/.changeset/breezy-chicken-care.md @@ -0,0 +1,8 @@ +--- +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +Exclude structural descendants below error and not-found boundaries from route lifecycle callbacks. Preserve lifecycle membership through invalidation, hydration, background reloads, and superseded navigation publication. diff --git a/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index 41d9e575c94..2adfc6e5c68 100644 --- a/docs/router/api/router/RouteOptionsType.md +++ b/docs/router/api/router/RouteOptionsType.md @@ -280,18 +280,22 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record - Type: `(match: RouteMatch) => void` - Optional - A function that will be called when a route is matched and loaded after not being matched in the previous location. +- Routes below an error or not-found boundary do not enter the active route lifecycle. The boundary itself remains active. A previously hidden route receives `onEnter` when a navigation makes it active again. ### `onStay` property - Type: `(match: RouteMatch) => void` - Optional - A function that will be called when a route is matched and loaded after being matched in the previous location. +- Both navigations must include the route in the active branch, through the first error or not-found boundary. ### `onLeave` property - Type: `(match: RouteMatch) => void` - Optional - A function that will be called when a route is no longer matched after being matched in the previous location. +- This also runs when a navigation hides a previously active route below an error or not-found boundary, even if the route remains structurally matched. +- Background reloads update route data without dispatching these lifecycle callbacks. ### `onCatch` property diff --git a/packages/react-router/tests/not-found-route-lifecycle.test.tsx b/packages/react-router/tests/not-found-route-lifecycle.test.tsx new file mode 100644 index 00000000000..3a648c33262 --- /dev/null +++ b/packages/react-router/tests/not-found-route-lifecycle.test.tsx @@ -0,0 +1,64 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + notFound, +} from '../src' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +test('a structural descendant below a not-found boundary does not run onEnter when its component cannot render', async () => { + const parentOnEnter = vi.fn() + const unavailableOnEnter = vi.fn() + const descendantOnEnter = vi.fn() + + const rootRoute = createRootRoute() + const tenantRoute = createRoute({ + onEnter: parentOnEnter, + getParentRoute: () => rootRoute, + path: 'tenants/$tenantId', + }) + const unavailableSettingsRoute = createRoute({ + onEnter: unavailableOnEnter, + getParentRoute: () => tenantRoute, + path: 'settings', + beforeLoad: () => { + throw notFound() + }, + notFoundComponent: () =>
Tenant settings unavailable
, + }) + const profileRoute = createRoute({ + getParentRoute: () => unavailableSettingsRoute, + path: 'profile', + onEnter: descendantOnEnter, + component: () =>
Profile settings
, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + tenantRoute.addChildren([ + unavailableSettingsRoute.addChildren([profileRoute]), + ]), + ]), + history: createMemoryHistory({ + initialEntries: ['/tenants/acme/settings/profile'], + }), + }) + + render() + + expect( + await screen.findByText('Tenant settings unavailable'), + ).toBeInTheDocument() + + expect(screen.queryByText('Profile settings')).not.toBeInTheDocument() + expect(parentOnEnter).toHaveBeenCalled() + expect(unavailableOnEnter).toHaveBeenCalled() + expect(descendantOnEnter).not.toHaveBeenCalled() +}) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 91600c176d7..fc966253e77 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -2,7 +2,11 @@ // can rewrite relative imports for both ESM and CJS. import { isNotFound } from './not-found' import { isRedirect } from './redirect' -import { getLocationChangeInfo, runRouteLifecycle } from './router' +import { + getLocationChangeInfo, + lifecycleEnd, + runRouteLifecycle, +} from './router' import { hydrateSsrMatchId } from './ssr/ssr-match-id' import type { GLOBAL_SEROVAL, GLOBAL_TSR } from './ssr/constants' import type { AnySerializationAdapter } from './ssr/serializer/transformer' @@ -1586,6 +1590,7 @@ export function commitMatches( resolvedPrefix?: number, ): void { const previous = router._committed + const previousEnd = router._lifecycleEnd const previousCached = router._cache for (const match of matches) { match.preload = false @@ -1641,6 +1646,8 @@ export function commitMatches( // The lane becomes committed before publication can synchronously reenter. tx[3 /* matches */] = [] router._cache = cached + // Publish lifecycle membership with its branch before observers can reenter. + const nextEnd = (router._lifecycleEnd = lifecycleEnd(matches)) publishMatches(router, matches) // Retained cache objects keep their leases; only departing owners need handoff. const previousMatches = [...previousCached.values(), ...previous] @@ -1657,7 +1664,7 @@ export function commitMatches( handoff[1 /* finish */]() } } - runRouteLifecycle(router, previous, matches, tx) + runRouteLifecycle(router, previous, matches, previousEnd, nextEnd, tx) } /** @@ -2519,6 +2526,7 @@ export async function hydrate(router: AnyRouter): Promise { }, ] router._committed = committedMatches + router._lifecycleEnd = lifecycleEnd(committedMatches) router._handoff = handoff router._preflight = undefined router.batch(() => { diff --git a/packages/router-core/src/load-server.ts b/packages/router-core/src/load-server.ts index 7608b0fc65c..f04011d1a4a 100644 --- a/packages/router-core/src/load-server.ts +++ b/packages/router-core/src/load-server.ts @@ -5,7 +5,11 @@ import { isRedirect, redirect } from './redirect' import { rootRouteId } from './root' import { loadRouteChunk } from './load-client' import { waitForReason } from './await-signal' -import { getLocationChangeInfo, runRouteLifecycle } from './router' +import { + getLocationChangeInfo, + lifecycleEnd, + runRouteLifecycle, +} from './router' import type { ParsedLocation } from './location' import type { AnyRouteMatch } from './Matches' import type { NotFoundError } from './not-found' @@ -859,6 +863,7 @@ export async function loadServerRoute( router.updateLatestLocation() const next = router.latestLocation const previous = router._committed + const previousEnd = router._lifecycleEnd let result: ServerLoadResult try { const canonical = router.buildLocation({ @@ -894,17 +899,19 @@ export async function loadServerRoute( } router._serverResult = result + let nextEnd = 0 router.batch(() => { router.stores.location.set(next) router.stores.status.set('idle') if (result.type === 'render') { + router._committed = result.matches + nextEnd = router._lifecycleEnd = lifecycleEnd(result.matches) router.stores.setMatches(result.matches) router.stores.resolvedLocation.set(next) } }) if (result.type === 'render') { - router._committed = result.matches - runRouteLifecycle(router, previous, result.matches) + runRouteLifecycle(router, previous, result.matches, previousEnd, nextEnd) } router._commitPromise?.resolve() router._commitPromise = undefined diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 274353acd12..e5b6255c63b 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -928,13 +928,33 @@ export function _getUserHistoryState({ return state } +export function lifecycleEnd(matches: Array) { + return ( + matches.findIndex( + (match) => + match.status === 'error' || + match.status === 'notFound' || + match._notFound, + ) + 1 + ) +} + /** Run route lifecycle callbacks in leave/enter/stay phases. */ export function runRouteLifecycle( router: AnyRouter, previous: Array, matches: Array, + previousEnd: number | undefined, + nextEnd: number, owner?: LoadTransaction, ): void { + // Zero or undefined means the full branch; copy only at a fallback. + if (previousEnd) { + previous = previous.slice(0, previousEnd) + } + if (nextEnd) { + matches = matches.slice(0, nextEnd) + } for (const match of previous) { if (owner && router._tx !== owner) { return @@ -1015,6 +1035,11 @@ export interface RouterCore< in out TDehydrated extends Record = Record, > { shouldViewTransition?: boolean | ViewTransitionOptions + /** + * Foreground lifecycle boundary survives invalidation/background statuses. + * Zero or undefined means the whole branch participates. + */ + _lifecycleEnd?: number /** Current client load transaction and owner of navigation writes. */ _tx?: LoadTransaction /** Joinable in-flight loader generations keyed by match ID. */ diff --git a/packages/router-core/tests/background-trim-abort.test.ts b/packages/router-core/tests/background-trim-abort.test.ts index aa992df1114..7d23824972d 100644 --- a/packages/router-core/tests/background-trim-abort.test.ts +++ b/packages/router-core/tests/background-trim-abort.test.ts @@ -8,6 +8,67 @@ import { } from '../src' import { createTestRouter } from './routerTestUtils' +test.each(['success', 'error', 'notFound'] as const)( + 'departure after a background %s preserves lifecycle membership and current data', + async (outcome) => { + let revision = 0 + const onLeave = vi.fn() + const childOnLeave = vi.fn() + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + loader: () => { + revision++ + if (revision > 1 && outcome !== 'success') { + throw outcome === 'error' ? new Error('reload failed') : notFound() + } + return revision + }, + errorComponent: () => null, + notFoundComponent: () => null, + onLeave, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: '/child', + onLeave: childOnLeave, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + }) + const history = createMemoryHistory({ initialEntries: ['/parent/child'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + otherRoute, + ]), + history, + }) + try { + await router.load() + await router.invalidate() + await expect.poll(() => router.state.matches[1]?.status).toBe(outcome) + if (outcome === 'success') { + await expect.poll(() => router.state.matches[1]?.loaderData).toBe(2) + } + expect(childOnLeave).not.toHaveBeenCalled() + await router.navigate({ to: '/other' }) + expect(childOnLeave).toHaveBeenCalledOnce() + expect(onLeave).toHaveBeenCalledOnce() + expect(onLeave.mock.calls[0]![0]).toMatchObject({ + status: outcome, + ...(outcome === 'success' && { loaderData: 2 }), + }) + await router.invalidate() + expect(childOnLeave).toHaveBeenCalledOnce() + } finally { + history.destroy() + } + }, +) + test('a background boundary keeps the matched branch and its lifecycle stable', async () => { let parentLoads = 0 const parentError = new Error('parent background reload failed') diff --git a/packages/router-core/tests/client-lane-adversarial.test.ts b/packages/router-core/tests/client-lane-adversarial.test.ts index bcd75475951..dbcc15fed84 100644 --- a/packages/router-core/tests/client-lane-adversarial.test.ts +++ b/packages/router-core/tests/client-lane-adversarial.test.ts @@ -365,9 +365,8 @@ describe('adversarial client lane ownership', () => { test('keeps a hidden child matched without projecting it below a parent error', async () => { const parentError = new Error('parent failed') - const childHead = vi.fn(() => ({ - meta: [{ title: 'unreachable child' }], - })) + const parentOnEnter = vi.fn() + const childHead = vi.fn() const childOnEnter = vi.fn() const rootRoute = new BaseRootRoute({}) @@ -378,6 +377,7 @@ describe('adversarial client lane ownership', () => { throw parentError }, errorComponent: () => null, + onEnter: parentOnEnter, }) const childRoute = new BaseRoute({ getParentRoute: () => parentRoute, @@ -407,7 +407,8 @@ describe('adversarial client lane ownership', () => { error: parentError, }) expect(childHead).not.toHaveBeenCalled() - expect(childOnEnter).toHaveBeenCalledTimes(1) + expect(childOnEnter).not.toHaveBeenCalled() + expect(parentOnEnter).toHaveBeenCalledOnce() }) test('a redirect aborts the discarded loader generation', async () => { diff --git a/packages/router-core/tests/fallback-route-lifecycle.bench.ts b/packages/router-core/tests/fallback-route-lifecycle.bench.ts new file mode 100644 index 00000000000..5542aee292d --- /dev/null +++ b/packages/router-core/tests/fallback-route-lifecycle.bench.ts @@ -0,0 +1,208 @@ +import { bench, describe, expect } from 'vitest' +import { lifecycleEnd, runRouteLifecycle } from '../src/router' +import type { AnyRouteMatch, AnyRouter, AnyRoute } from '../src' +import type { LoadTransaction } from '../src/load-client' + +function baselineEnd(matches: Array) { + const boundary = matches.findIndex( + (match) => + match.status === 'error' || + match.status === 'notFound' || + match._notFound, + ) + return boundary < 0 ? matches.length : boundary + 1 +} + +function hasLifecycleMatch( + matches: Array, + end: number, + routeId: string, +) { + for (let index = 0; index < end; index++) { + if (matches[index]!.routeId === routeId) { + return true + } + } + return false +} + +/** Run route lifecycle callbacks in leave/enter/stay phases. */ +function baselineLifecycle( + router: AnyRouter, + previous: Array, + matches: Array, + previousEnd: number, + nextEnd: number, + owner?: LoadTransaction, +): void { + for (let index = 0; index < previousEnd; index++) { + if (owner && router._tx !== owner) { + return + } + const match = previous[index]! + if (!hasLifecycleMatch(matches, nextEnd, match.routeId)) { + ;(router.routesById as Record)[ + match.routeId + ]!.options.onLeave?.(match) + } + } + for (let index = 0; index < nextEnd; index++) { + if (owner && router._tx !== owner) { + return + } + const match = matches[index]! + ;(router.routesById as Record)[match.routeId]!.options[ + hasLifecycleMatch(previous, previousEnd, match.routeId) + ? 'onStay' + : 'onEnter' + ]?.(match) + } +} +// Freeze the published allocation-free dispatcher as the comparison baseline. +// Include membership preparation and compare client/server at every depth. +// Batch small route trees to amortize the timing overhead. +for (const depth of [1, 2, 3, 4, 5, 6]) { + for (const mode of ['client', 'server'] as const) { + for (const scenario of [ + 'stay', + 'replace', + 'sibling', + 'ascend', + 'descend', + 'hide', + 'reveal', + 'hidden', + 'first-load', + 'no-hooks', + 'sparse-hooks', + 'root-error', + 'leaf-notFound', + ] as const) { + if (depth === 1 && ['sibling', 'ascend', 'descend'].includes(scenario)) { + continue + } + if ( + process.env.LIFECYCLE_BENCH_CASE && + !`${scenario}, depth ${depth}, ${mode}`.includes( + process.env.LIFECYCLE_BENCH_CASE, + ) + ) { + continue + } + describe(`${scenario}, depth ${depth}, ${mode}`, () => { + let calls: Array | undefined + let count = 0 + const callback = (name: string) => () => { + count++ + calls?.push(name) + } + const makeMatches = (prefix: string, hidden: boolean) => + Array.from({ length: depth }, (_, index) => ({ + routeId: index < 1 ? `shared${index}` : `${prefix}${index}`, + status: 'success', + _notFound: hidden && index === Math.min(1, depth - 1), + })) as Array + const previous = + scenario === 'first-load' + ? [] + : makeMatches('a', scenario === 'reveal' || scenario === 'hidden') + const matches = makeMatches( + scenario === 'replace' ? 'b' : 'a', + scenario === 'hide' || scenario === 'hidden', + ) + if (scenario === 'sibling') { + matches[depth - 1]!.routeId = `b${depth - 1}` + } + if (scenario === 'ascend') { + matches.pop() + } + if (scenario === 'descend') { + previous.pop() + } + if (scenario === 'root-error') { + matches[0]!.status = 'error' + } + if (scenario === 'leaf-notFound') { + matches[depth - 1]!.status = 'notFound' + } + const owner = + mode === 'server' ? undefined : ([] as unknown as LoadTransaction) + const router = { + _tx: owner, + routesById: Object.fromEntries( + [...previous, ...matches].map((match) => [ + match.routeId, + { + options: + scenario === 'no-hooks' || + (scenario === 'sparse-hooks' && match.routeId !== 'shared0') + ? {} + : { + onEnter: callback(`enter:${match.routeId}`), + onStay: callback(`stay:${match.routeId}`), + onLeave: callback(`leave:${match.routeId}`), + }, + }, + ]), + ), + } as unknown as AnyRouter + const previousEnd = lifecycleEnd(previous) + const baselinePreviousEnd = baselineEnd(previous) + const candidateLifecycle = ( + router: AnyRouter, + previous: Array, + matches: Array, + owner?: LoadTransaction, + ) => { + const nextEnd = (router._lifecycleEnd = lifecycleEnd(matches)) + runRouteLifecycle( + router, + previous, + matches, + previousEnd, + nextEnd, + owner, + ) + } + const originalLifecycle = ( + router: AnyRouter, + previous: Array, + matches: Array, + owner?: LoadTransaction, + ) => { + const nextEnd = (router._lifecycleEnd = baselineEnd(matches)) + baselineLifecycle( + router, + previous, + matches, + baselinePreviousEnd, + nextEnd, + owner, + ) + } + calls = [] + originalLifecycle(router, previous, matches, owner) + const expected = calls + calls = [] + candidateLifecycle(router, previous, matches, owner) + expect(calls).toEqual(expected) + calls = undefined + for (const [name, run] of [ + ['original', originalLifecycle], + ['candidate', candidateLifecycle], + ] as const) { + bench( + name, + () => { + for (let i = 0; i < 100; i++) { + router._lifecycleEnd = previousEnd + run(router, previous, matches, owner) + } + }, + { time: 300 }, + ) + } + }) + } + } +} diff --git a/packages/router-core/tests/fallback-route-lifecycle.test.ts b/packages/router-core/tests/fallback-route-lifecycle.test.ts new file mode 100644 index 00000000000..3d802fa6cf1 --- /dev/null +++ b/packages/router-core/tests/fallback-route-lifecycle.test.ts @@ -0,0 +1,288 @@ +import { createMemoryHistory } from '@tanstack/history' +import { expect, test, vi } from 'vitest' +import { BaseRootRoute, BaseRoute, notFound } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +test('an explicitly targeted not-found owner remains lifecycle-active', async () => { + const onEnter = vi.fn() + const rootRoute = new BaseRootRoute({ + beforeLoad: () => { + throw notFound({ routeId: '/child' }) + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/child', + notFoundComponent: () => null, + onEnter, + }) + const history = createMemoryHistory({ initialEntries: ['/child'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([childRoute]), + history, + }) + try { + await router.load() + expect(onEnter).toHaveBeenCalledOnce() + } finally { + history.destroy() + } +}) + +test.each(['client', 'server'] as const)( + '%s lifecycle follows the active branch through fallback transitions', + async (environment) => { + for (const fallback of ['error', 'notFound', 'fuzzy'] as const) { + for (const boundary of ['root', 'parent', 'child'] as const) { + const events: Array = [] + let failed = false + const callbacks = (name: string) => ({ + onEnter: () => { + events.push(`enter:${name}`) + }, + onStay: () => { + events.push(`stay:${name}`) + }, + onLeave: () => { + events.push(`leave:${name}`) + }, + }) + const boundaryOptions = (name: string) => + name === boundary + ? { + errorComponent: () => null, + notFoundComponent: () => null, + beforeLoad: () => { + if (failed && fallback !== 'fuzzy') { + throw fallback === 'error' + ? new Error('unavailable') + : notFound() + } + }, + } + : {} + const rootRoute = new BaseRootRoute({ + ...callbacks('root'), + ...boundaryOptions('root'), + }) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + ...callbacks('parent'), + ...boundaryOptions('parent'), + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: 'child', + ...callbacks('child'), + ...boundaryOptions('child'), + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + ...callbacks('other'), + }) + const history = createMemoryHistory({ initialEntries: ['/other'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute]), + otherRoute, + ]), + history, + isServer: environment === 'server', + }) + const visit = async (path: string) => { + events.length = 0 + if (environment === 'server') { + const response = await loadServerResponse(router, path) + expect(response.status).toBe( + failed && path !== '/other' + ? fallback === 'error' + ? 500 + : 404 + : 200, + ) + } else { + await router.navigate({ to: path }) + } + } + + const active = + boundary === 'root' + ? ['root'] + : boundary === 'parent' + ? ['root', 'parent'] + : ['root', 'parent', 'child'] + const hidden = + boundary === 'root' + ? ['parent', 'child'] + : boundary === 'parent' + ? ['child'] + : [] + try { + await visit('/parent/child') + expect(events).toEqual(['enter:root', 'enter:parent', 'enter:child']) + + failed = true + const missingPath = + fallback === 'fuzzy' ? '/parent/child/missing' : '/parent/child' + await visit(`${missingPath}?step=1`) + expect(events).toEqual([ + ...hidden.map((name) => `leave:${name}`), + ...active.map((name) => `stay:${name}`), + ]) + // Matching still includes the child; only its lifecycle is hidden. + expect(router.state.matches.map((match) => match.routeId)).toContain( + childRoute.id, + ) + + await visit(`${missingPath}?step=2`) + expect(events).toEqual(active.map((name) => `stay:${name}`)) + + failed = false + await visit('/parent/child') + expect(events).toEqual([ + ...active.map((name) => `stay:${name}`), + ...hidden.map((name) => `enter:${name}`), + ]) + + failed = true + await visit(`${missingPath}?step=3`) + expect(events).toEqual([ + ...hidden.map((name) => `leave:${name}`), + ...active.map((name) => `stay:${name}`), + ]) + + failed = false + await visit('/other') + expect(events).toEqual([ + ...active + .filter((name) => name !== 'root') + .map((name) => `leave:${name}`), + 'stay:root', + 'enter:other', + ]) + } finally { + history.destroy() + } + } + } + }, +) + +test('publication supersession keeps lifecycle membership with the published branch', async () => { + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: 'child', + }) + const leafRoute = new BaseRoute({ + getParentRoute: () => childRoute, + path: 'leaf', + }) + const shortEnter = vi.fn() + const otherEnter = vi.fn() + const shortRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/short', + onEnter: shortEnter, + }) + const otherRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/other', + onEnter: otherEnter, + }) + const history = createMemoryHistory({ + initialEntries: ['/parent/child/leaf'], + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([ + parentRoute.addChildren([childRoute.addChildren([leafRoute])]), + shortRoute, + otherRoute, + ]), + history, + }) + const publish = router.stores.setMatches.bind(router.stores) + let successor: Promise | undefined + try { + await router.load() + router.stores.setMatches = (matches) => { + publish(matches) + if ( + !successor && + router._committed === matches && + matches.at(-1)?.routeId === shortRoute.id + ) { + successor = router.navigate({ to: '/other' }) + } + } + await router.navigate({ to: '/short' }) + expect(successor).toBeDefined() + await successor + expect(router.state.location.pathname).toBe('/other') + expect(shortEnter).not.toHaveBeenCalled() + expect(otherEnter).toHaveBeenCalledOnce() + } finally { + router.stores.setMatches = publish + history.destroy() + } +}) + +test.each([ + ['error', false], + ['error', true], + ['notFound', false], + ['notFound', true], +] as const)( + 'invalidation preserves the previous %s lifecycle boundary (forcePending: %s)', + async (fallback, forcePending) => { + const events: Array = [] + let failed = true + const rootRoute = new BaseRootRoute({}) + const parentRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + errorComponent: () => null, + notFoundComponent: () => null, + beforeLoad: () => { + if (failed) { + throw fallback === 'error' ? new Error('unavailable') : notFound() + } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => parentRoute, + path: 'child', + onEnter: () => { + events.push('enter') + }, + onStay: () => { + events.push('stay') + }, + onLeave: () => { + events.push('leave') + }, + }) + const history = createMemoryHistory({ initialEntries: ['/parent/child'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([parentRoute.addChildren([childRoute])]), + history, + }) + try { + await router.load() + expect(events).toEqual([]) + await router.invalidate({ forcePending }) + expect(events).toEqual([]) + failed = false + await router.invalidate({ forcePending }) + expect(events).toEqual(['enter']) + } finally { + history.destroy() + } + }, +) diff --git a/packages/router-core/tests/hydration-terminal-error-child-head.test.ts b/packages/router-core/tests/hydration-terminal-error-child-head.test.ts index 0e607f7a1c6..1deb5334756 100644 --- a/packages/router-core/tests/hydration-terminal-error-child-head.test.ts +++ b/packages/router-core/tests/hydration-terminal-error-child-head.test.ts @@ -1,7 +1,7 @@ import { runInNewContext } from 'node:vm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' -import { BaseRootRoute, BaseRoute } from '../src' +import { BaseRootRoute, BaseRoute, notFound } from '../src' import { hydrate } from '../src/ssr/client' import { attachRouterServerSsrUtils } from '../src/ssr/ssr-server' import { createTestRouter } from './routerTestUtils' @@ -172,4 +172,60 @@ describe('hydrated terminal error prefix', () => { ) expect(committedApp?.meta).toEqual([{ title: 'App error title' }]) }) + it.each(['error', 'notFound'] as const)( + 'preserves hydrated %s lifecycle membership through invalidation', + async (fallback) => { + let failed = true + const childEnter = vi.fn() + const childStay = vi.fn() + const childLeave = vi.fn() + const setup = (isServer: boolean) => { + const rootRoute = new BaseRootRoute({}) + const appRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/app', + errorComponent: () => null, + notFoundComponent: () => null, + beforeLoad: () => { + if (failed) { + throw fallback === 'error' ? new Error('unavailable') : notFound() + } + }, + }) + const childRoute = new BaseRoute({ + getParentRoute: () => appRoute, + path: 'child', + onEnter: childEnter, + onStay: childStay, + onLeave: childLeave, + }) + return createTestRouter({ + routeTree: rootRoute.addChildren([ + appRoute.addChildren([childRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/app/child'] }), + isServer, + }) + } + const server = setup(true) + const client = setup(false) + try { + mockWindow.$_TSR = await dehydrateToBootstrap(server) + await hydrate(client) + expect(client.state.matches).toHaveLength(3) + await client.invalidate() + expect(childEnter).not.toHaveBeenCalled() + expect(childStay).not.toHaveBeenCalled() + expect(childLeave).not.toHaveBeenCalled() + failed = false + await client.invalidate() + expect(childEnter).toHaveBeenCalledOnce() + expect(childStay).not.toHaveBeenCalled() + expect(childLeave).not.toHaveBeenCalled() + } finally { + server.history.destroy() + client.history.destroy() + } + }, + ) }) diff --git a/packages/router-core/tests/lifecycle-dispatch.test.ts b/packages/router-core/tests/lifecycle-dispatch.test.ts new file mode 100644 index 00000000000..12767c5a97a --- /dev/null +++ b/packages/router-core/tests/lifecycle-dispatch.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from 'vitest' +import { lifecycleEnd, runRouteLifecycle } from '../src/router' +import type { AnyRouteMatch, AnyRouter } from '../src' +import type { LoadTransaction } from '../src/load-client' + +test.each(['onLeave', 'onEnter', 'onStay'] as const)( + '%s supersession stops remaining lifecycle callbacks', + (callback) => { + const events: Array = [] + const owner = {} as LoadTransaction + const matches = ['parent', 'child'].map((routeId) => ({ + routeId, + status: 'success', + })) as Array + const router = { + _tx: owner, + routesById: Object.fromEntries( + matches.map(({ routeId }) => [ + routeId, + { + options: { + [callback]: () => { + events.push(routeId) + router._tx = {} as LoadTransaction + }, + }, + }, + ]), + ), + } as unknown as AnyRouter + const previous = callback === 'onEnter' ? [] : matches + const next = callback === 'onLeave' ? [] : matches + runRouteLifecycle( + router, + previous, + next, + lifecycleEnd(previous), + lifecycleEnd(next), + owner, + ) + expect(events).toEqual(['parent']) + expect(matches).toHaveLength(2) + }, +) + +test('saved fallback membership survives statuses changing before dispatch', () => { + const events: Array = [] + const previous = [ + { routeId: 'parent', status: 'notFound' }, + { routeId: 'child', status: 'success' }, + ] as Array + const previousEnd = lifecycleEnd(previous) + previous[0]!.status = 'pending' + const matches = previous.map((match) => ({ + ...match, + status: 'success' as const, + })) + const router = { + routesById: Object.fromEntries( + matches.map(({ routeId }) => [ + routeId, + { + options: { + onEnter: () => events.push(`enter:${routeId}`), + onStay: () => events.push(`stay:${routeId}`), + onLeave: () => events.push(`leave:${routeId}`), + }, + }, + ]), + ), + } as unknown as AnyRouter + runRouteLifecycle( + router, + previous, + matches, + previousEnd, + lifecycleEnd(matches), + ) + expect(events).toEqual(['stay:parent', 'enter:child']) + expect(previous).toHaveLength(2) + expect(previous[0]!.status).toBe('pending') +}) diff --git a/packages/router-core/tests/not-found-navigation-lifecycle.test.ts b/packages/router-core/tests/not-found-navigation-lifecycle.test.ts new file mode 100644 index 00000000000..7fb50b6c892 --- /dev/null +++ b/packages/router-core/tests/not-found-navigation-lifecycle.test.ts @@ -0,0 +1,174 @@ +import { createMemoryHistory } from '@tanstack/history' +import { expect, test, vi } from 'vitest' +import { BaseRootRoute, BaseRoute, createControlledPromise } from '../src' +import { createTestRouter } from './routerTestUtils' + +test('retains the not-found owner while its hidden child loads again', async () => { + const ownerBeforeLoadStarted = createControlledPromise() + const ownerBeforeLoadGate = createControlledPromise() + const childBeforeLoadStarted = createControlledPromise() + const childBeforeLoadGate = createControlledPromise() + const layoutOnEnter = vi.fn() + const layoutOnStay = vi.fn() + const childOnEnter = vi.fn() + const childOnLeave = vi.fn() + const childOnStay = vi.fn() + let gateNextOwnerLoad = false + let gateNextChildLoad = false + const ownerBeforeLoad = vi.fn(() => { + if (gateNextOwnerLoad) { + ownerBeforeLoadStarted.resolve() + return ownerBeforeLoadGate + } + return undefined + }) + const childBeforeLoad = vi.fn(() => { + if (gateNextChildLoad) { + childBeforeLoadStarted.resolve() + return childBeforeLoadGate + } + return undefined + }) + + const rootRoute = new BaseRootRoute({ component: () => null }) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + id: '_agents', + component: () => null, + notFoundComponent: () => null, + beforeLoad: ownerBeforeLoad, + pendingMs: 0, + pendingMinMs: 0, + pendingComponent: () => null, + onEnter: layoutOnEnter, + onStay: layoutOnStay, + }) + const agentsRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/agents', + beforeLoad: childBeforeLoad, + pendingMs: 0, + pendingMinMs: 0, + pendingComponent: () => null, + component: () => null, + onEnter: childOnEnter, + onLeave: childOnLeave, + onStay: childOnStay, + }) + const history = createMemoryHistory({ + initialEntries: ['/agents'], + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([layoutRoute.addChildren([agentsRoute])]), + history, + }) + let navigation: Promise | undefined + + try { + await router.load() + await router.navigate({ to: '/agents/missing' } as any) + + expect(router.state.status).toBe('idle') + expect(router.state.matches.map((match) => match.routeId)).toEqual([ + rootRoute.id, + layoutRoute.id, + agentsRoute.id, + ]) + const sourceOwner = router.state.matches[1] + expect(sourceOwner).toMatchObject({ + routeId: layoutRoute.id, + status: 'success', + _notFound: true, + }) + expect(router.state.matches[2]).toMatchObject({ + routeId: agentsRoute.id, + status: 'success', + }) + expect(childBeforeLoad).toHaveBeenCalledTimes(1) + expect(layoutOnEnter).toHaveBeenCalledTimes(1) + expect(layoutOnStay).toHaveBeenCalledTimes(1) + expect(childOnEnter).toHaveBeenCalledTimes(1) + expect(childOnLeave).toHaveBeenCalledTimes(1) + expect(childOnStay).not.toHaveBeenCalled() + + ownerBeforeLoad.mockClear() + childBeforeLoad.mockClear() + layoutOnStay.mockClear() + childOnEnter.mockClear() + childOnLeave.mockClear() + gateNextOwnerLoad = true + gateNextChildLoad = true + navigation = router.navigate({ to: '/agents' }) + await ownerBeforeLoadStarted + + expect(router.state.status).toBe('pending') + expect(router.state.matches[1]).toMatchObject({ + id: sourceOwner?.id, + routeId: layoutRoute.id, + status: 'success', + _notFound: true, + }) + expect(childBeforeLoad).not.toHaveBeenCalled() + expect(layoutOnStay).not.toHaveBeenCalled() + expect(childOnEnter).not.toHaveBeenCalled() + expect(childOnLeave).not.toHaveBeenCalled() + expect(childOnStay).not.toHaveBeenCalled() + + ownerBeforeLoadGate.resolve() + await childBeforeLoadStarted + + await vi.waitFor(() => { + expect(router.state.matches[2]).toMatchObject({ + routeId: agentsRoute.id, + status: 'pending', + }) + }) + expect(router.state.status).toBe('pending') + expect(router.state.matches[1]).toMatchObject({ + id: sourceOwner?.id, + routeId: layoutRoute.id, + status: 'success', + }) + expect(router.state.matches[1]?._notFound).toBeFalsy() + expect(layoutOnStay).not.toHaveBeenCalled() + expect(childOnEnter).not.toHaveBeenCalled() + expect(childOnLeave).not.toHaveBeenCalled() + expect(childOnStay).not.toHaveBeenCalled() + + childBeforeLoadGate.resolve() + await navigation + + expect(router.state).toMatchObject({ + status: 'idle', + location: { pathname: '/agents' }, + resolvedLocation: { pathname: '/agents' }, + }) + expect(router.state.matches).toEqual([ + expect.objectContaining({ + routeId: rootRoute.id, + status: 'success', + }), + expect.objectContaining({ + id: sourceOwner?.id, + routeId: layoutRoute.id, + status: 'success', + }), + expect.objectContaining({ + routeId: agentsRoute.id, + status: 'success', + }), + ]) + expect(router.state.matches.some((match) => match._notFound)).toBe(false) + expect(ownerBeforeLoad).toHaveBeenCalledTimes(1) + expect(childBeforeLoad).toHaveBeenCalledTimes(1) + expect(layoutOnStay).toHaveBeenCalledTimes(1) + expect(childOnEnter).toHaveBeenCalledTimes(1) + expect(childOnLeave).not.toHaveBeenCalled() + expect(childOnStay).not.toHaveBeenCalled() + } finally { + ownerBeforeLoadGate.resolve() + childBeforeLoadGate.resolve() + await Promise.allSettled(navigation ? [navigation] : []) + history.destroy() + } +})