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) => {