Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions docs/router/framework/react/api/router.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ title: Router API
- [`<Link>`](../router/linkComponent.md)
- [`<MatchRoute>`](../router/matchRouteComponent.md)
- [`<Navigate>`](../router/navigateComponent.md)
- [`<NotFoundComponent>`](../router/notFoundComponentComponent.md)
- [`<Outlet>`](../router/outletComponent.md)
- Hooks
- [`useAwaited`](../router/useAwaitedHook.md)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
id: notFoundComponentComponent
title: NotFoundComponent component
---

The `NotFoundComponent` component is a component that renders when a not-found error occurs in a route.

## NotFoundComponent props

The `NotFoundComponent` component accepts the following props:

### `props.data` prop

- Type: `unknown`
- Optional
- Custom data that is passed to the `notFoundComponent` when the not-found error is handled
- This data comes from the `data` property of the `NotFoundError` object

### `props.isNotFound` prop

- Type: `boolean`
- Required
- A boolean value indicating whether the current state is a not-found error state
- This value is always `true`

### `props.routeId` prop

- Type: `RouteIds<RegisteredRouter['routeTree']>`
- Required
- The ID of the route that is attempting to handle the not-found error
- Must be one of the valid route IDs from the router's route tree

## NotFoundComponent returns

- Returns appropriate UI for not-found error situations
- Typically includes a "page not found" message along with links to go home or navigate to previous pages
62 changes: 61 additions & 1 deletion examples/react/kitchen-sink/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createRoute,
createRouter,
lazyRouteComponent,
notFound,
redirect,
retainSearchParams,
useNavigate,
Expand All @@ -29,13 +30,47 @@ import {
postInvoice,
} from './mockTodos'
import { useMutation } from './useMutation'
import type { NotFoundRouteProps } from '@tanstack/react-router'
import type { Invoice } from './mockTodos'
import './styles.css'

//

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

type MissingUserData = {
userId: number
}

function isMissingUserData(data: unknown): data is MissingUserData {
return (
typeof data === 'object' &&
data !== null &&
typeof (data as { userId?: unknown }).userId === 'number'
)
}

function UsersNotFoundComponent({ data, routeId }: NotFoundRouteProps) {
const userId = isMissingUserData(data) ? data.userId : undefined

return (
<div className="p-4 space-y-2">
<h4 className="text-lg font-bold">User not found</h4>
<p>
{typeof userId === 'number'
? `We couldn't find a user with ID ${userId}.`
: "We couldn't find the requested user."}
</p>
<p className="text-xs text-gray-500">
Rendered by the "{routeId}" route.
</p>
<p className="text-sm text-gray-500">
Pick another user from the list on the left to continue.
</p>
</div>
)
}

const rootRoute = createRootRouteWithContext<{
auth: Auth
}>()({
Expand Down Expand Up @@ -449,6 +484,7 @@ const usersLayoutRoute = createRoute({
sortBy: usersView?.sortBy ?? 'name',
}),
loader: ({ deps }) => fetchUsers(deps),
notFoundComponent: UsersNotFoundComponent,
component: UsersLayoutComponent,
})

Expand Down Expand Up @@ -563,6 +599,18 @@ function UsersLayoutComponent() {
</div>
)
})}
<div className="px-3 py-2 text-xs text-gray-500 bg-gray-100 dark:bg-gray-800/60">
Need to see how not-found errors look?{' '}
<Link
to={userRoute.to}
search={{
userId: 404,
}}
className="text-blue-700"
>
Try loading user 404
</Link>
</div>
</div>
<div className="flex-initial border-l">
<Outlet />
Expand Down Expand Up @@ -610,7 +658,19 @@ const userRoute = createRoute({
loaderDeps: ({ search: { userId } }) => ({
userId,
}),
loader: ({ deps: { userId } }) => fetchUserById(userId),
loader: async ({ deps: { userId } }) => {
const user = await fetchUserById(userId)

if (!user) {
throw notFound({
data: {
userId,
},
})
}

return user
},
component: UserComponent,
})

