Skip to content
Open
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
30 changes: 30 additions & 0 deletions .changeset/error-component-props-unknown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@tanstack/router-core': minor
'@tanstack/react-router': minor
'@tanstack/solid-router': minor
'@tanstack/vue-router': minor
---

Type the `error` prop passed to error components as `unknown` instead of `Error`.

Route loading and rendering code can throw any value — a primitive, a rich
domain object, or an `Error` — and the router already modelled `RouteMatch.error`
as `unknown`. Only the error-component contract claimed `Error`, which forced an
`as any` cast internally and misled consumers into unguarded `error.message`
access that could fail at runtime.

`ErrorComponentProps` no longer takes a `TError` type parameter. The parameter
was introduced in a previous release but never propagated through route
configuration or the React, Solid, and Vue `ErrorRouteComponent` contracts, so
it could not describe the thrown value. A typed-error API can be revisited if an
explicit error type can be threaded end to end.

Error components must now narrow before accessing error-specific properties:

```tsx
errorComponent: ({ error }) => {
const message = error instanceof Error ? error.message : String(error)

return <div>{message}</div>
}
```
15 changes: 14 additions & 1 deletion docs/router/api/router/errorComponentComponent.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,22 @@ The `ErrorComponent` component accepts the following props:

### `props.error` prop

- Type: `TError` (defaults to `Error`)
- Type: `unknown`
- The error that was thrown by the component's children

Anything can be thrown in JavaScript, and route loading and rendering code is
no exception — a `loader` may `throw 'oops'` or throw a rich domain object
instead of an `Error`. The prop is therefore typed as `unknown`, and error
components must narrow the value before accessing error-specific properties:

```tsx
errorComponent: ({ error }) => {
const message = error instanceof Error ? error.message : String(error)

return <div>{message}</div>
}
```

### `props.info` prop

