From 7963ad6ec58012327af4852dd0a43f4209f680ef Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 25 Aug 2026 09:44:12 +0200 Subject: [PATCH] fix(router-core): skip descendant lifecycle callbacks --- ...8128-descendant-lifecycle-context.test.tsx | 64 +++++++++++++++++++ packages/router-core/src/router.ts | 37 +++++++++-- 2 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 packages/react-router/tests/issue-8128-descendant-lifecycle-context.test.tsx diff --git a/packages/react-router/tests/issue-8128-descendant-lifecycle-context.test.tsx b/packages/react-router/tests/issue-8128-descendant-lifecycle-context.test.tsx new file mode 100644 index 0000000000..3a648c3326 --- /dev/null +++ b/packages/react-router/tests/issue-8128-descendant-lifecycle-context.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/router.ts b/packages/router-core/src/router.ts index 900609098e..b156f8872a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -938,24 +938,49 @@ export function runRouteLifecycle( if (owner && router._tx !== owner) { return } - if (!matches.some((candidate) => candidate.routeId === match.routeId)) { + 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 } - const route = (router.routesById as Record)[ - match.routeId - ]! - route.options[ - previous.some((candidate) => candidate.routeId === match.routeId) + ;(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 + } } }