diff --git a/docs/router/framework/react/api/router.md b/docs/router/framework/react/api/router.md index ad3a58f8512..e760a073788 100644 --- a/docs/router/framework/react/api/router.md +++ b/docs/router/framework/react/api/router.md @@ -32,6 +32,7 @@ title: Router API - [``](../router/linkComponent.md) - [``](../router/matchRouteComponent.md) - [``](../router/navigateComponent.md) + - [``](../router/notFoundComponentComponent.md) - [``](../router/outletComponent.md) - Hooks - [`useAwaited`](../router/useAwaitedHook.md) diff --git a/docs/router/framework/react/api/router/notFoundComponentComponent.md b/docs/router/framework/react/api/router/notFoundComponentComponent.md new file mode 100644 index 00000000000..63b9f93ae05 --- /dev/null +++ b/docs/router/framework/react/api/router/notFoundComponentComponent.md @@ -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` +- 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 diff --git a/examples/react/kitchen-sink/src/main.tsx b/examples/react/kitchen-sink/src/main.tsx index e22afc9fc49..ce7687c6669 100644 --- a/examples/react/kitchen-sink/src/main.tsx +++ b/examples/react/kitchen-sink/src/main.tsx @@ -11,6 +11,7 @@ import { createRoute, createRouter, lazyRouteComponent, + notFound, redirect, retainSearchParams, useNavigate, @@ -29,6 +30,7 @@ import { postInvoice, } from './mockTodos' import { useMutation } from './useMutation' +import type { NotFoundRouteProps } from '@tanstack/react-router' import type { Invoice } from './mockTodos' import './styles.css' @@ -36,6 +38,39 @@ 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 ( +
+

User not found

+

+ {typeof userId === 'number' + ? `We couldn't find a user with ID ${userId}.` + : "We couldn't find the requested user."} +

+

+ Rendered by the "{routeId}" route. +

+

+ Pick another user from the list on the left to continue. +

