Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/calm-errors-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/react-router': patch
'@tanstack/router-core': patch
'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
Comment thread
Sheraff marked this conversation as resolved.
---

Preserve falsy thrown values in React and Vue error boundaries. Type React and Vue boundary error components and `onCatch` callbacks as `unknown`. Solid boundary errors remain typed as `Error`; SSR now wraps non-`Error` loader errors to match Solid’s native boundary behavior, preserving the original value in `cause`. Router state and loader `onError` values are unchanged.

When upgrading React or Vue, narrow boundary errors (for example, with `error instanceof Error`) before reading `message` or `stack`. `ErrorComponentProps<TError>` remains available for values narrowed to a specific error type. Route `onError` types are unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,9 @@ export const Route = createFileRoute('/broken')({
})

function BrokenError(props: ErrorComponentProps) {
return <div data-testid="error-state">{props.error.message}</div>
return (
<div data-testid="error-state">
{props.error instanceof Error ? props.error.message : String(props.error)}
</div>
)
}
2 changes: 1 addition & 1 deletion docs/router/api/router/RouteOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record<string, any>

### `onCatch` property

- Type: `(error: Error, errorInfo: ErrorInfo) => void`
- Type: `(error: unknown) => void` in React and Vue; `(error: Error) => void` in Solid
- Optional - Defaults to `routerOptions.defaultOnCatch`
- A function that will be called when errors are caught when the route encounters an error.

Expand Down
2 changes: 1 addition & 1 deletion docs/router/api/router/RouterOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ The `RouterOptions` type accepts an object with the following properties and met

### `defaultOnCatch` property

- Type: `(error: Error, errorInfo: ErrorInfo) => void`
- Type: `(error: unknown, errorInfo: ErrorInfo) => void`
- Optional
- The default `onCatch` handler for errors caught by the Router ErrorBoundary

Expand Down
2 changes: 1 addition & 1 deletion docs/router/api/router/catchBoundaryComponent.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The `CatchBoundary` component accepts the following props:

### `props.onCatch` prop

- Type: `(error: any) => void`
- Type: `(error: unknown, errorInfo: ErrorInfo) => void` in React; `(error: unknown) => void` in Vue; `(error: Error) => void` in Solid
- Optional
- A callback that will be called with the error that was thrown by the component's children.

Expand Down
8 changes: 4 additions & 4 deletions docs/router/api/router/errorComponentComponent.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ id: errorComponentComponent
title: ErrorComponent component
---

The `ErrorComponent` component is a component that renders an error message and optionally the error's message.
The `ErrorComponent` component renders an error notice and optional details about the thrown value.

## ErrorComponent props

The `ErrorComponent` component accepts the following props:

### `props.error` prop

- Type: `TError` (defaults to `Error`)
- The error that was thrown by the component's children
- Type: `unknown` in React and Vue; `Error` in Solid
- The caught error. Solid normalizes non-`Error` values, including SSR loader errors, into an `Error` with the original value in `cause`.

### `props.info` prop

Expand All @@ -27,6 +27,6 @@ The `ErrorComponent` component accepts the following props:

## ErrorComponent returns