Expand Down
4 changes: 2 additions & 2 deletions packages/react-router/src/renderRouteNotFound.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function renderRouteNotFound(
) {
if (!route.options.notFoundComponent) {
if (router.options.defaultNotFoundComponent) {
return <router.options.defaultNotFoundComponent data={data} />
return <router.options.defaultNotFoundComponent {...data} />
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part causes the structure to become nested like data: { data: ... }.

}

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

return <route.options.notFoundComponent data={data} />
return <route.options.notFoundComponent {...data} />
}
124 changes: 123 additions & 1 deletion packages/react-router/tests/not-found.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import {
createRootRoute,
createRoute,
createRouter,
notFound,
} from '../src'
import type { RouterHistory } from '../src'
import type { NotFoundRouteProps, RouterHistory } from '../src'

let history: RouterHistory

Expand Down Expand Up @@ -123,3 +124,124 @@ test.each([
expect(notFoundComponent).toBeInTheDocument()
},
)

test('defaultNotFoundComponent and notFoundComponent receives data props via spread operator', async () => {
const isCustomData = (data: unknown): data is typeof customData => {
return 'message' in (data as typeof customData)
}

const customData = {
message: 'Custom not found message',
}

const DefaultNotFoundComponentWithProps = (props: NotFoundRouteProps) => (
<div data-testid="default-not-found-with-props">
<span data-testid="message">
{isCustomData(props.data) && <span>{props.data.message}</span>}
</span>
</div>
)

const rootRoute = createRootRoute({
component: () => (
<div data-testid="root-component">
<h1>Root Component</h1>
<div>
<Link
data-testid="default-not-found-route-link"
to="/default-not-found-route"
>
link to default not found route
</Link>
<Link data-testid="not-found-route-link" to="/not-found-route">
link to not found route
</Link>
</div>
<Outlet />
</div>
),
})

const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<div data-testid="index-component">
<h2>Index Page</h2>
</div>
),
})

const defaultNotFoundRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/default-not-found-route',
loader: () => {
throw notFound({ data: customData })
},
component: () => (
<div data-testid="default-not-found-route-component">
Should not render
</div>
),
})

const notFoundRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/not-found-route',
loader: () => {
throw notFound({ data: customData })
},
component: () => (
<div data-testid="not-found-route-component">Should not render</div>
),
notFoundComponent: (props) => (
<div data-testid="not-found-with-props">
<span data-testid="message">
{isCustomData(props.data) && <span>{props.data.message}</span>}
</span>
</div>
),
})

const router = createRouter({
routeTree: rootRoute.addChildren([
indexRoute,
defaultNotFoundRoute,
notFoundRoute,
]),
history,
defaultNotFoundComponent: DefaultNotFoundComponentWithProps,
})

render(<RouterProvider router={router} />)
await router.load()
await screen.findByTestId('root-component')

const defaultNotFoundRouteLink = screen.getByTestId(
'default-not-found-route-link',
)
defaultNotFoundRouteLink.click()

const defaultNotFoundComponent = await screen.findByTestId(
'default-not-found-with-props',
{},
{ timeout: 1000 },
)
expect(defaultNotFoundComponent).toBeInTheDocument()

const defaultNotFoundComponentMessage = await screen.findByTestId('message')
expect(defaultNotFoundComponentMessage).toHaveTextContent(customData.message)

const notFoundRouteLink = screen.getByTestId('not-found-route-link')
notFoundRouteLink.click()

const notFoundComponent = await screen.findByTestId(
'not-found-with-props',
{},
{ timeout: 1000 },
)
expect(notFoundComponent).toBeInTheDocument()

const errorMessageComponent = await screen.findByTestId('message')
expect(errorMessageComponent).toHaveTextContent(customData.message)
})
8 changes: 5 additions & 3 deletions packages/router-core/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
RouteMatch,
} from './Matches'
import type { RootRouteId } from './root'
import type { ParseRoute, RouteById, RoutePaths } from './routeInfo'
import type { ParseRoute, RouteById, RouteIds, RoutePaths } from './routeInfo'
import type { AnyRouter, Register, RegisteredRouter, SSROption } from './router'
import type { BuildLocationFn, NavigateFn } from './RouterProvider'
import type {
Expand Down Expand Up @@ -1488,9 +1488,11 @@ export type ErrorComponentProps<TError = Error> = {
info?: { componentStack: string }
reset: () => void
}

export type NotFoundRouteProps = {
// TODO: Make sure this is `| null | undefined` (this is for global not-founds)
data: unknown
data?: unknown
isNotFound: boolean
routeId: RouteIds<RegisteredRouter['routeTree']>
}

export class BaseRoute<
Expand Down
Loading
Loading