+
+ ) +} + const rootRoute = createRootRouteWithContext<{ auth: Auth }>()({ @@ -449,6 +484,7 @@ const usersLayoutRoute = createRoute({ sortBy: usersView?.sortBy ?? 'name', }), loader: ({ deps }) => fetchUsers(deps), + notFoundComponent: UsersNotFoundComponent, component: UsersLayoutComponent, }) @@ -563,6 +599,18 @@ function UsersLayoutComponent() { ) })} +
+ Need to see how not-found errors look?{' '} + + Try loading user 404 + +
@@ -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, }) diff --git a/packages/react-router/src/renderRouteNotFound.tsx b/packages/react-router/src/renderRouteNotFound.tsx index 26cf5b09288..86f31dfc220 100644 --- a/packages/react-router/src/renderRouteNotFound.tsx +++ b/packages/react-router/src/renderRouteNotFound.tsx @@ -10,7 +10,7 @@ export function renderRouteNotFound( ) { if (!route.options.notFoundComponent) { if (router.options.defaultNotFoundComponent) { - return + return } if (process.env.NODE_ENV === 'development') { @@ -23,5 +23,5 @@ export function renderRouteNotFound( return } - return + return } diff --git a/packages/react-router/tests/not-found.test.tsx b/packages/react-router/tests/not-found.test.tsx index 9bcbdbd79c8..ce8bdf1207f 100644 --- a/packages/react-router/tests/not-found.test.tsx +++ b/packages/react-router/tests/not-found.test.tsx @@ -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 @@ -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) => ( +
+ + {isCustomData(props.data) && {props.data.message}} + +
+ ) + + const rootRoute = createRootRoute({ + component: () => ( +
+

Root Component

+
+ + link to default not found route + + + link to not found route + +
+ +
+ ), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+

Index Page

+
+ ), + }) + + const defaultNotFoundRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/default-not-found-route', + loader: () => { + throw notFound({ data: customData }) + }, + component: () => ( +
+ Should not render +
+ ), + }) + + const notFoundRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/not-found-route', + loader: () => { + throw notFound({ data: customData }) + }, + component: () => ( +
Should not render
+ ), + notFoundComponent: (props) => ( +
+ + {isCustomData(props.data) && {props.data.message}} + +
+ ), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + defaultNotFoundRoute, + notFoundRoute, + ]), + history, + defaultNotFoundComponent: DefaultNotFoundComponentWithProps, + }) + + render() + 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) +}) diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index 4b4e4404cc3..53d726ef05a 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -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 { @@ -1488,9 +1488,11 @@ export type ErrorComponentProps = { 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 } export class BaseRoute< diff --git a/packages/router-core/tests/load.test-d.ts b/packages/router-core/tests/load.test-d.ts new file mode 100644 index 00000000000..d356698cf47 --- /dev/null +++ b/packages/router-core/tests/load.test-d.ts @@ -0,0 +1,138 @@ +import { describe, expectTypeOf, test } from 'vitest' +import type { NotFoundRouteProps, RegisteredRouter, RouteIds } from '../src' + +describe('NotFoundRouteProps', () => { + test('should have correct basic property types', () => { + expectTypeOf().toEqualTypeOf< + unknown | undefined + >() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + RouteIds + >() + }) + + test('should have data as optional property', () => { + expectTypeOf().toMatchTypeOf<{ + data?: unknown + isNotFound: boolean + routeId: RouteIds + }>() + }) + + test('should have isNotFound as required property', () => { + expectTypeOf>().toEqualTypeOf<{ + isNotFound: boolean + }>() + }) + + test('should have routeId as required property', () => { + expectTypeOf>().toEqualTypeOf<{ + routeId: RouteIds + }>() + }) + + test('should be assignable with minimal required properties', () => { + const props: NotFoundRouteProps = { + isNotFound: true, + routeId: '/' as RouteIds, + } + expectTypeOf(props).toMatchTypeOf() + }) + + test('should be assignable with all properties', () => { + const props: NotFoundRouteProps = { + data: { message: 'Not found' }, + isNotFound: true, + routeId: '/' as RouteIds, + } + expectTypeOf(props).toMatchTypeOf() + }) + + test('should accept any data type', () => { + const propsWithString: NotFoundRouteProps = { + data: 'string data', + isNotFound: true, + routeId: '/' as RouteIds, + } + expectTypeOf(propsWithString).toMatchTypeOf() + + const propsWithObject: NotFoundRouteProps = { + data: { userId: 123, message: 'User not found' }, + isNotFound: true, + routeId: '/users' as RouteIds, + } + expectTypeOf(propsWithObject).toMatchTypeOf() + + const propsWithArray: NotFoundRouteProps = { + data: [1, 2, 3], + isNotFound: true, + routeId: '/' as RouteIds, + } + expectTypeOf(propsWithArray).toMatchTypeOf() + }) + + test('should accept undefined data', () => { + const props: NotFoundRouteProps = { + data: undefined, + isNotFound: true, + routeId: '/' as RouteIds, + } + expectTypeOf(props).toMatchTypeOf() + }) + + test('notFoundComponent should accept NotFoundRouteProps', () => { + type NotFoundComponent = (props: NotFoundRouteProps) => any + + const component: NotFoundComponent = (props) => { + expectTypeOf(props.data).toEqualTypeOf() + expectTypeOf(props.isNotFound).toEqualTypeOf() + expectTypeOf(props.routeId).toEqualTypeOf< + RouteIds + >() + return null + } + + expectTypeOf(component).toMatchTypeOf() + }) + + test('defaultNotFoundComponent should accept NotFoundRouteProps', () => { + type DefaultNotFoundComponent = (props: NotFoundRouteProps) => any + + const component: DefaultNotFoundComponent = (props) => { + expectTypeOf(props).toMatchTypeOf() + return null + } + + expectTypeOf(component).toMatchTypeOf() + }) + + test('should be spreadable as component props', () => { + const notFoundData = { + data: { message: 'Custom error' }, + isNotFound: true as const, + routeId: '/' as RouteIds, + } + + type SpreadProps = typeof notFoundData + expectTypeOf().toMatchTypeOf() + + const component = (props: NotFoundRouteProps) => { + expectTypeOf(props).toMatchTypeOf() + return null + } + + expectTypeOf(component).parameter(0).toMatchTypeOf() + }) + + test('should maintain type safety with spread', () => { + const data: NotFoundRouteProps = { + data: { userId: 123 }, + isNotFound: true, + routeId: '/users' as RouteIds, + } + + const spreadData = { ...data } + expectTypeOf(spreadData).toMatchTypeOf() + }) +}) diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index ae62c408ecf..0e6afc46715 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -308,7 +308,14 @@ export const MatchInner = (props: { matchId: string }): any => { {(_) => { invariant(isNotFound(match().error), 'Expected a notFound error') - return renderRouteNotFound(router, route(), match().error) + // Use Show with keyed to ensure re-render when routeId changes + return ( + + {(_routeId) => + renderRouteNotFound(router, route(), match().error) + } + + ) }} diff --git a/packages/solid-router/src/renderRouteNotFound.tsx b/packages/solid-router/src/renderRouteNotFound.tsx index b7421d863c0..4603e6c0a31 100644 --- a/packages/solid-router/src/renderRouteNotFound.tsx +++ b/packages/solid-router/src/renderRouteNotFound.tsx @@ -9,7 +9,7 @@ export function renderRouteNotFound( ) { if (!route.options.notFoundComponent) { if (router.options.defaultNotFoundComponent) { - return + return } if (process.env.NODE_ENV === 'development') { @@ -22,5 +22,5 @@ export function renderRouteNotFound( return } - return + return } diff --git a/packages/solid-router/tests/not-found.test.tsx b/packages/solid-router/tests/not-found.test.tsx index 355235e3f2a..38a3e639444 100644 --- a/packages/solid-router/tests/not-found.test.tsx +++ b/packages/solid-router/tests/not-found.test.tsx @@ -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 @@ -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) => ( +
+ + {isCustomData(props.data) && {props.data.message}} + +
+ ) + + const rootRoute = createRootRoute({ + component: () => ( +
+

Root Component

+
+ + link to default not found route + + + link to not found route + +
+ +
+ ), + }) + + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+

Index Page

+
+ ), + }) + + const defaultNotFoundRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/default-not-found-route', + loader: () => { + throw notFound({ data: customData }) + }, + component: () => ( +
+ Should not render +
+ ), + }) + + const notFoundRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/not-found-route', + loader: () => { + throw notFound({ data: customData }) + }, + component: () => ( +
Should not render
+ ), + notFoundComponent: (props) => ( +
+ + {isCustomData(props.data) && {props.data.message}} + +
+ ), + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([ + indexRoute, + defaultNotFoundRoute, + notFoundRoute, + ]), + history, + defaultNotFoundComponent: DefaultNotFoundComponentWithProps, + }) + + render(() => ) + 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) +})