Skip to content

Commit 6b235a7

Browse files
authored
fix: update error handling in MatchInner to access error data directly (#4746)
1 parent a165006 commit 6b235a7

File tree

10 files changed

+499
-11
lines changed

10 files changed

+499
-11
lines changed

docs/router/framework/react/api/router.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ title: Router API
3232
- [`<Link>`](../router/linkComponent.md)
3333
- [`<MatchRoute>`](../router/matchRouteComponent.md)
3434
- [`<Navigate>`](../router/navigateComponent.md)
35+
- [`<NotFoundComponent>`](../router/notFoundComponentComponent.md)
3536
- [`<Outlet>`](../router/outletComponent.md)
3637
- Hooks
3738
- [`useAwaited`](../router/useAwaitedHook.md)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
id: notFoundComponentComponent
3+
title: NotFoundComponent component
4+
---
5+
6+
The `NotFoundComponent` component is a component that renders when a not-found error occurs in a route.
7+
8+
## NotFoundComponent props
9+
10+
The `NotFoundComponent` component accepts the following props:
11+
12+
### `props.data` prop
13+
14+
- Type: `unknown`
15+
- Optional
16+
- Custom data that is passed to the `notFoundComponent` when the not-found error is handled
17+
- This data comes from the `data` property of the `NotFoundError` object
18+
19+
### `props.isNotFound` prop
20+
21+
- Type: `boolean`
22+
- Required
23+
- A boolean value indicating whether the current state is a not-found error state
24+
- This value is always `true`
25+
26+
### `props.routeId` prop
27+
28+
- Type: `RouteIds<RegisteredRouter['routeTree']>`
29+
- Required
30+
- The ID of the route that is attempting to handle the not-found error
31+
- Must be one of the valid route IDs from the router's route tree
32+
33+
## NotFoundComponent returns
34+
35+
- Returns appropriate UI for not-found error situations
36+
- Typically includes a "page not found" message along with links to go home or navigate to previous pages

examples/react/kitchen-sink/src/main.tsx

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createRoute,
1212
createRouter,
1313
lazyRouteComponent,
14+
notFound,
1415
redirect,
1516
retainSearchParams,
1617
useNavigate,
@@ -29,13 +30,47 @@ import {
2930
postInvoice,
3031
} from './mockTodos'
3132
import { useMutation } from './useMutation'
33+
import type { NotFoundRouteProps } from '@tanstack/react-router'
3234
import type { Invoice } from './mockTodos'
3335
import './styles.css'
3436

3537
//
3638

3739
type UsersViewSortBy = 'name' | 'id' | 'email'
3840

41+
type MissingUserData = {
42+
userId: number
43+
}
44+
45+
function isMissingUserData(data: unknown): data is MissingUserData {
46+
return (
47+
typeof data === 'object' &&
48+
data !== null &&
49+
typeof (data as { userId?: unknown }).userId === 'number'
50+
)
51+
}
52+
53+
function UsersNotFoundComponent({ data, routeId }: NotFoundRouteProps) {
54+
const userId = isMissingUserData(data) ? data.userId : undefined
55+
56+
return (
57+
<div className="p-4 space-y-2">
58+
<h4 className="text-lg font-bold">User not found</h4>
59+
<p>
60+
{typeof userId === 'number'
61+
? `We couldn't find a user with ID ${userId}.`
62+
: "We couldn't find the requested user."}
63+
</p>
64+
<p className="text-xs text-gray-500">
65+
Rendered by the "{routeId}" route.
66+
</p>
67+
<p className="text-sm text-gray-500">
68+
Pick another user from the list on the left to continue.
69+
</p>
70+
</div>
71+
)
72+
}
73+
3974
const rootRoute = createRootRouteWithContext<{
4075
auth: Auth
4176
}>()({
@@ -449,6 +484,7 @@ const usersLayoutRoute = createRoute({
449484
sortBy: usersView?.sortBy ?? 'name',
450485
}),
451486
loader: ({ deps }) => fetchUsers(deps),
487+
notFoundComponent: UsersNotFoundComponent,
452488
component: UsersLayoutComponent,
453489
})
454490

@@ -563,6 +599,18 @@ function UsersLayoutComponent() {
563599
</div>
564600
)
565601
})}
602+
<div className="px-3 py-2 text-xs text-gray-500 bg-gray-100 dark:bg-gray-800/60">
603+
Need to see how not-found errors look?{' '}
604+
<Link
605+
to={userRoute.to}
606+
search={{
607+
userId: 404,
608+
}}
609+
className="text-blue-700"
610+
>
611+
Try loading user 404
612+
</Link>
613+
</div>
566614
</div>
567615
<div className="flex-initial border-l">
568616
<Outlet />
@@ -610,7 +658,19 @@ const userRoute = createRoute({
610658
loaderDeps: ({ search: { userId } }) => ({
611659
userId,
612660
}),
613-
loader: ({ deps: { userId } }) => fetchUserById(userId),
661+
loader: async ({ deps: { userId } }) => {
662+
const user = await fetchUserById(userId)
663+
664+
if (!user) {
665+
throw notFound({
666+
data: {
667+
userId,
668+
},
669+
})
670+
}
671+
672+
return user
673+
},
614674
component: UserComponent,
615675
})
616676

