router icon indicating copy to clipboard operation
router copied to clipboard

`notFound()` with custom `routeId` in `onError` callback doesn't render target route's `notFoundComponent`

Open Rendez opened this issue 2 months ago • 5 comments

Which project does this relate to?

Router

Describe the bug

Problem

When throwing a notFound() error with a custom routeId option from a route's onError callback, the error is correctly assigned to the target route's match in the router state, but the target route's notFoundComponent never renders. Instead, the application hangs in a blank state.

Root Cause

The issue stems from a mismatch between the route loading logic and the rendering logic:

  1. Loading Phase (Works Correctly): The _handleNotFound function in packages/router-core/src/load-matches.ts:53-91 correctly identifies the target route and assigns the error to its match with status: 'notFound'. 1

  2. Rendering Phase (Fails): Each route's Match component wraps its content in a CatchNotFound boundary. The fallback logic in packages/react-router/src/Match.tsx:113-125 checks if error.routeId !== matchState.routeId and re-throws the error if they don't match. 2

  3. The Hang: When a child route's onError throws notFound({ routeId: parentRouteId }), the child route's CatchNotFound boundary re-throws the error because the routeId doesn't match. This error bubbles up through parent routes, each re-throwing it for the same reason, until it reaches the target route. However, by this point, the rendering has already failed to display the notFoundComponent.

Code References

Loading logic that works correctly:

Rendering logic that causes the issue:

Same issue exists in Solid Router:

Potential Solutions

  1. Modify the re-throw logic in CatchNotFound to check if the current match's status is already 'notFound' and render accordingly, rather than always re-throwing when routeId doesn't match.

  2. Add special handling for onError-originated errors that allows them to be rendered by the target route even when thrown from a child route.

  3. Document the limitation and recommend throwing notFound with custom routeId only from loader, not from onError.

Benefits of Fixing This

  1. Consistent API: The routeId option would work consistently across all error throwing contexts (loader, beforeLoad, and onError).

  2. Better error handling flexibility: Developers could centralize not-found error handling in parent routes even when errors originate from child route error handlers.

  3. Matches documentation: The not-found errors guide suggests this pattern should work but doesn't mention the onError limitation.

Workaround

Currently, the only workaround is to throw notFound({ routeId }) directly from loader instead of from onError:

export const Route = createFileRoute('/_layout/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    if (!post) throw notFound({ routeId: '/_layout' })
    return { post }
  }
})

Your Example Website or App

https://stackblitz.com/edit/github-ieacg2xa?file=src%2Fmain.tsx

Steps to Reproduce the Bug or Issue

// Parent route with notFoundComponent
export const Route = createFileRoute('/_layout')({
  component: () => <div><Outlet /></div>,
  notFoundComponent: () => <div>Not found in layout!</div>
})

// Child route that throws notFound with parent routeId in onError
export const Route = createFileRoute('/_layout/posts/$postId')({
  loader: async ({ params }) => {
    const post = await fetchPost(params.postId)
    if (!post) throw new Error('Post not found')
    return { post }
  },
  onError: (error) => {
    if (error.message === 'Post not found') {
      // This correctly updates router state but never renders
      throw notFound({ routeId: '/_layout' })
    }
  }
})

Expected behavior

Expected: The /_layout route's notFoundComponent should render. Or at the very least the route's defaultNotFoundComponent, just like one thrown within beforeLoad does.

Actual: The app hangs with a blank screen. The router state shows the correct match has status: 'notFound', but the component never renders due to the re-throw logic.

Screenshots or Videos

No response

Platform

  • Router Version: 1.132.31

Additional context

This issue affects both React Router (packages/react-router/src/Match.tsx:113-125) and Solid Router (packages/solid-router/src/Match.tsx:112-127) implementations identically, suggesting it's an architectural design decision rather than a bug. 2 3

The documentation at docs/router/framework/react/guide/not-found-errors.md:169-201 shows examples of using routeId but only from loader context, not from onError. 4

Rendez avatar Oct 20 '25 11:10 Rendez

@Rendez , can you try this again with the latest version? we've made some improvements to the notfound, so this might have been resolved.

birkskyum avatar Oct 22 '25 03:10 birkskyum

@birkskyum could you point me to the release link please?

Rendez avatar Oct 22 '25 07:10 Rendez

https://github.com/TanStack/router/releases/tag/v1.133.18

Part of the "solid-start: improve hydration"

Specifically this change to load-matches

  • https://github.com/TanStack/router/pull/5518/files#diff-335fd021cd7df4dd0be46185c59bf58f8e78ec9ec0aa84df7eefed16a4672bda

birkskyum avatar Oct 22 '25 11:10 birkskyum

We've experienced what I think is a similar problem and have a proposed PR https://github.com/TanStack/router/pull/5686 incase its any use

matclayton avatar Nov 19 '25 15:11 matclayton

We've merged #5686 - @Rendez , would you mind checking if this issue is resolved?

birkskyum avatar Nov 19 '25 20:11 birkskyum