Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 249 additions & 0 deletions packages/react-router/tests/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <Outlet />,
notFoundComponent: () => (
<div data-testid="root-not-found">Root Not Found</div>
),
})

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<div data-testid="home-page">
<Link to="/parent/child">Go to child</Link>
</div>
),
})

const parentRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/parent',
pendingMs: 0,
pendingComponent: () => (
<div data-testid="pending-component">Loading...</div>
),
component: () => (
<div data-testid="parent-component">
Parent
<Outlet />
</div>
),
notFoundComponent: () => (
<div data-testid="parent-not-found">Parent Not Found</div>
),
})

const childRoute = createRoute({
getParentRoute: () => parentRoute,
path: '/child',
beforeLoad: () => {
throw notFound()
},
component: () => <div data-testid="child-component">Child</div>,
})

const routeTree = rootRoute.addChildren([
indexRoute,
parentRoute.addChildren([childRoute]),
])
const router = createRouter({ routeTree, history })

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

// 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: () => (
<div data-testid="root-not-found">Root Not Found</div>
),
})

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div data-testid="home-page">Home</div>,
})

// Direct child of root (no intermediate parent)
const childRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/child',
beforeLoad: () => {
throw notFound()
},
component: () => <div data-testid="child-component">Child</div>,
notFoundComponent: () => (
<div data-testid="child-not-found">Child Not Found</div>
),
})

const routeTree = rootRoute.addChildren([indexRoute, childRoute])
const router = createRouter({ routeTree, history })

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

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: () => <Outlet />,
notFoundComponent: () => (
<div data-testid="root-not-found">Root Not Found</div>
),
})

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div data-testid="home-page">Home</div>,
})

const parentRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/parent',
component: () => (
<div data-testid="parent-component">
Parent
<Outlet />
</div>
),
notFoundComponent: () => (
<div data-testid="parent-not-found">Parent Not Found</div>
),
})

const childRoute = createRoute({
getParentRoute: () => parentRoute,
path: '/child',
beforeLoad: () => {
throw notFound()
},
component: () => <div data-testid="child-component">Child</div>,
})

const routeTree = rootRoute.addChildren([
indexRoute,
parentRoute.addChildren([childRoute]),
])
const router = createRouter({ routeTree, history })

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

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: () => <Outlet />,
notFoundComponent: () => (
<div data-testid="root-not-found">Root Not Found</div>
),
})

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<div data-testid="home-page">
<Link to="/parent/child">Go to child</Link>
</div>
),
})

const parentRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/parent',
pendingMs: 0,
pendingComponent: () => (
<div data-testid="pending-component">Loading...</div>
),
component: () => (
<div data-testid="parent-component">
Parent
<Outlet />
</div>
),
notFoundComponent: () => (
<div data-testid="parent-not-found">Parent Not Found</div>
),
})

const childRoute = createRoute({
getParentRoute: () => parentRoute,
path: '/child',
beforeLoad: async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
throw notFound()
},
component: () => <div data-testid="child-component">Child</div>,
})

const routeTree = rootRoute.addChildren([
indexRoute,
parentRoute.addChildren([childRoute]),
])
const router = createRouter({ routeTree, history })

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

// 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({
Expand Down
47 changes: 35 additions & 12 deletions packages/router-core/src/load-matches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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)
Expand All @@ -109,16 +121,17 @@ 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)
}
}

const handleRedirectAndNotFound = (
inner: InnerLoadContext,
match: AnyRouteMatch | undefined,
err: unknown,
routerCode?: string,
): void => {
if (!isRedirect(err) && !isNotFound(err)) return

Expand Down Expand Up @@ -159,7 +172,7 @@ const handleRedirectAndNotFound = (
err = inner.router.resolveRedirect(err)
throw err
} else {
_handleNotFound(inner, err)
_handleNotFound(inner, err, routerCode)
throw err
}
}
Expand Down Expand Up @@ -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) => {
Expand Down
Loading