- Returns a formatted error message with the error's message if it exists.
- Displays an error notice, with details when the thrown value has a nonempty `message`. Values without a message still display the error notice.
- The error message can be toggled by clicking the "Show Error" button.
- By default, the error message will be shown in development.
14 changes: 9 additions & 5 deletions docs/router/guide/data-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@ The `routeOptions.onCatch` option is a function that is called whenever an error
```tsx
// src/routes/posts.tsx
export const Route = createFileRoute('/posts')({
onCatch: ({ error, errorInfo }) => {
onCatch: (error) => {
// Log the error
console.error(error)
},
Expand All @@ -581,16 +581,20 @@ 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 caught value (`unknown` in React and Vue; `Error` in Solid)
- `reset` - A function to reset the internal `CatchBoundary`

`ErrorComponentProps` defaults its `error` property to `unknown` in React and Vue, where you must narrow the value before accessing properties such as `message`. In Solid, it defaults to `Error`. `ErrorComponentProps<MyError>` can describe an error after you have narrowed it; it does not restrict what a route can throw. This applies to boundary components and `onCatch` callbacks, including `defaultOnCatch`; it does not change the `onError` callback.

React and Vue boundaries pass through the caught value, including falsy values. Solid wraps non-`Error` throws in an `Error` whose `cause` contains the original value. This also applies to Solid error components rendered during SSR: existing `Error` instances are preserved, strings become the error message, and other values use the message `Unknown error`. Router state and loader `onError` callbacks retain the original value.

```tsx
// src/routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
errorComponent: ({ error }) => {
// Render an error message
return <div>{error.message}</div>
return <div>{error instanceof Error ? error.message : String(error)}</div>
},
})
```
Expand All @@ -604,7 +608,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 @@ -630,7 +634,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
2 changes: 1 addition & 1 deletion docs/router/how-to/use-environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ const fetchPosts = async () => {
export const Route = createFileRoute('/posts/')({
loader: fetchPosts,
errorComponent: ({ error }) => (
<div>Error loading posts: {error.message}</div>
<div>Error loading posts: {error instanceof Error ? error.message : String(error)}</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
2 changes: 1 addition & 1 deletion e2e/react-start/basic-auth/src/routes/_authed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const Route = createFileRoute('/_authed')({
}
},
errorComponent: ({ error }) => {
if (error.message === 'Not authenticated') {
if (error instanceof Error && error.message === 'Not authenticated') {
return <Login />
}

Expand Down
2 changes: 1 addition & 1 deletion e2e/react-start/clerk-basic/src/routes/_authed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const Route = createFileRoute('/_authed')({
}
},
errorComponent: ({ error }) => {
if (error.message === 'Not authenticated') {
if (error instanceof Error && error.message === 'Not authenticated') {
return (
<div className="flex items-center justify-center p-12">
<SignIn routing="hash" forceRedirectUrl={window.location.href} />
Expand Down
4 changes: 2 additions & 2 deletions e2e/react-start/rsc/src/routes/rsc-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const Route = createFileRoute('/rsc-error')({
component: RscErrorComponent,
})

function RouteErrorComponent({ error }: { error: Error }) {
function RouteErrorComponent({ error }: { error: unknown }) {
return (
<div style={pageStyles.container}>
<h1 data-testid="rsc-error-title" style={pageStyles.title}>
Expand Down Expand Up @@ -122,7 +122,7 @@ function RouteErrorComponent({ error }: { error: Error }) {
style={{ margin: 0, color: '#991b1b' }}
data-testid="error-message"
>
{error.message}
{error instanceof Error ? error.message : String(error)}
</h2>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ export const Route = createFileRoute('/deferred-rejection')({
}
},
errorComponent: ({ error }) => (
<div data-testid="deferred-error-boundary">{error.message}</div>
<div data-testid="deferred-error-boundary">
{error instanceof Error ? error.message : String(error)}
</div>
),
component: DeferredRejection,
})
Expand Down
21 changes: 21 additions & 0 deletions e2e/solid-start/basic/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as LayoutRouteImport } from './routes/_layout'
import { Route as DeferredRouteImport } from './routes/deferred'
import { Route as DeferredWithoutSuspenseRouteImport } from './routes/deferred-without-suspense'
import { Route as ErrorNormalizationRouteImport } from './routes/error-normalization'
import { Route as InlineScriptsRouteImport } from './routes/inline-scripts'
import { Route as LinksRouteImport } from './routes/links'
import { Route as NotFoundRouteRouteImport } from './routes/not-found/route'
Expand Down Expand Up @@ -89,6 +90,11 @@ const DeferredWithoutSuspenseRoute = DeferredWithoutSuspenseRouteImport.update({
path: '/deferred-without-suspense',
getParentRoute: () => rootRouteImport,
} as any)
const ErrorNormalizationRoute = ErrorNormalizationRouteImport.update({
id: '/error-normalization',
path: '/error-normalization',
getParentRoute: () => rootRouteImport,
} as any)
const InlineScriptsRoute = InlineScriptsRouteImport.update({
id: '/inline-scripts',
path: '/inline-scripts',
Expand Down Expand Up @@ -393,6 +399,7 @@ export interface FileRoutesByFullPath {
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/deferred': typeof DeferredRoute
'/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute
'/error-normalization': typeof ErrorNormalizationRoute
'/inline-scripts': typeof InlineScriptsRoute
'/links': typeof LinksRoute
'/posts': typeof PostsRouteWithChildren
Expand Down Expand Up @@ -451,6 +458,7 @@ export interface FileRoutesByTo {
'/specialChars': typeof SpecialCharsRouteRouteWithChildren
'/deferred': typeof DeferredRoute
'/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute
'/error-normalization': typeof ErrorNormalizationRoute
'/inline-scripts': typeof InlineScriptsRoute
'/links': typeof LinksRoute
'/scripts': typeof ScriptsRoute
Expand Down Expand Up @@ -509,6 +517,7 @@ export interface FileRoutesById {
'/_layout': typeof LayoutRouteWithChildren
'/deferred': typeof DeferredRoute
'/deferred-without-suspense': typeof DeferredWithoutSuspenseRoute
'/error-normalization': typeof ErrorNormalizationRoute
'/inline-scripts': typeof InlineScriptsRoute
'/links': typeof LinksRoute
'/posts': typeof PostsRouteWithChildren
Expand Down Expand Up @@ -572,6 +581,7 @@ export interface FileRouteTypes {
| '/specialChars'
| '/deferred'
| '/deferred-without-suspense'
| '/error-normalization'
| '/inline-scripts'
| '/links'
| '/posts'
Expand Down Expand Up @@ -630,6 +640,7 @@ export interface FileRouteTypes {
| '/specialChars'
| '/deferred'
| '/deferred-without-suspense'
| '/error-normalization'
| '/inline-scripts'
| '/links'
| '/scripts'
Expand Down Expand Up @@ -687,6 +698,7 @@ export interface FileRouteTypes {
| '/_layout'
| '/deferred'
| '/deferred-without-suspense'
| '/error-normalization'
| '/inline-scripts'
| '/links'
| '/posts'
Expand Down Expand Up @@ -750,6 +762,7 @@ export interface RootRouteChildren {
LayoutRoute: typeof LayoutRouteWithChildren
DeferredRoute: typeof DeferredRoute
DeferredWithoutSuspenseRoute: typeof DeferredWithoutSuspenseRoute
ErrorNormalizationRoute: typeof ErrorNormalizationRoute
InlineScriptsRoute: typeof InlineScriptsRoute
LinksRoute: typeof LinksRoute
PostsRoute: typeof PostsRouteWithChildren
Expand Down Expand Up @@ -797,6 +810,13 @@ declare module '@tanstack/solid-router' {
preLoaderRoute: typeof DeferredWithoutSuspenseRouteImport
parentRoute: typeof rootRouteImport
}
'/error-normalization': {
id: '/error-normalization'
path: '/error-normalization'
fullPath: '/error-normalization'
preLoaderRoute: typeof ErrorNormalizationRouteImport
parentRoute: typeof rootRouteImport
}
'/inline-scripts': {
id: '/inline-scripts'
path: '/inline-scripts'
Expand Down Expand Up @@ -1398,6 +1418,7 @@ const rootRouteChildren: RootRouteChildren = {
LayoutRoute: LayoutRouteWithChildren,
DeferredRoute: DeferredRoute,
DeferredWithoutSuspenseRoute: DeferredWithoutSuspenseRoute,
ErrorNormalizationRoute: ErrorNormalizationRoute,
InlineScriptsRoute: InlineScriptsRoute,
LinksRoute: LinksRoute,
PostsRoute: PostsRouteWithChildren,
Expand Down
30 changes: 30 additions & 0 deletions e2e/solid-start/basic/src/routes/error-normalization.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Link, createFileRoute } from '@tanstack/solid-router'
import { createSignal, onMount } from 'solid-js'

export const Route = createFileRoute('/error-normalization')({
validateSearch: (search) => ({
kind: search.kind === 'string' ? ('string' as const) : ('null' as const),
}),
loaderDeps: ({ search }) => search,
loader: ({ deps }) => {
throw deps.kind === 'string' ? 'loader failure' : null
},
errorComponent: ({ error }) => {
const [hydrated, setHydrated] = createSignal(false)
onMount(() => setHydrated(true))

return (
<section
data-testid="error-details"
data-name={error.name}
data-message={error.message}
data-cause={String(error.cause)}
data-hydrated={hydrated()}
>
<Link to="/error-normalization" search={{ kind: 'null' }}>
Throw null
</Link>
</section>
)
},
})
Loading
Loading