From 780ec44fbf708a7f5e5ed516fcd9c56591dee2fa Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Sat, 14 Feb 2026 01:12:17 +0100 Subject: [PATCH] fix: resolve pending state when throwing notFound in beforeLoad fixes #6021 --- packages/react-router/tests/router.test.tsx | 249 ++++++++++++++++++++ packages/router-core/src/load-matches.ts | 47 +++- 2 files changed, 284 insertions(+), 12 deletions(-) diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 052dafa377..61f5370c03 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -2484,6 +2484,255 @@ describe('statusCode', () => { ) }) +describe('notFound in beforeLoad with pendingComponent', () => { + it('should transition router.state.status to idle when child beforeLoad throws notFound and parent has pendingComponent with pendingMs: 0', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + + const rootRoute = createRootRoute({ + component: () => , + notFoundComponent: () => ( +
Root Not Found
+ ), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+ Go to child +
+ ), + }) + + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + pendingMs: 0, + pendingComponent: () => ( +
Loading...
+ ), + component: () => ( +
+ Parent + +
+ ), + notFoundComponent: () => ( +
Parent Not Found
+ ), + }) + + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: () => { + throw notFound() + }, + component: () =>
Child
, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + render() + + // Wait for initial load + await act(() => router.latestLoadPromise) + expect(router.state.status).toBe('idle') + expect(screen.getByTestId('home-page')).toBeInTheDocument() + + // Navigate to the child route that throws notFound in beforeLoad + await act(() => router.navigate({ to: '/parent/child' })) + + // The router status should eventually become idle + await waitFor(() => { + expect(router.state.status).toBe('idle') + }) + + expect(router.state.statusCode).toBe(404) + }) + + it('should transition router.state.status to idle when child beforeLoad throws notFound and parent has NO pendingComponent', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + + const rootRoute = createRootRoute({ + notFoundComponent: () => ( +
Root Not Found
+ ), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + + // Direct child of root (no intermediate parent) + const childRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/child', + beforeLoad: () => { + throw notFound() + }, + component: () =>
Child
, + notFoundComponent: () => ( +
Child Not Found
+ ), + }) + + const routeTree = rootRoute.addChildren([indexRoute, childRoute]) + const router = createRouter({ routeTree, history }) + + render() + + await act(() => router.latestLoadPromise) + expect(router.state.status).toBe('idle') + + await act(() => router.navigate({ to: '/child' })) + + await waitFor(() => { + expect(router.state.status).toBe('idle') + }) + + expect(router.state.statusCode).toBe(404) + }) + + it('should transition router.state.status to idle when nested child beforeLoad throws notFound WITHOUT pendingComponent', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + + const rootRoute = createRootRoute({ + component: () => , + notFoundComponent: () => ( +
Root Not Found
+ ), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Home
, + }) + + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + component: () => ( +
+ Parent + +
+ ), + notFoundComponent: () => ( +
Parent Not Found
+ ), + }) + + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: () => { + throw notFound() + }, + component: () =>
Child
, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + render() + + await act(() => router.latestLoadPromise) + expect(router.state.status).toBe('idle') + + await act(() => router.navigate({ to: '/parent/child' })) + + await waitFor(() => { + expect(router.state.status).toBe('idle') + }) + + expect(router.state.statusCode).toBe(404) + }) + + it('should transition router.state.status to idle when child async beforeLoad throws notFound and parent has pendingComponent with pendingMs: 0', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + + const rootRoute = createRootRoute({ + component: () => , + notFoundComponent: () => ( +
Root Not Found
+ ), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+ Go to child +
+ ), + }) + + const parentRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/parent', + pendingMs: 0, + pendingComponent: () => ( +
Loading...
+ ), + component: () => ( +
+ Parent + +
+ ), + notFoundComponent: () => ( +
Parent Not Found
+ ), + }) + + const childRoute = createRoute({ + getParentRoute: () => parentRoute, + path: '/child', + beforeLoad: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) + throw notFound() + }, + component: () =>
Child
, + }) + + const routeTree = rootRoute.addChildren([ + indexRoute, + parentRoute.addChildren([childRoute]), + ]) + const router = createRouter({ routeTree, history }) + + render() + + // Wait for initial load + await act(() => router.latestLoadPromise) + expect(router.state.status).toBe('idle') + expect(screen.getByTestId('home-page')).toBeInTheDocument() + + // Navigate to the child route that throws notFound in beforeLoad + await act(() => router.navigate({ to: '/parent/child' })) + + // The router status should eventually become idle + await waitFor(() => { + expect(router.state.status).toBe('idle') + }) + + expect(router.state.statusCode).toBe(404) + }) +}) + describe('Router rewrite functionality', () => { it('should rewrite URLs using input before router interprets them', async () => { const rootRoute = createRootRoute({ diff --git a/packages/router-core/src/load-matches.ts b/packages/router-core/src/load-matches.ts index 4523276d6a..838d77e9d3 100644 --- a/packages/router-core/src/load-matches.ts +++ b/packages/router-core/src/load-matches.ts @@ -74,7 +74,11 @@ const buildMatchContext = ( return context } -const _handleNotFound = (inner: InnerLoadContext, err: NotFoundError) => { +const _handleNotFound = ( + inner: InnerLoadContext, + err: NotFoundError, + routerCode?: string, +) => { // Find the route that should handle the not found error // First check if a specific route is requested to show the error const routeCursor = @@ -90,11 +94,19 @@ const _handleNotFound = (inner: InnerLoadContext, err: NotFoundError) => { ).defaultNotFoundComponent } - // Ensure we have a notFoundComponent - invariant( - routeCursor.options.notFoundComponent, - 'No notFoundComponent found. Please set a notFoundComponent on your route or provide a defaultNotFoundComponent to the router.', - ) + // For BEFORE_LOAD errors that will walk up to a parent route, + // don't require notFoundComponent on the current (child) route — + // an ancestor will handle it. Only enforce the invariant when + // we've reached a route that won't walk up further. + const willWalkUp = routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute + + if (!willWalkUp) { + // Ensure we have a notFoundComponent + invariant( + routeCursor.options.notFoundComponent, + 'No notFoundComponent found. Please set a notFoundComponent on your route or provide a defaultNotFoundComponent to the router.', + ) + } // Find the match for this route const matchForRoute = inner.matches.find((m) => m.routeId === routeCursor.id) @@ -109,9 +121,9 @@ const _handleNotFound = (inner: InnerLoadContext, err: NotFoundError) => { isFetching: false, })) - if ((err as any).routerCode === 'BEFORE_LOAD' && routeCursor.parentRoute) { - err.routeId = routeCursor.parentRoute.id - _handleNotFound(inner, err) + if (willWalkUp) { + err.routeId = routeCursor.parentRoute!.id + _handleNotFound(inner, err, routerCode) } } @@ -119,6 +131,7 @@ const handleRedirectAndNotFound = ( inner: InnerLoadContext, match: AnyRouteMatch | undefined, err: unknown, + routerCode?: string, ): void => { if (!isRedirect(err) && !isNotFound(err)) return @@ -159,7 +172,7 @@ const handleRedirectAndNotFound = ( err = inner.router.resolveRedirect(err) throw err } else { - _handleNotFound(inner, err) + _handleNotFound(inner, err, routerCode) throw err } } @@ -199,13 +212,23 @@ const handleSerialError = ( err.routerCode = routerCode inner.firstBadMatchIndex ??= index - handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err) + handleRedirectAndNotFound( + inner, + inner.router.getMatch(matchId), + err, + routerCode, + ) try { route.options.onError?.(err) } catch (errorHandlerErr) { err = errorHandlerErr - handleRedirectAndNotFound(inner, inner.router.getMatch(matchId), err) + handleRedirectAndNotFound( + inner, + inner.router.getMatch(matchId), + err, + routerCode, + ) } inner.updateMatch(matchId, (prev) => {