- Type: `{ componentStack: string }`
Expand Down
10 changes: 5 additions & 5 deletions docs/router/guide/data-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,16 +559,16 @@ export const Route = createFileRoute('/posts')({

The `routeOptions.errorComponent` option is a component that is rendered when an error occurs during the route loading or rendering lifecycle. It is rendered with the following props:

- `error` - The error that occurred
- `error` - The error that occurred, typed as `unknown`. Anything can be thrown, so narrow it before accessing error-specific properties.
- `reset` - A function to reset the internal `CatchBoundary`

```tsx
// src/routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
errorComponent: ({ error }) => {
// Render an error message
return <div>{error.message}</div>
// `error` is `unknown`, so narrow it before reading `message`
return <div>{error instanceof Error ? error.message : String(error)}</div>
},
})
```
Expand All @@ -582,7 +582,7 @@ export const Route = createFileRoute('/posts')({
errorComponent: ({ error, reset }) => {
return (
<div>
{error.message}
{error instanceof Error ? error.message : String(error)}
<button
onClick={() => {
// Reset the router error boundary
Expand All @@ -608,7 +608,7 @@ export const Route = createFileRoute('/posts')({

return (
<div>
{error.message}
{error instanceof Error ? error.message : String(error)}
<button
onClick={() => {
// Invalidate the route to reload the loader, which will also reset the error boundary
Expand Down
2 changes: 1 addition & 1 deletion docs/router/guide/external-data-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export const Route = createFileRoute('/')({

return (
<div>
{error.message}
{error instanceof Error ? error.message : String(error)}
<button
onClick={() => {
// Invalidate the route to reload the loader, and reset any router error boundaries
Expand Down
16 changes: 12 additions & 4 deletions docs/router/how-to/setup-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,12 @@ export function LoadingComponent() {
return <div data-testid="loading">Loading...</div>
}

export function ErrorComponent({ error }: { error: Error }) {
return <div data-testid="error">Error: {error.message}</div>
export function ErrorComponent({ error }: { error: unknown }) {
return (
<div data-testid="error">
Error: {error instanceof Error ? error.message : String(error)}
</div>
)
}
```

Expand Down Expand Up @@ -586,8 +590,12 @@ describe('Code-Based Route Data Loading', () => {
return <div>{user.name}</div>
}

function ErrorComponent({ error }: { error: Error }) {
return <div data-testid="error">Error: {error.message}</div>
function ErrorComponent({ error }: { error: unknown }) {
return (
<div data-testid="error">
Error: {error instanceof Error ? error.message : String(error)}
</div>
)
}

const userRoute = createRoute({
Expand Down
8 changes: 5 additions & 3 deletions docs/router/how-to/use-environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,11 @@ const fetchPosts = async () => {

export const Route = createFileRoute('/posts/')({
loader: fetchPosts,
errorComponent: ({ error }) => (
<div>Error loading posts: {error.message}</div>
),
errorComponent: ({ error }) => {
const message = error instanceof Error ? error.message : String(error)

return <div>Error loading posts: {message}</div>
},
})
```

Expand Down
4 changes: 2 additions & 2 deletions docs/router/how-to/validate-search-params.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const Route = createFileRoute('/products')({
return (
<div className="error">
<h2>Invalid Search Parameters</h2>
<p>{error.message}</p>
<p>{error instanceof Error ? error.message : String(error)}</p>
<button
onClick={() => router.navigate({ to: '/products', search: {} })}
>
Expand Down Expand Up @@ -302,7 +302,7 @@ export const Route = createFileRoute('/search')({
return (
<div className="error">
<h2>Invalid Search Parameters</h2>
<p>{error.message}</p>
<p>{error instanceof Error ? error.message : String(error)}</p>
<button onClick={() => router.navigate({ to: '/search', search: {} })}>
Reset Search
</button>
Expand Down
6 changes: 5 additions & 1 deletion docs/start/framework/react/guide/server-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,11 @@ export const Route = createFileRoute('/')({
// If this fails, the errorComponent renders
Greeting: await getGreeting(),
}),
errorComponent: ({ error }) => <div>Failed to load: {error.message}</div>,
errorComponent: ({ error }) => (
<div>
Failed to load: {error instanceof Error ? error.message : String(error)}
</div>
),
component: HomePage,
})
```
Expand Down
4 changes: 2 additions & 2 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
ErrorComponent
return (
<RouteErrorComponent
error={match.error as any}
error={match.error}
reset={undefined as any}
info={{
componentStack: '',
Expand Down Expand Up @@ -507,7 +507,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({
ErrorComponent
return (
<RouteErrorComponent
error={match.error as any}
error={match.error}
reset={undefined as any}
info={{
componentStack: '',
Expand Down
21 changes: 21 additions & 0 deletions packages/react-router/tests/errorComponent.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { expectTypeOf, test } from 'vitest'
import type { ErrorComponentProps } from '../src'

test('error prop is unknown', () => {
expectTypeOf<ErrorComponentProps['error']>().toBeUnknown()
})

test('error-specific properties are accessible after narrowing', () => {
const narrowed = (props: ErrorComponentProps) =>
props.error instanceof Error ? props.error.message : String(props.error)

expectTypeOf(narrowed).returns.toEqualTypeOf<string>()
})

test('error-specific properties are inaccessible without narrowing', () => {
const unnarrowed = (props: ErrorComponentProps) =>
// @ts-expect-error - `error` is `unknown`; narrow it before accessing `message`
props.error.message

expectTypeOf(unnarrowed).toBeFunction()
})
11 changes: 9 additions & 2 deletions packages/react-router/tests/errorComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import {
import type { ErrorComponentProps, RouterHistory } from '../src'

function MyErrorComponent(props: ErrorComponentProps) {
return <div>Error: {props.error.message}</div>
const message =
props.error instanceof Error ? props.error.message : String(props.error)

return <div>Error: {message}</div>
}

async function asyncToThrowFn() {
Expand Down Expand Up @@ -380,7 +383,11 @@ test('#4684: SSR renders head content when beforeLoad throws', async () => {
component: function FailingRoute() {
return <div>Route content</div>
},
errorComponent: ({ error }) => <div>Error UI: {error.message}</div>,
errorComponent: ({ error }) => (
<div>
Error UI: {error instanceof Error ? error.message : String(error)}
</div>
),
})

const handler = createRequestHandler({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ function setup({ failVia }: { failVia: 'render' | 'loader' }) {
history: createMemoryHistory({ initialEntries: ['/test'] }),
defaultErrorComponent: (props: ErrorComponentProps) => {
errorRenders++
return <div data-testid="error-ui">error: {props.error.message}</div>
const message =
props.error instanceof Error ? props.error.message : String(props.error)

return <div data-testid="error-ui">error: {message}</div>
},
})

Expand Down
6 changes: 5 additions & 1 deletion packages/react-router/tests/lazy/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { createLazyRoute } from '../../src'
export function Route(id: string) {
return createLazyRoute(id)({
component: () => <div>About route content</div>,
errorComponent: ({ error }) => <div>Lazy Error: {error.message}</div>,
errorComponent: ({ error }) => (
<div>
Lazy Error: {error instanceof Error ? error.message : String(error)}
</div>
),
})
}
5 changes: 4 additions & 1 deletion packages/react-router/tests/loaders.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -895,9 +895,12 @@ test('reproducer for #6388 - rapid navigation between parameterized routes shoul
},
errorComponent: ({ error }) => {
errorComponentRenderCount(error)
const message = error instanceof Error ? error.message : ''
const name = error instanceof Error ? error.name : ''

return (
<div data-testid="error-component">
Error Component: {error.message} | Name: {error.name}
Error Component: {message} | Name: {name}
</div>
)
},
Expand Down
8 changes: 5 additions & 3 deletions packages/react-router/tests/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1853,7 +1853,7 @@ describe('search params in URL', () => {

describe.each(testCases)('search param validation', (validateSearch) => {
it('does not throw an error when the search param is valid', async () => {
let errorSpy: Error | undefined
let errorSpy: unknown
const rootRoute = createRootRoute({
validateSearch,
errorComponent: ({ error }) => {
Expand All @@ -1873,7 +1873,7 @@ describe('search params in URL', () => {
})

it('throws an error when the search param is not valid', async () => {
let errorSpy: Error | undefined
let errorSpy: unknown
const rootRoute = createRootRoute({
validateSearch,
errorComponent: ({ error }) => {
Expand All @@ -1888,7 +1888,9 @@ describe('search params in URL', () => {
await act(() => router.load())

expect(errorSpy).toBeInstanceOf(SearchParamError)
expect(errorSpy?.cause).toBeInstanceOf(TestValidationError)
expect((errorSpy as SearchParamError).cause).toBeInstanceOf(
TestValidationError,
)
})
})
})
Expand Down
4 changes: 2 additions & 2 deletions packages/router-core/src/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1607,8 +1607,8 @@ export type ErrorRouteProps = {
reset: () => void
}

export type ErrorComponentProps<TError = Error> = {
error: TError
export type ErrorComponentProps = {
error: unknown
info?: { componentStack: string }
reset: () => void
}
Expand Down
21 changes: 21 additions & 0 deletions packages/solid-router/tests/errorComponent.test-d.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { expectTypeOf, test } from 'vitest'
import type { ErrorComponentProps } from '../src'

test('error prop is unknown', () => {
expectTypeOf<ErrorComponentProps['error']>().toBeUnknown()
})

test('error-specific properties are accessible after narrowing', () => {
const narrowed = (props: ErrorComponentProps) =>
props.error instanceof Error ? props.error.message : String(props.error)

expectTypeOf(narrowed).returns.toEqualTypeOf<string>()
})

test('error-specific properties are inaccessible without narrowing', () => {
const unnarrowed = (props: ErrorComponentProps) =>
// @ts-expect-error - `error` is `unknown`; narrow it before accessing `message`
props.error.message

expectTypeOf(unnarrowed).toBeFunction()
})
5 changes: 4 additions & 1 deletion packages/solid-router/tests/errorComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import {
import type { ErrorComponentProps } from '../src'

function MyErrorComponent(props: ErrorComponentProps) {
return <div>Error: {props.error.message}</div>
const message = () =>
props.error instanceof Error ? props.error.message : String(props.error)

return <div>Error: {message()}</div>
}

async function asyncToThrowFn() {
Expand Down
8 changes: 6 additions & 2 deletions packages/solid-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3660,7 +3660,9 @@ describe('Link', () => {

test('when navigating from /invoices to ./invoiceId and the current route is /posts/$postId/details', async () => {
const rootRoute = createRootRoute({
errorComponent: (err) => <div>{err.error.message}</div>,
errorComponent: (err) => (
<div>{err.error instanceof Error ? err.error.message : ''}</div>
),
})

const indexRoute = createRoute({
Expand Down Expand Up @@ -5629,7 +5631,9 @@ describe('search middleware', () => {

test('search middlewares work', async () => {
const rootRoute = createRootRoute({
errorComponent: (error) => <div>{error.error.stack}</div>,
errorComponent: (error) => (
<div>{error.error instanceof Error ? error.error.stack : ''}</div>
),
validateSearch: (input) => {
return {
root: input.root as string | undefined,
Expand Down
Loading