From 9213daba6e2ce5298892ad6e30ba1dda5743844e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 5 Sep 2026 00:25:12 +0200 Subject: [PATCH 1/8] fix(router-core): track lifecycle membership through fallback boundaries --- .changeset/breezy-chicken-care.md | 8 + docs/router/api/router/RouteOptionsType.md | 4 + .../tests/not-found-route-lifecycle.test.tsx | 64 +++++ packages/router-core/src/load-client.ts | 12 +- packages/router-core/src/load-server.ts | 13 +- packages/router-core/src/router.ts | 16 ++ .../tests/client-lane-adversarial.test.ts | 9 +- .../tests/fallback-route-lifecycle.test.ts | 262 ++++++++++++++++++ ...ydration-terminal-error-child-head.test.ts | 58 +++- .../not-found-navigation-lifecycle.test.ts | 174 ++++++++++++ 10 files changed, 610 insertions(+), 10 deletions(-) create mode 100644 .changeset/breezy-chicken-care.md create mode 100644 packages/react-router/tests/not-found-route-lifecycle.test.tsx create mode 100644 packages/router-core/tests/fallback-route-lifecycle.test.ts create mode 100644 packages/router-core/tests/not-found-navigation-lifecycle.test.ts 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..7564f4a113a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -928,13 +928,27 @@ export function _getUserHistoryState({ return state } +export function lifecycleEnd(matches: Array) { + const boundary = matches.findIndex( + (match) => + match.status === 'error' || + match.status === 'notFound' || + match._notFound, + ) + return boundary < 0 ? matches.length : boundary + 1 +} + /** Run route lifecycle callbacks in leave/enter/stay phases. */ export function runRouteLifecycle( router: AnyRouter, previous: Array, matches: Array, + previousEnd: number, + nextEnd: number, owner?: LoadTransaction, ): void { + previous = previous.slice(0, previousEnd) + matches = matches.slice(0, nextEnd) for (const match of previous) { if (owner && router._tx !== owner) { return @@ -1080,6 +1094,8 @@ export class RouterCore< _cache = new Map() /** Accepted semantic lane, excluding temporary pending presentation. */ _committed: Array = [] + // Foreground lifecycle membership survives invalidation/background statuses. + _lifecycleEnd = 0 // Must build in constructor stores!: RouterStores 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.test.ts b/packages/router-core/tests/fallback-route-lifecycle.test.ts new file mode 100644 index 00000000000..b279ed240ca --- /dev/null +++ b/packages/router-core/tests/fallback-route-lifecycle.test.ts @@ -0,0 +1,262 @@ +import { createMemoryHistory } from '@tanstack/history' +import { expect, test, vi } from 'vitest' +import { BaseRootRoute, BaseRoute, notFound } from '../src' +import { createTestRouter, loadServerResponse } from './routerTestUtils' + +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/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() + } +}) From 79c2666207e0d3957acdcfc6bb59ada0ccb6c97b Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 5 Sep 2026 00:25:12 +0200 Subject: [PATCH 2/8] test(router-core): benchmark fallback lifecycle dispatch --- .../tests/fallback-route-lifecycle.bench.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 packages/router-core/tests/fallback-route-lifecycle.bench.ts 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..f0c0092a4f1 --- /dev/null +++ b/packages/router-core/tests/fallback-route-lifecycle.bench.ts @@ -0,0 +1,171 @@ +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 originalLifecycle( + router: AnyRouter, + previous: Array, + matches: Array, + owner?: LoadTransaction, +): void { + for (const match of previous) { + if (owner && router._tx !== owner) { + return + } + if ( + matches.find( + (candidate) => + candidate.routeId === match.routeId || + candidate.status === 'error' || + candidate.status === 'notFound' || + candidate._notFound, + )?.routeId !== match.routeId + ) { + ;(router.routesById as Record)[ + match.routeId + ]!.options.onLeave?.(match) + } + if ( + match.status === 'error' || + match.status === 'notFound' || + match._notFound + ) { + break + } + } + for (const match of matches) { + if (owner && router._tx !== owner) { + return + } + ;(router.routesById as Record)[match.routeId]!.options[ + previous.find( + (candidate) => + candidate.routeId === match.routeId || + candidate.status === 'error' || + candidate.status === 'notFound' || + candidate._notFound, + )?.routeId === match.routeId + ? 'onStay' + : 'onEnter' + ]?.(match) + if ( + match.status === 'error' || + match.status === 'notFound' || + match._notFound + ) { + break + } + } +} + +// Compare callback dispatch itself, with both implementations receiving the +// same snapshots. Batch small route trees to amortize the timing overhead. +for (const depth of [1, 2, 4, 12]) { + for (const scenario of [ + 'stay', + 'replace', + 'hide', + 'reveal', + 'hidden', + 'first-load', + 'no-hooks', + 'sparse-hooks', + 'root-error', + 'leaf-notFound', + ] as const) { + if ( + process.env.LIFECYCLE_BENCH_CASE && + !`${scenario}, depth ${depth}`.includes(process.env.LIFECYCLE_BENCH_CASE) + ) { + continue + } + describe(`${scenario}, depth ${depth}`, () => { + 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 === 'root-error') { + matches[0]!.status = 'error' + } + if (scenario === 'leaf-notFound') { + matches[depth - 1]!.status = 'notFound' + } + const owner = depth === 1 ? 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 candidateLifecycle = ( + router: AnyRouter, + previous: Array, + matches: Array, + owner?: LoadTransaction, + ) => { + const nextEnd = (router._lifecycleEnd = lifecycleEnd(matches)) + runRouteLifecycle( + router, + previous, + matches, + previousEnd, + 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 }, + ) + } + }) + } +} From 351615d02b468c7c526be2581c229e0ad6e5f92e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 5 Sep 2026 00:52:50 +0200 Subject: [PATCH 3/8] test(router-core): preserve explicitly targeted not-found lifecycle --- .../tests/fallback-route-lifecycle.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/router-core/tests/fallback-route-lifecycle.test.ts b/packages/router-core/tests/fallback-route-lifecycle.test.ts index b279ed240ca..3d802fa6cf1 100644 --- a/packages/router-core/tests/fallback-route-lifecycle.test.ts +++ b/packages/router-core/tests/fallback-route-lifecycle.test.ts @@ -3,6 +3,32 @@ 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) => { From f79e2268b08fdd83e7c26e66b749fb9170a5f39c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 5 Sep 2026 00:52:50 +0200 Subject: [PATCH 4/8] perf(router-core): avoid lifecycle prefix allocations --- packages/router-core/src/router.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 7564f4a113a..8d201281160 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -938,6 +938,19 @@ export function lifecycleEnd(matches: Array) { 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. */ export function runRouteLifecycle( router: AnyRouter, @@ -947,27 +960,24 @@ export function runRouteLifecycle( nextEnd: number, owner?: LoadTransaction, ): void { - previous = previous.slice(0, previousEnd) - matches = matches.slice(0, nextEnd) - for (const match of previous) { + for (let index = 0; index < previousEnd; index++) { if (owner && router._tx !== owner) { return } - if (!matches.some((candidate) => candidate.routeId === match.routeId)) { + const match = previous[index]! + if (!hasLifecycleMatch(matches, nextEnd, match.routeId)) { ;(router.routesById as Record)[ match.routeId ]!.options.onLeave?.(match) } } - for (const match of matches) { + for (let index = 0; index < nextEnd; index++) { if (owner && router._tx !== owner) { return } - const route = (router.routesById as Record)[ - match.routeId - ]! - route.options[ - previous.some((candidate) => candidate.routeId === match.routeId) + const match = matches[index]! + ;(router.routesById as Record)[match.routeId]!.options[ + hasLifecycleMatch(previous, previousEnd, match.routeId) ? 'onStay' : 'onEnter' ]?.(match) From eadd0da101b5ac80fd7b6a578efc924d22422d49 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 5 Sep 2026 01:35:02 +0200 Subject: [PATCH 5/8] test(router-core): cover lifecycle dispatch at typical route depths --- .../tests/background-trim-abort.test.ts | 61 ++++ .../tests/fallback-route-lifecycle.bench.ts | 313 ++++++++++-------- .../tests/lifecycle-dispatch.test.ts | 82 +++++ 3 files changed, 318 insertions(+), 138 deletions(-) create mode 100644 packages/router-core/tests/lifecycle-dispatch.test.ts 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/fallback-route-lifecycle.bench.ts b/packages/router-core/tests/fallback-route-lifecycle.bench.ts index f0c0092a4f1..5542aee292d 100644 --- a/packages/router-core/tests/fallback-route-lifecycle.bench.ts +++ b/packages/router-core/tests/fallback-route-lifecycle.bench.ts @@ -3,169 +3,206 @@ import { lifecycleEnd, runRouteLifecycle } from '../src/router' import type { AnyRouteMatch, AnyRouter, AnyRoute } from '../src' import type { LoadTransaction } from '../src/load-client' -function originalLifecycle( +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 (const match of previous) { + for (let index = 0; index < previousEnd; index++) { if (owner && router._tx !== owner) { return } - if ( - matches.find( - (candidate) => - candidate.routeId === match.routeId || - candidate.status === 'error' || - candidate.status === 'notFound' || - candidate._notFound, - )?.routeId !== match.routeId - ) { + const match = previous[index]! + if (!hasLifecycleMatch(matches, nextEnd, match.routeId)) { ;(router.routesById as Record)[ match.routeId ]!.options.onLeave?.(match) } - if ( - match.status === 'error' || - match.status === 'notFound' || - match._notFound - ) { - break - } } - for (const match of matches) { + for (let index = 0; index < nextEnd; index++) { if (owner && router._tx !== owner) { return } + const match = matches[index]! ;(router.routesById as Record)[match.routeId]!.options[ - previous.find( - (candidate) => - candidate.routeId === match.routeId || - candidate.status === 'error' || - candidate.status === 'notFound' || - candidate._notFound, - )?.routeId === match.routeId + hasLifecycleMatch(previous, previousEnd, match.routeId) ? 'onStay' : 'onEnter' ]?.(match) - if ( - match.status === 'error' || - match.status === 'notFound' || - match._notFound - ) { - break - } } } - -// Compare callback dispatch itself, with both implementations receiving the -// same snapshots. Batch small route trees to amortize the timing overhead. -for (const depth of [1, 2, 4, 12]) { - for (const scenario of [ - 'stay', - 'replace', - 'hide', - 'reveal', - 'hidden', - 'first-load', - 'no-hooks', - 'sparse-hooks', - 'root-error', - 'leaf-notFound', - ] as const) { - if ( - process.env.LIFECYCLE_BENCH_CASE && - !`${scenario}, depth ${depth}`.includes(process.env.LIFECYCLE_BENCH_CASE) - ) { - continue - } - describe(`${scenario}, depth ${depth}`, () => { - 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 === 'root-error') { - matches[0]!.status = 'error' +// 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 (scenario === 'leaf-notFound') { - matches[depth - 1]!.status = 'notFound' - } - const owner = depth === 1 ? 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 candidateLifecycle = ( - router: AnyRouter, - previous: Array, - matches: Array, - owner?: LoadTransaction, - ) => { - const nextEnd = (router._lifecycleEnd = lifecycleEnd(matches)) - runRouteLifecycle( - router, - previous, - matches, - previousEnd, - nextEnd, - owner, + if ( + process.env.LIFECYCLE_BENCH_CASE && + !`${scenario}, depth ${depth}, ${mode}`.includes( + process.env.LIFECYCLE_BENCH_CASE, ) + ) { + continue } - 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 }, + 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/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') +}) From 47a5ad52375abfec6e92c49f8d23d05df974284e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 5 Sep 2026 01:42:07 +0200 Subject: [PATCH 6/8] perf(router-core): reduce fallback lifecycle bundle overhead --- packages/router-core/src/router.ts | 51 ++++++++++++++---------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 8d201281160..16b5d29aa4d 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -929,26 +929,14 @@ export function _getUserHistoryState({ } export function lifecycleEnd(matches: Array) { - const boundary = matches.findIndex( - (match) => - match.status === 'error' || - match.status === 'notFound' || - match._notFound, + return ( + matches.findIndex( + (match) => + match.status === 'error' || + match.status === 'notFound' || + match._notFound, + ) + 1 ) - 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. */ @@ -960,24 +948,32 @@ export function runRouteLifecycle( nextEnd: number, owner?: LoadTransaction, ): void { - for (let index = 0; index < previousEnd; index++) { + // Zero means the full branch; copy only when a fallback limits membership. + 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 } - const match = previous[index]! - if (!hasLifecycleMatch(matches, nextEnd, match.routeId)) { + if (!matches.some((candidate) => candidate.routeId === match.routeId)) { ;(router.routesById as Record)[ match.routeId ]!.options.onLeave?.(match) } } - for (let index = 0; index < nextEnd; index++) { + for (const match of matches) { if (owner && router._tx !== owner) { return } - const match = matches[index]! - ;(router.routesById as Record)[match.routeId]!.options[ - hasLifecycleMatch(previous, previousEnd, match.routeId) + const route = (router.routesById as Record)[ + match.routeId + ]! + route.options[ + previous.some((candidate) => candidate.routeId === match.routeId) ? 'onStay' : 'onEnter' ]?.(match) @@ -1104,7 +1100,8 @@ export class RouterCore< _cache = new Map() /** Accepted semantic lane, excluding temporary pending presentation. */ _committed: Array = [] - // Foreground lifecycle membership survives invalidation/background statuses. + // Foreground lifecycle boundary survives invalidation/background statuses. + // Zero means the whole branch participates. _lifecycleEnd = 0 // Must build in constructor From 9f1668406cf5be9ea59e8e8824edf766b5128d74 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 5 Sep 2026 01:55:04 +0200 Subject: [PATCH 7/8] perf(router-core): omit unused initial lifecycle boundary --- packages/router-core/src/router.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 16b5d29aa4d..9007d63a909 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -944,11 +944,11 @@ export function runRouteLifecycle( router: AnyRouter, previous: Array, matches: Array, - previousEnd: number, + previousEnd: number | undefined, nextEnd: number, owner?: LoadTransaction, ): void { - // Zero means the full branch; copy only when a fallback limits membership. + // Zero or undefined means the full branch; copy only at a fallback. if (previousEnd) { previous = previous.slice(0, previousEnd) } @@ -1101,8 +1101,8 @@ export class RouterCore< /** Accepted semantic lane, excluding temporary pending presentation. */ _committed: Array = [] // Foreground lifecycle boundary survives invalidation/background statuses. - // Zero means the whole branch participates. - _lifecycleEnd = 0 + // Zero or undefined means the whole branch participates. + declare _lifecycleEnd?: number // Must build in constructor stores!: RouterStores From a383ad51868139c1cbd7a641db687ce715f663c0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 5 Sep 2026 13:55:44 +0200 Subject: [PATCH 8/8] fix(router-core): keep lifecycle metadata declaration compatible with Playwright --- packages/router-core/src/router.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 9007d63a909..e5b6255c63b 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1035,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. */ @@ -1100,9 +1105,6 @@ export class RouterCore< _cache = new Map() /** Accepted semantic lane, excluding temporary pending presentation. */ _committed: Array = [] - // Foreground lifecycle boundary survives invalidation/background statuses. - // Zero or undefined means the whole branch participates. - declare _lifecycleEnd?: number // Must build in constructor stores!: RouterStores