packages/react-router/src/renderRouteNotFound.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function renderRouteNotFound(
1010
) {
1111
if (!route.options.notFoundComponent) {
1212
if (router.options.defaultNotFoundComponent) {
13-
return <router.options.defaultNotFoundComponent data={data} />
13+
return <router.options.defaultNotFoundComponent {...data} />
1414
}
1515

1616
if (process.env.NODE_ENV === 'development') {
@@ -23,5 +23,5 @@ export function renderRouteNotFound(
2323
return <DefaultGlobalNotFound />
2424
}
2525

26-
return <route.options.notFoundComponent data={data} />
26+
return <route.options.notFoundComponent {...data} />
2727
}

packages/react-router/tests/not-found.test.tsx

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import {
99
createRootRoute,
1010
createRoute,
1111
createRouter,
12+
notFound,
1213
} from '../src'
13-
import type { RouterHistory } from '../src'
14+
import type { NotFoundRouteProps, RouterHistory } from '../src'
1415

1516
let history: RouterHistory
1617

@@ -123,3 +124,124 @@ test.each([
123124
expect(notFoundComponent).toBeInTheDocument()
124125
},
125126
)
127+
128+
test('defaultNotFoundComponent and notFoundComponent receives data props via spread operator', async () => {
129+
const isCustomData = (data: unknown): data is typeof customData => {
130+
return 'message' in (data as typeof customData)
131+
}
132+
133+
const customData = {
134+
message: 'Custom not found message',
135+
}
136+
137+
const DefaultNotFoundComponentWithProps = (props: NotFoundRouteProps) => (
138+
<div data-testid="default-not-found-with-props">
139+
<span data-testid="message">
140+
{isCustomData(props.data) && <span>{props.data.message}</span>}
141+
</span>
142+
</div>
143+
)
144+
145+
const rootRoute = createRootRoute({
146+
component: () => (
147+
<div data-testid="root-component">
148+
<h1>Root Component</h1>
149+
<div>
150+
<Link
151+
data-testid="default-not-found-route-link"
152+
to="/default-not-found-route"
153+
>
154+
link to default not found route
155+
</Link>
156+
<Link data-testid="not-found-route-link" to="/not-found-route">
157+
link to not found route
158+
</Link>
159+
</div>
160+
<Outlet />
161+
</div>
162+
),
163+
})
164+
165+
const indexRoute = createRoute({
166+
getParentRoute: () => rootRoute,
167+
path: '/',
168+
component: () => (
169+
<div data-testid="index-component">
170+
<h2>Index Page</h2>
171+
</div>
172+
),
173+
})
174+
175+
const defaultNotFoundRoute = createRoute({
176+
getParentRoute: () => rootRoute,
177+
path: '/default-not-found-route',
178+
loader: () => {
179+
throw notFound({ data: customData })
180+
},
181+
component: () => (
182+
<div data-testid="default-not-found-route-component">
183+
Should not render
184+
</div>
185+
),
186+
})
187+
188+
const notFoundRoute = createRoute({
189+
getParentRoute: () => rootRoute,
190+
path: '/not-found-route',
191+
loader: () => {
192+
throw notFound({ data: customData })
193+
},
194+
component: () => (
195+
<div data-testid="not-found-route-component">Should not render</div>
196+
),
197+
notFoundComponent: (props) => (
198+
<div data-testid="not-found-with-props">
199+
<span data-testid="message">
200+
{isCustomData(props.data) && <span>{props.data.message}</span>}
201+
</span>
202+
</div>
203+
),
204+
})
205+
206+
const router = createRouter({
207+
routeTree: rootRoute.addChildren([
208+
indexRoute,
209+
defaultNotFoundRoute,
210+
notFoundRoute,
211+
]),
212+
history,
213+
defaultNotFoundComponent: DefaultNotFoundComponentWithProps,
214+
})
215+
216+
render(<RouterProvider router={router} />)
217+
await router.load()
218+
await screen.findByTestId('root-component')
219+
220+
const defaultNotFoundRouteLink = screen.getByTestId(
221+
'default-not-found-route-link',
222+
)
223+
defaultNotFoundRouteLink.click()
224+
225+
const defaultNotFoundComponent = await screen.findByTestId(
226+
'default-not-found-with-props',
227+
{},
228+
{ timeout: 1000 },
229+
)
230+
expect(defaultNotFoundComponent).toBeInTheDocument()
231+
232+
const defaultNotFoundComponentMessage = await screen.findByTestId('message')
233+
expect(defaultNotFoundComponentMessage).toHaveTextContent(customData.message)
234+
235+
const notFoundRouteLink = screen.getByTestId('not-found-route-link')
236+
notFoundRouteLink.click()
237+
238+
const notFoundComponent = await screen.findByTestId(
239+
'not-found-with-props',
240+
{},
241+
{ timeout: 1000 },
242+
)
243+
expect(notFoundComponent).toBeInTheDocument()
244+
245+
const errorMessageComponent = await screen.findByTestId('message')
246+
expect(errorMessageComponent).toHaveTextContent(customData.message)
247+
})

packages/router-core/src/route.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type {
1414
RouteMatch,
1515
} from './Matches'
1616
import type { RootRouteId } from './root'
17-
import type { ParseRoute, RouteById, RoutePaths } from './routeInfo'
17+
import type { ParseRoute, RouteById, RouteIds, RoutePaths } from './routeInfo'
1818
import type { AnyRouter, Register, RegisteredRouter, SSROption } from './router'
1919
import type { BuildLocationFn, NavigateFn } from './RouterProvider'
2020
import type {
@@ -1488,9 +1488,11 @@ export type ErrorComponentProps<TError = Error> = {
14881488
info?: { componentStack: string }
14891489
reset: () => void
14901490
}
1491+
14911492
export type NotFoundRouteProps = {
1492-
// TODO: Make sure this is `| null | undefined` (this is for global not-founds)
1493-
data: unknown
1493+
data?: unknown
1494+
isNotFound: boolean
1495+
routeId: RouteIds<RegisteredRouter['routeTree']>
14941496
}
14951497

14961498
export class BaseRoute<

0 commit comments

Comments
 (0)