Skip to content
8 changes: 8 additions & 0 deletions .changeset/breezy-chicken-care.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions docs/router/api/router/RouteOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,18 +280,22 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record<string, any>
- 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

Expand Down
64 changes: 64 additions & 0 deletions packages/react-router/tests/not-found-route-lifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div>Tenant settings unavailable</div>,
})
const profileRoute = createRoute({
getParentRoute: () => unavailableSettingsRoute,
path: 'profile',
onEnter: descendantOnEnter,
component: () => <div>Profile settings</div>,
})
const router = createRouter({
routeTree: rootRoute.addChildren([
tenantRoute.addChildren([
unavailableSettingsRoute.addChildren([profileRoute]),
]),
]),
history: createMemoryHistory({
initialEntries: ['/tenants/acme/settings/profile'],
}),
})

render(<RouterProvider router={router} />)

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()
})
12 changes: 10 additions & 2 deletions packages/router-core/src/load-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -1657,7 +1664,7 @@ export function commitMatches(
handoff[1 /* finish */]()
}
}
runRouteLifecycle(router, previous, matches, tx)
runRouteLifecycle(router, previous, matches, previousEnd, nextEnd, tx)
}

/**
Expand Down Expand Up @@ -2519,6 +2526,7 @@ export async function hydrate(router: AnyRouter): Promise<void> {
},
]
router._committed = committedMatches
router._lifecycleEnd = lifecycleEnd(committedMatches)
router._handoff = handoff
router._preflight = undefined
router.batch(() => {
Expand Down
13 changes: 10 additions & 3 deletions packages/router-core/src/load-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -928,13 +928,33 @@ export function _getUserHistoryState({
return state
}

export function lifecycleEnd(matches: Array<AnyRouteMatch>) {
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<AnyRouteMatch>,
matches: Array<AnyRouteMatch>,
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
Expand Down Expand Up @@ -1015,6 +1035,11 @@ export interface RouterCore<
in out TDehydrated extends Record<string, any> = Record<string, any>,
> {
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. */
Expand Down
61 changes: 61 additions & 0 deletions packages/router-core/tests/background-trim-abort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
9 changes: 5 additions & 4 deletions packages/router-core/tests/client-lane-adversarial.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({})
Expand All @@ -378,6 +377,7 @@ describe('adversarial client lane ownership', () => {
throw parentError
},
errorComponent: () => null,
onEnter: parentOnEnter,
})
const childRoute = new BaseRoute({
getParentRoute: () => parentRoute,
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading