From 2b710bab675e73499b50185be4f5409175827696 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Mon, 24 Aug 2026 23:20:25 +0200 Subject: [PATCH] fix(router-core): retain not-found boundary during navigation --- .../tests/issue-8128-context.test.tsx | 116 ++++++++++++++++++ ...ource-notfound-retention-boundary.test.tsx | 108 ++++++++++++++++ packages/router-core/src/load-client.ts | 7 +- 3 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 packages/react-router/tests/issue-8128-context.test.tsx create mode 100644 packages/react-router/tests/issue-8128-source-notfound-retention-boundary.test.tsx diff --git a/packages/react-router/tests/issue-8128-context.test.tsx b/packages/react-router/tests/issue-8128-context.test.tsx new file mode 100644 index 0000000000..80c3c2dd7e --- /dev/null +++ b/packages/react-router/tests/issue-8128-context.test.tsx @@ -0,0 +1,116 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { expect, onTestFinished, test, vi } from 'vitest' +import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import { hydrate } from '../src/ssr/client' + +test('#8128: leaving a hydrated root not-found page preserves beforeLoad context', async () => { + const rootRenders: Array = [] + + vi.useFakeTimers() + onTestFinished(async () => { + cleanup() + delete window.$_TSR + delete (window as Window & { $R?: unknown }).$R + vi.useRealTimers() + }) + + const rootRoute = createRootRoute({ + beforeLoad: async () => { + await new Promise((resolve) => setTimeout(resolve, 250)) + return { locale: 'en' } + }, + component: () => { + const context = rootRoute.useRouteContext() + rootRenders.push(context.locale) + + return ( + <> + + Locale: {context.locale ?? 'missing'} + + + + ) + }, + notFoundComponent: () =>
Not found
, + }) + const dashboardRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/dashboard', + component: () =>
Dashboard
, + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([dashboardRoute]), + history: createMemoryHistory({ + initialEntries: ['/any-not-found-url'], + }), + defaultPendingComponent: () =>
Loading
, + defaultPendingMs: 150, + defaultPendingMinMs: 0, + }) + const rootMatch = router.matchRoutes(router.latestLocation)[0]! + // Model the public server bootstrap for a hydrated root-level not-found. + window.$_TSR = { + router: { + manifest: { routes: {} }, + dehydratedData: {}, + matches: [ + { + i: dehydrateSsrMatchId(rootMatch.id), + b: { locale: 'en' }, + u: Date.now(), + s: 'success', + ssr: true, + g: true, + }, + ], + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + initialized: false, + } + + await hydrate(router) + render() + + expect(router.state.matches[0]).toMatchObject({ + status: 'success', + _notFound: true, + context: { locale: 'en' }, + }) + expect(screen.getByText('Not found')).toBeInTheDocument() + + let navigation!: Promise + await act(async () => { + navigation = router.navigate({ to: '/dashboard' }) + await vi.advanceTimersByTimeAsync(150) + }) + + expect(router.state.matches[0]).toMatchObject({ + status: 'success', + _notFound: true, + context: { locale: 'en' }, + }) + expect(screen.getByTestId('root-locale')).toHaveTextContent('Locale: en') + expect(screen.getByText('Not found')).toBeInTheDocument() + expect(screen.queryByText('Loading')).not.toBeInTheDocument() + expect(rootRenders).not.toContain(undefined) + + await act(async () => { + await vi.advanceTimersByTimeAsync(100) + await navigation + }) + expect(screen.getByText('Dashboard')).toBeInTheDocument() +}) diff --git a/packages/react-router/tests/issue-8128-source-notfound-retention-boundary.test.tsx b/packages/react-router/tests/issue-8128-source-notfound-retention-boundary.test.tsx new file mode 100644 index 0000000000..c6816211e8 --- /dev/null +++ b/packages/react-router/tests/issue-8128-source-notfound-retention-boundary.test.tsx @@ -0,0 +1,108 @@ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, expect, onTestFinished, test } from 'vitest' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +function createGate() { + let resolve!: () => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + + return { promise, resolve } +} + +afterEach(cleanup) + +test('#8128: returning from a layout-owned fuzzy not-found retains only through its owner', async () => { + const beforeLoadStarted = createGate() + const beforeLoadGate = createGate() + onTestFinished(() => beforeLoadGate.resolve()) + let gateNextBeforeLoad = false + + const rootRoute = createRootRoute({ component: Outlet }) + const agentsLayoutRoute = createRoute({ + getParentRoute: () => rootRoute, + id: '_agents', + component: () => ( +
+

Agent operations

+ +
+ ), + notFoundComponent: () =>

Agent page not found

, + }) + const agentsRoute = createRoute({ + getParentRoute: () => agentsLayoutRoute, + path: '/agents', + beforeLoad: async () => { + if (gateNextBeforeLoad) { + gateNextBeforeLoad = false + beforeLoadStarted.resolve() + await beforeLoadGate.promise + } + }, + pendingMs: 0, + pendingMinMs: 0, + pendingComponent: () =>

Loading agents

, + component: () =>

Agents directory

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([ + agentsLayoutRoute.addChildren([agentsRoute]), + ]), + history: createMemoryHistory({ initialEntries: ['/agents'] }), + }) + + let navigation: Promise | undefined + try { + render() + + expect(await screen.findByText('Agents directory')).toBeVisible() + expect( + screen.getByRole('heading', { name: 'Agent operations' }), + ).toBeVisible() + + await act(async () => { + navigation = router.navigate({ to: '/agents/missing' } as any) + await navigation + }) + + expect( + screen.getByRole('heading', { name: 'Agent operations' }), + ).toBeVisible() + expect(screen.getByText('Agent page not found')).toBeVisible() + expect(screen.queryByText('Agents directory')).not.toBeInTheDocument() + + gateNextBeforeLoad = true + await act(async () => { + navigation = router.navigate({ to: '/agents' }) + await beforeLoadStarted.promise + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect( + screen.getByRole('heading', { name: 'Agent operations' }), + ).toBeVisible() + expect(screen.queryByText('Agent page not found')).not.toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Loading agents') + + await act(async () => { + beforeLoadGate.resolve() + await navigation + }) + + expect(screen.getByText('Agents directory')).toBeVisible() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + } finally { + await act(async () => { + await Promise.allSettled(navigation ? [navigation] : []) + }) + } +}) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 8a4c85c0aa..2f7351b47f 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -1308,15 +1308,16 @@ async function executeClientLane( if ( committed?.id !== match.id || committed.status !== 'success' || - committed._notFound || match.preload || visible?.id !== match.id || - visible.status !== 'success' || - visible._notFound + visible.status !== 'success' ) { break } retainedEnd++ + if (committed._notFound || visible._notFound) { + break + } } const tasks: Array = [] const start = options[6 /* resolvedPrefix */] ?? 0