From ca9b64c792031c45dbb3d227adb9335149111b6a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 1 Sep 2026 10:04:41 +0200 Subject: [PATCH 1/5] fix(router): handle unknown error boundary values --- .changeset/calm-errors-listen.md | 8 +++ docs/router/api/router/RouterOptionsType.md | 2 +- docs/router/guide/data-loading.md | 10 +-- packages/react-router/src/CatchBoundary.tsx | 25 ++++--- packages/react-router/src/Match.tsx | 2 +- packages/react-router/src/Matches.tsx | 2 +- packages/react-router/src/not-found.tsx | 2 +- packages/react-router/src/router.ts | 2 +- .../tests/errorComponent.test.tsx | 71 ++++++++++++++++++- ...sue-4476-react-query-cancellation.test.tsx | 6 +- ...e-6107-lazy-chunk-error-component.test.tsx | 6 +- ...earch-default-normalization-abort.test.tsx | 6 +- ...-7635-error-head-after-navigation.test.tsx | 6 +- ...-7638-invalidate-transition-error.test.tsx | 9 ++- packages/react-router/tests/lazy/error.tsx | 6 +- packages/react-router/tests/loaders.test.tsx | 4 +- packages/react-router/tests/redirect.test.tsx | 16 +++-- packages/react-router/tests/router.test.tsx | 8 ++- packages/router-core/src/route.ts | 4 +- .../tests/errorComponentProps.test-d.ts | 7 ++ packages/solid-router/src/CatchBoundary.tsx | 8 ++- packages/solid-router/src/Match.tsx | 2 +- packages/solid-router/src/Matches.tsx | 2 +- packages/solid-router/src/router.ts | 2 +- .../tests/createLazyRoute.test.tsx | 4 +- .../tests/errorComponent.test.tsx | 52 +++++++++++++- packages/solid-router/tests/link.test.tsx | 8 ++- packages/solid-router/tests/router.test.tsx | 8 ++- .../tests/server/errorComponent.test.tsx | 4 +- packages/vue-router/src/CatchBoundary.tsx | 26 ++++--- packages/vue-router/src/Match.tsx | 2 +- packages/vue-router/src/Matches.tsx | 11 ++- packages/vue-router/src/not-found.tsx | 4 +- packages/vue-router/src/router.ts | 2 +- packages/vue-router/tests/Outlet.test.tsx | 2 +- .../vue-router/tests/errorComponent.test.tsx | 48 ++++++++++++- packages/vue-router/tests/link.test.tsx | 8 ++- packages/vue-router/tests/router.test.tsx | 8 ++- 38 files changed, 320 insertions(+), 83 deletions(-) create mode 100644 .changeset/calm-errors-listen.md create mode 100644 packages/router-core/tests/errorComponentProps.test-d.ts diff --git a/.changeset/calm-errors-listen.md b/.changeset/calm-errors-listen.md new file mode 100644 index 00000000000..8dac6103b75 --- /dev/null +++ b/.changeset/calm-errors-listen.md @@ -0,0 +1,8 @@ +--- +'@tanstack/react-router': patch +'@tanstack/router-core': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +Handle arbitrary thrown values in router error boundaries and type caught errors as `unknown`. diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 709d08c621a..7a52e622413 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -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 diff --git a/docs/router/guide/data-loading.md b/docs/router/guide/data-loading.md index 26a82eaad68..1a67de33696 100644 --- a/docs/router/guide/data-loading.md +++ b/docs/router/guide/data-loading.md @@ -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) }, @@ -581,7 +581,7 @@ 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 unknown value that was thrown - `reset` - A function to reset the internal `CatchBoundary` ```tsx @@ -590,7 +590,7 @@ export const Route = createFileRoute('/posts')({ loader: () => fetchPosts(), errorComponent: ({ error }) => { // Render an error message - return
{error.message}
+ return
{error instanceof Error ? error.message : String(error)}
}, }) ``` @@ -604,7 +604,7 @@ export const Route = createFileRoute('/posts')({ errorComponent: ({ error, reset }) => { return (
- {error.message} + {error instanceof Error ? error.message : String(error)}
) : null} diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index a0bfa89d552..97b8f795892 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -236,7 +236,7 @@ export const MatchInner = React.memo(function MatchInnerImpl({ ErrorComponent const errorElement = ( React.ReactElement - onCatch?: (error: Error, errorInfo: ErrorInfo) => void + onCatch?: (error: NotFoundError, errorInfo: ErrorInfo) => void children: React.ReactNode }) { const router = useRouter() diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 178eace31bd..de5e89a9fbc 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -73,7 +73,7 @@ declare module '@tanstack/router-core' { * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultoncatch-property) * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#handling-errors-with-routeoptionsoncatch) */ - defaultOnCatch?: (error: Error, errorInfo: React.ErrorInfo) => void + defaultOnCatch?: (error: unknown, errorInfo: React.ErrorInfo) => void } } diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index b5ee4b6709c..6ad5a4e4a26 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { + CatchBoundary, HeadContent, Link, Outlet, @@ -23,7 +24,11 @@ import { import type { ErrorComponentProps, RouterHistory } from '../src' function MyErrorComponent(props: ErrorComponentProps) { - return
Error: {props.error.message}
+ return
Error: {getErrorMessage(props.error)}
+} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) } async function asyncToThrowFn() { @@ -324,7 +329,9 @@ test('ancestor route errorComponent resets when a background child generation re let loaderCalls = 0 const rootRoute = createRootRoute({ component: Outlet, - errorComponent: ({ error }) =>
Ancestor error: {error.message}
, + errorComponent: ({ error }) => ( +
Ancestor error: {getErrorMessage(error)}
+ ), }) const childRoute = createRoute({ getParentRoute: () => rootRoute, @@ -422,6 +429,62 @@ test('errorComponent receives primitive errors thrown from beforeLoad', async () expect(screen.queryByText('About route content')).not.toBeInTheDocument() }) +test.each([ + ['false', false], + ['zero', 0], + ['negative zero', -0], + ['bigint zero', 0n], + ['empty string', ''], + ['null', null], + ['undefined', undefined], + ['NaN', NaN], +] as const)('CatchBoundary renders falsy thrown value %s', (_, thrown) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + const onCatch = vi.fn() + + function ThrowFalsy(): never { + throw thrown + } + + render( + 0} + errorComponent={({ error }) => ( +
{Object.is(error, thrown) ? 'Caught value' : 'Wrong value'}
+ )} + onCatch={onCatch} + > + +
, + ) + + expect(screen.getByText('Caught value')).toBeInTheDocument() + expect(screen.queryByText('Wrong value')).not.toBeInTheDocument() + expect(onCatch).toHaveBeenCalledWith(thrown, expect.anything()) +}) + +test.each([ + ['null', null], + ['undefined', undefined], +] as const)('default error UI renders thrown %s', async (_, thrown) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + function ThrowFalsy(): never { + throw thrown + } + + const rootRoute = createRootRoute({ component: ThrowFalsy }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + + expect(await screen.findByText('Something went wrong!')).toBeInTheDocument() +}) + test.each(['beforeLoad', 'loader'] as const)( 'a Promise synchronously thrown from %s renders the route error UI', async (hook) => { @@ -736,7 +799,9 @@ test('#4684: SSR renders head content when beforeLoad throws', async () => { component: function FailingRoute() { return
Route content
}, - errorComponent: ({ error }) =>
Error UI: {error.message}
, + errorComponent: ({ error }) => ( +
Error UI: {getErrorMessage(error)}
+ ), }) const handler = createRequestHandler({ diff --git a/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx b/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx index 039608ffb2a..824fcc1fb78 100644 --- a/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx +++ b/packages/react-router/tests/issue-4476-react-query-cancellation.test.tsx @@ -77,7 +77,11 @@ test('#4476: pending navigation keeps the query observer mounted and its fetchQu }, errorComponent: ({ error }) => { routeError(error) - return
{error.name}
+ return ( +
+ {error instanceof Error ? error.name : String(error)} +
+ ) }, component: () => { const { data } = pageTwoRoute.useRouteContext() diff --git a/packages/react-router/tests/issue-6107-lazy-chunk-error-component.test.tsx b/packages/react-router/tests/issue-6107-lazy-chunk-error-component.test.tsx index 22440fe1190..138e7f5716f 100644 --- a/packages/react-router/tests/issue-6107-lazy-chunk-error-component.test.tsx +++ b/packages/react-router/tests/issue-6107-lazy-chunk-error-component.test.tsx @@ -59,7 +59,11 @@ test('#6107: lazy chunk hover failure is non-fatal and navigation renders defaul defaultPreloadDelay: 0, defaultErrorComponent: ({ error }) => { defaultErrorRendered(error) - return
{error.message}
+ return ( +
+ {error instanceof Error ? error.message : String(error)} +
+ ) }, }) const preloadRoute = vi.spyOn(router, 'preloadRoute') diff --git a/packages/react-router/tests/issue-6371-search-default-normalization-abort.test.tsx b/packages/react-router/tests/issue-6371-search-default-normalization-abort.test.tsx index 7698d117a6a..836ff045bc4 100644 --- a/packages/react-router/tests/issue-6371-search-default-normalization-abort.test.tsx +++ b/packages/react-router/tests/issue-6371-search-default-normalization-abort.test.tsx @@ -73,7 +73,11 @@ test('#6371: initial search defaults produce one live canonical loader', async ( ), errorComponent: ({ error }) => { errorComponentRendered(error) - return
{error.message}
+ return ( +
+ {error instanceof Error ? error.message : String(error)} +
+ ) }, }) const history = createMemoryHistory({ initialEntries: ['/about'] }) diff --git a/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx b/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx index 56aed7b375d..23bc0aa5f37 100644 --- a/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx +++ b/packages/react-router/tests/issue-7635-error-head-after-navigation.test.tsx @@ -63,7 +63,11 @@ test('#7635: a parent beforeLoad error replaces the previous child title', async component: Outlet, errorComponent: ({ error }) => { appErrorRendered(error) - return
{error.message}
+ return ( +
+ {error instanceof Error ? error.message : String(error)} +
+ ) }, }) const childRoute = createRoute({ diff --git a/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx b/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx index 9f1c0eec3f5..e80f073896f 100644 --- a/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx +++ b/packages/react-router/tests/issue-7638-invalidate-transition-error.test.tsx @@ -95,7 +95,14 @@ function setup({ failVia }: { failVia: 'render' | 'loader' }) { history: createMemoryHistory({ initialEntries: ['/test'] }), defaultErrorComponent: (props: ErrorComponentProps) => { errorRenders++ - return
error: {props.error.message}
+ return ( +
+ error:{' '} + {props.error instanceof Error + ? props.error.message + : String(props.error)} +
+ ) }, }) diff --git a/packages/react-router/tests/lazy/error.tsx b/packages/react-router/tests/lazy/error.tsx index 6ef7036e33f..271f6a058ec 100644 --- a/packages/react-router/tests/lazy/error.tsx +++ b/packages/react-router/tests/lazy/error.tsx @@ -3,6 +3,10 @@ import { createLazyRoute } from '../../src' export function Route(id: string) { return createLazyRoute(id)({ component: () =>
About route content
, - errorComponent: ({ error }) =>
Lazy Error: {error.message}
, + errorComponent: ({ error }) => ( +
+ Lazy Error: {error instanceof Error ? error.message : String(error)} +
+ ), }) } diff --git a/packages/react-router/tests/loaders.test.tsx b/packages/react-router/tests/loaders.test.tsx index 6859b963e25..b96931d61ae 100644 --- a/packages/react-router/tests/loaders.test.tsx +++ b/packages/react-router/tests/loaders.test.tsx @@ -922,7 +922,9 @@ test('reproducer for #6388 - rapid navigation between parameterized routes shoul errorComponentRenderCount(error) return (
- Error Component: {error.message} | Name: {error.name} + Error Component:{' '} + {error instanceof Error ? error.message : String(error)} | Name:{' '} + {error instanceof Error ? error.name : typeof error}
) }, diff --git a/packages/react-router/tests/redirect.test.tsx b/packages/react-router/tests/redirect.test.tsx index 3cd6a3d88f4..ea8cd30dd44 100644 --- a/packages/react-router/tests/redirect.test.tsx +++ b/packages/react-router/tests/redirect.test.tsx @@ -28,6 +28,10 @@ import type { RouterHistory } from '../src' let history: RouterHistory +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} + beforeEach(() => { history = createBrowserHistory() expect(window.location.pathname).toBe('/') @@ -95,7 +99,7 @@ describe('redirect', () => { }) }, errorComponent: ({ error }) => ( -
{error.message}
+
{getErrorMessage(error)}
), }) const targetRoute = createRoute({ @@ -132,7 +136,7 @@ describe('redirect', () => { }) const rootRoute = createRootRoute({ errorComponent: ({ error }) => ( -
Root: {error.message}
+
Root: {getErrorMessage(error)}
), }) const indexRoute = createRoute({ @@ -140,7 +144,7 @@ describe('redirect', () => { path: '/', loader, errorComponent: ({ error }) => ( -
Index: {error.message}
+
Index: {getErrorMessage(error)}
), }) const router = createRouter({ @@ -168,7 +172,7 @@ describe('redirect', () => { }) const rootRoute = createRootRoute({ errorComponent: ({ error }) => ( -
Root: {error.message}
+
Root: {getErrorMessage(error)}
), }) const indexRoute = createRoute({ @@ -176,7 +180,7 @@ describe('redirect', () => { path: '/', loader: indexLoader, errorComponent: ({ error }) => ( -
Index: {error.message}
+
Index: {getErrorMessage(error)}
), }) const otherRoute = createRoute({ @@ -184,7 +188,7 @@ describe('redirect', () => { path: '/other', loader: otherLoader, errorComponent: ({ error }) => ( -
Other: {error.message}
+
Other: {getErrorMessage(error)}
), }) const router = createRouter({ diff --git a/packages/react-router/tests/router.test.tsx b/packages/react-router/tests/router.test.tsx index 43c4d474239..926e2c3b8f6 100644 --- a/packages/react-router/tests/router.test.tsx +++ b/packages/react-router/tests/router.test.tsx @@ -1857,7 +1857,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 }) => { @@ -1877,7 +1877,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 }) => { @@ -1892,7 +1892,9 @@ describe('search params in URL', () => { await act(() => router.load()) expect(errorSpy).toBeInstanceOf(SearchParamError) - expect(errorSpy?.cause).toBeInstanceOf(TestValidationError) + expect( + errorSpy instanceof Error ? errorSpy.cause : undefined, + ).toBeInstanceOf(TestValidationError) }) }) }) diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index f2543892677..b7e36880716 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1298,7 +1298,7 @@ export interface UpdatableRouteOptions< postSearchFilters?: Array< SearchFilter> > - onCatch?: (error: Error) => void + onCatch?: (error: unknown) => void onError?: (err: any) => void // These functions are called as route matches are loaded, stick around and leave the active // matches @@ -1601,7 +1601,7 @@ export type ErrorRouteProps = { reset: () => void } -export type ErrorComponentProps = { +export type ErrorComponentProps = { error: TError info?: { componentStack: string } reset: () => void diff --git a/packages/router-core/tests/errorComponentProps.test-d.ts b/packages/router-core/tests/errorComponentProps.test-d.ts new file mode 100644 index 00000000000..23df7cdcfa7 --- /dev/null +++ b/packages/router-core/tests/errorComponentProps.test-d.ts @@ -0,0 +1,7 @@ +import { expectTypeOf, test } from 'vitest' +import type { ErrorComponentProps } from '../src' + +test('ErrorComponentProps defaults error to unknown', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf['error']>().toEqualTypeOf() +}) diff --git a/packages/solid-router/src/CatchBoundary.tsx b/packages/solid-router/src/CatchBoundary.tsx index a69c4859238..c0bb6519314 100644 --- a/packages/solid-router/src/CatchBoundary.tsx +++ b/packages/solid-router/src/CatchBoundary.tsx @@ -8,7 +8,7 @@ export function CatchBoundary( getResetKey: () => unknown children: Solid.JSX.Element errorComponent?: ErrorRouteComponent - onCatch?: (error: Error) => void + onCatch?: (error: unknown) => void } & Solid.ParentProps, ) { return ( @@ -45,7 +45,7 @@ export function CatchBoundary( ) } -export function ErrorComponent({ error }: { error: any }) { +export function ErrorComponent({ error }: { error: unknown }) { const [show, setShow] = Solid.createSignal( process.env.NODE_ENV !== 'production', ) @@ -81,7 +81,9 @@ export function ErrorComponent({ error }: { error: any }) { overflow: 'auto', }} > - {error.message ? {error.message} : null} + {(error as { message?: string } | null)?.message ? ( + {(error as { message: string }).message} + ) : null} ) : null} diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index a6db669e11e..5e07eafa65d 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -117,7 +117,7 @@ export const Match = (props: { routeId: string }) => { component={routeErrorComponent() ? CatchBoundary : SafeFragment} getResetKey={currentMatch} errorComponent={routeErrorComponent() as any} - onCatch={(error: Error) => { + onCatch={(error: unknown) => { // Forward not found errors (we don't want to show the error component for these) const notFoundError = getNotFound(error) if (notFoundError) { diff --git a/packages/solid-router/src/Matches.tsx b/packages/solid-router/src/Matches.tsx index d2dd1a681ad..923f6bdda5c 100644 --- a/packages/solid-router/src/Matches.tsx +++ b/packages/solid-router/src/Matches.tsx @@ -103,7 +103,7 @@ function MatchesInner() { console.warn( `Warning: The following error wasn't caught by any route! At the very least, consider setting an 'errorComponent' in your RootRoute!`, ) - console.warn(`Warning: ${error.message || error.toString()}`) + console.warn('Warning:', error) } : undefined } diff --git a/packages/solid-router/src/router.ts b/packages/solid-router/src/router.ts index 3fce7c9d75c..3b477c2bf13 100644 --- a/packages/solid-router/src/router.ts +++ b/packages/solid-router/src/router.ts @@ -69,7 +69,7 @@ declare module '@tanstack/router-core' { * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultoncatch-property) * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#handling-errors-with-routeoptionsoncatch) */ - defaultOnCatch?: (error: Error) => void + defaultOnCatch?: (error: unknown) => void } } diff --git a/packages/solid-router/tests/createLazyRoute.test.tsx b/packages/solid-router/tests/createLazyRoute.test.tsx index 4dcbbbfc2c5..67c20a1cdc8 100644 --- a/packages/solid-router/tests/createLazyRoute.test.tsx +++ b/packages/solid-router/tests/createLazyRoute.test.tsx @@ -176,7 +176,9 @@ it('renders an eager loader error with a delayed lazy errorComponent', async () const lazyPageOptions = createLazyRoute('/page')({ component: () =>

Page

, errorComponent: ({ error }) => ( -

Lazy error: {error.message}

+

+ Lazy error: {error instanceof Error ? error.message : String(error)} +

), }) const lazyOptions = createControlledPromise() diff --git a/packages/solid-router/tests/errorComponent.test.tsx b/packages/solid-router/tests/errorComponent.test.tsx index af45fdcceeb..c1cb59db9d4 100644 --- a/packages/solid-router/tests/errorComponent.test.tsx +++ b/packages/solid-router/tests/errorComponent.test.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library' import { createControlledPromise } from '@tanstack/router-core' import { + CatchBoundary, Link, Outlet, RouterProvider, @@ -14,7 +15,11 @@ import { import type { ErrorComponentProps } from '../src' function MyErrorComponent(props: ErrorComponentProps) { - return
Error: {props.error.message}
+ return
Error: {getErrorMessage(props.error)}
+} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) } async function asyncToThrowFn() { @@ -213,7 +218,9 @@ test('ancestor route errorComponent resets when a background child generation re let loaderCalls = 0 const rootRoute = createRootRoute({ component: Outlet, - errorComponent: ({ error }) =>
Ancestor error: {error.message}
, + errorComponent: ({ error }) => ( +
Ancestor error: {getErrorMessage(error)}
+ ), }) const childRoute = createRoute({ getParentRoute: () => rootRoute, @@ -260,3 +267,44 @@ test('ancestor route errorComponent resets when a background child generation re await screen.findByText('Recovered child revision 2'), ).toBeInTheDocument() }) + +test.each([ + ['false', false], + ['zero', 0], + ['negative zero', -0], + ['bigint zero', 0n], + ['empty string', ''], + ['null', null], + ['undefined', undefined], + ['NaN', NaN], +] as const)('CatchBoundary renders falsy thrown value %s', (_, thrown) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + let caught: unknown + + function ThrowFalsy(): never { + throw thrown + } + + render(() => ( + 0} + errorComponent={({ error }) => ( +
+ {error instanceof Error && Object.is(error.cause, thrown) + ? 'Caught value' + : 'Wrong value'} +
+ )} + onCatch={(error) => { + caught = error + }} + > + +
+ )) + + expect(screen.getByText('Caught value')).toBeInTheDocument() + expect(screen.queryByText('Wrong value')).not.toBeInTheDocument() + expect(caught).toBeInstanceOf(Error) + expect(Object.is((caught as Error).cause, thrown)).toBe(true) +}) diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index b7310a5405c..80c25a9bb40 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -3699,7 +3699,9 @@ describe('Link', () => { test('when navigating from /invoices to ./invoiceId and the current route is /posts/$postId/details', async () => { const rootRoute = createRootRoute({ - errorComponent: (err) =>
{err.error.message}
, + errorComponent: ({ error }) => ( +
{error instanceof Error ? error.message : String(error)}
+ ), }) const indexRoute = createRoute({ @@ -5797,7 +5799,9 @@ describe('search middleware', () => { test('search middlewares work', async () => { const rootRoute = createRootRoute({ - errorComponent: (error) =>
{error.error.stack}
, + errorComponent: ({ error }) => ( +
{error instanceof Error ? error.stack : String(error)}
+ ), validateSearch: (input) => { return { root: input.root as string | undefined, diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx index ac0b2d145de..fa3ead12c77 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -1380,7 +1380,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 }) => { @@ -1400,7 +1400,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 }) => { @@ -1415,7 +1415,9 @@ describe('search params in URL', () => { await router.load() expect(errorSpy).toBeInstanceOf(SearchParamError) - expect(errorSpy?.cause).toBeInstanceOf(TestValidationError) + expect( + errorSpy instanceof Error ? errorSpy.cause : undefined, + ).toBeInstanceOf(TestValidationError) }) }) }) diff --git a/packages/solid-router/tests/server/errorComponent.test.tsx b/packages/solid-router/tests/server/errorComponent.test.tsx index 4ab6e796585..7441ba7de8b 100644 --- a/packages/solid-router/tests/server/errorComponent.test.tsx +++ b/packages/solid-router/tests/server/errorComponent.test.tsx @@ -18,7 +18,9 @@ describe('errorComponent (server)', () => { }, component: () =>
Index route
, errorComponent: ({ error }) => ( -
Route error: {error.message}
+
+ Route error: {error instanceof Error ? error.message : String(error)} +
), }) diff --git a/packages/vue-router/src/CatchBoundary.tsx b/packages/vue-router/src/CatchBoundary.tsx index d170e6fb1a1..2b4f66e78f4 100644 --- a/packages/vue-router/src/CatchBoundary.tsx +++ b/packages/vue-router/src/CatchBoundary.tsx @@ -6,7 +6,7 @@ type CatchBoundaryProps = { getResetKey: () => unknown children: Vue.VNode errorComponent?: ErrorRouteComponent | Vue.Component - onCatch?: (error: Error) => void + onCatch?: (error: unknown) => void } const VueErrorBoundary = Vue.defineComponent({ @@ -18,22 +18,22 @@ const VueErrorBoundary = Vue.defineComponent({ errorComponent: null, }, setup(props) { - const error = Vue.ref(null) + const error = Vue.shallowRef<[unknown] | 0>(0) const reset = () => { - error.value = null + error.value = 0 } Vue.watch( () => props.resetKey, - (newKey, oldKey) => { - if (newKey !== oldKey && error.value) { + () => { + if (error.value) { reset() } }, ) - Vue.onErrorCaptured((err: Error) => { + Vue.onErrorCaptured((err: unknown) => { if ( err instanceof Promise || (err && typeof (err as any).then === 'function') @@ -41,7 +41,7 @@ const VueErrorBoundary = Vue.defineComponent({ return false } - error.value = err + error.value = [err] if (props.onError) { props.onError(err) @@ -57,7 +57,7 @@ const VueErrorBoundary = Vue.defineComponent({ const errorComponent = props.errorComponent ?? ErrorComponent const errorProps = { - error: error.value, + error: error.value[0], reset, } @@ -86,7 +86,7 @@ CatchBoundary.props = ['getResetKey', 'children', 'errorComponent', 'onCatch'] export const ErrorComponent = Vue.defineComponent({ name: 'ErrorComponent', props: { - error: Object, + error: null as unknown as Vue.PropType, reset: Function, }, setup(props) { @@ -140,8 +140,12 @@ export const ErrorComponent = Vue.defineComponent({ }, }, [ - props.error?.message - ? Vue.h('code', {}, props.error.message) + (props.error as { message?: string } | null)?.message + ? Vue.h( + 'code', + {}, + (props.error as { message: string }).message, + ) : null, ], ), diff --git a/packages/vue-router/src/Match.tsx b/packages/vue-router/src/Match.tsx index 0a643447f02..56530bb38e1 100644 --- a/packages/vue-router/src/Match.tsx +++ b/packages/vue-router/src/Match.tsx @@ -108,7 +108,7 @@ export const Match = Vue.defineComponent({ content = CatchBoundary({ getResetKey: () => activeMatch.value, errorComponent: routeErrorComponent, - onCatch: (error: Error) => { + onCatch: (error: unknown) => { // Forward not found errors (we don't want to show the error component for these) if (isNotFound(error)) { error.routeId ??= routeId diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx index ae3b4f72eec..7edd5c7593e 100644 --- a/packages/vue-router/src/Matches.tsx +++ b/packages/vue-router/src/Matches.tsx @@ -76,7 +76,12 @@ const errorComponentFn: ErrorRouteComponentType = ( ) => { return Vue.h('div', { class: 'error' }, [ Vue.h('h1', null, 'Error'), - Vue.h('p', null, props.error.message || String(props.error)), + Vue.h( + 'p', + null, + (props.error as { message?: string } | null)?.message || + String(props.error), + ), Vue.h('button', { onClick: props.reset }, 'Try Again'), ]) } @@ -105,11 +110,11 @@ const MatchesInner = Vue.defineComponent({ errorComponent: errorComponentFn, onCatch: process.env.NODE_ENV !== 'production' - ? (error: Error) => { + ? (error: unknown) => { console.warn( `Warning: The following error wasn't caught by any route! At the very least, consider setting an 'errorComponent' in your RootRoute!`, ) - console.warn(`Warning: ${error.message || error.toString()}`) + console.warn('Warning:', error) } : undefined, children: childElement, diff --git a/packages/vue-router/src/not-found.tsx b/packages/vue-router/src/not-found.tsx index f0ebf678ea4..05d9ab4c883 100644 --- a/packages/vue-router/src/not-found.tsx +++ b/packages/vue-router/src/not-found.tsx @@ -7,7 +7,7 @@ import type { ErrorComponentProps, NotFoundError } from '@tanstack/router-core' export function CatchNotFound(props: { fallback?: (error: NotFoundError) => Vue.VNode - onCatch?: (error: Error) => void + onCatch?: (error: NotFoundError) => void children: Vue.VNode }) { const router = useRouter() @@ -37,7 +37,7 @@ export function CatchNotFound(props: { return Vue.h(CatchBoundary, { getResetKey: () => `not-found-${pathname.value}-${status.value}`, - onCatch: (error: Error) => { + onCatch: (error: unknown) => { if (isNotFound(error)) { if (props.onCatch) { props.onCatch(error) diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index 00b55c4060a..27e7fbb891b 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -69,7 +69,7 @@ declare module '@tanstack/router-core' { * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#defaultoncatch-property) * @link [Guide](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#handling-errors-with-routeoptionsoncatch) */ - defaultOnCatch?: (error: Error) => void + defaultOnCatch?: (error: unknown) => void } } diff --git a/packages/vue-router/tests/Outlet.test.tsx b/packages/vue-router/tests/Outlet.test.tsx index f8d0f699a2d..fe140d02b9b 100644 --- a/packages/vue-router/tests/Outlet.test.tsx +++ b/packages/vue-router/tests/Outlet.test.tsx @@ -114,7 +114,7 @@ test('warns when Outlet is rendered inside an errorComponent', async () => { test('warns with the current component after a fallback transition', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const pending = createControlledPromise() - const FallbackComponent = (props: { error?: Error }) => ( + const FallbackComponent = (props: { error?: unknown }) => ( <> {props.error ? 'Error route' : 'Pending route'} diff --git a/packages/vue-router/tests/errorComponent.test.tsx b/packages/vue-router/tests/errorComponent.test.tsx index 92fab168e67..6d88d19778f 100644 --- a/packages/vue-router/tests/errorComponent.test.tsx +++ b/packages/vue-router/tests/errorComponent.test.tsx @@ -14,7 +14,11 @@ import { import type { ErrorComponentProps } from '../src' function MyErrorComponent(props: ErrorComponentProps) { - return
Error: {props.error.message}
+ return
Error: {getErrorMessage(props.error)}
+} + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) } async function asyncToThrowFn() { @@ -213,7 +217,9 @@ test('ancestor route errorComponent resets when a background child generation re let loaderCalls = 0 const rootRoute = createRootRoute({ component: Outlet, - errorComponent: ({ error }) =>
Ancestor error: {error.message}
, + errorComponent: ({ error }) => ( +
Ancestor error: {getErrorMessage(error)}
+ ), }) const childRoute = createRoute({ getParentRoute: () => rootRoute, @@ -263,3 +269,41 @@ test('ancestor route errorComponent resets when a background child generation re } } }) + +test.each([ + ['false', false], + ['zero', 0], + ['negative zero', -0], + ['bigint zero', 0n], + ['empty string', ''], + ['null', null], + ['undefined', undefined], + ['NaN', NaN], +] as const)( + 'CatchBoundary renders falsy thrown value %s', + async (_, thrown) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + let caught: unknown + + function ThrowFalsy(): never { + throw thrown + } + + const rootRoute = createRootRoute({ + component: ThrowFalsy, + errorComponent: ({ error }) => ( +
{Object.is(error, thrown) ? 'Caught value' : 'Wrong value'}
+ ), + onCatch: (error) => { + caught = error + }, + }) + const router = createRouter({ routeTree: rootRoute }) + + render() + + expect(await screen.findByText('Caught value')).toBeInTheDocument() + expect(screen.queryByText('Wrong value')).not.toBeInTheDocument() + expect(Object.is(caught, thrown)).toBe(true) + }, +) diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index 76247ccd35c..d456285d83d 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -3956,7 +3956,9 @@ describe('Link', () => { test('when navigating from /invoices to ./invoiceId and the current route is /posts/$postId/details', async () => { const rootRoute = createRootRoute({ - errorComponent: (err) =>
{err.error.message}
, + errorComponent: ({ error }) => ( +
{error instanceof Error ? error.message : String(error)}
+ ), }) const indexRoute = createRoute({ @@ -6248,7 +6250,9 @@ describe('search middleware', () => { test('search middlewares work', async () => { const rootRoute = createRootRoute({ - errorComponent: (error) =>
{error.error.stack}
, + errorComponent: ({ error }) => ( +
{error instanceof Error ? error.stack : String(error)}
+ ), validateSearch: (input) => { return { root: input.root as string | undefined, diff --git a/packages/vue-router/tests/router.test.tsx b/packages/vue-router/tests/router.test.tsx index 543a32548ec..3a0d17d66d9 100644 --- a/packages/vue-router/tests/router.test.tsx +++ b/packages/vue-router/tests/router.test.tsx @@ -1382,7 +1382,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 }) => { @@ -1402,7 +1402,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 }) => { @@ -1417,7 +1417,9 @@ describe('search params in URL', () => { await router.load() expect(errorSpy).toBeInstanceOf(SearchParamError) - expect(errorSpy?.cause).toBeInstanceOf(TestValidationError) + expect( + errorSpy instanceof Error ? errorSpy.cause : undefined, + ).toBeInstanceOf(TestValidationError) }) }) }) From 2f376ec52504048f4bcef1df5ba5becb67ae87ca Mon Sep 17 00:00:00 2001 From: Flo Date: Sat, 5 Sep 2026 10:28:53 +0200 Subject: [PATCH 2/5] fix(router): complete unknown boundary error migration --- .changeset/calm-errors-listen.md | 4 +- .../control-flow/react/src/routes/broken.tsx | 6 +- .../control-flow/solid/src/routes/broken.tsx | 6 +- docs/router/api/router/RouteOptionsType.md | 2 +- .../api/router/catchBoundaryComponent.md | 2 +- .../api/router/errorComponentComponent.md | 6 +- docs/router/guide/data-loading.md | 4 + docs/router/guide/external-data-loading.md | 2 +- docs/router/how-to/setup-testing.md | 16 +++- .../how-to/use-environment-variables.md | 2 +- docs/router/how-to/validate-search-params.md | 4 +- .../basic-auth/src/routes/_authed.tsx | 2 +- .../clerk-basic/src/routes/_authed.tsx | 2 +- e2e/react-start/rsc/src/routes/rsc-error.tsx | 4 +- .../src/routes/deferred-rejection.tsx | 4 +- .../basic-auth/src/routes/_authed.tsx | 2 +- e2e/solid-start/basic/src/routes/__root.tsx | 6 +- .../custom-basepath/src/routes/__root.tsx | 6 +- .../scroll-restoration/src/routes/__root.tsx | 6 +- .../src/routes/index.tsx | 4 +- .../src/routes/ssr/stream.tsx | 4 +- .../server-functions/src/routes/__root.tsx | 6 +- .../server-routes/src/routes/__root.tsx | 6 +- e2e/solid-start/website/src/routes/__root.tsx | 6 +- .../basic-auth/src/routes/_authed.tsx | 2 +- e2e/vue-start/basic/src/routes/__root.tsx | 6 +- .../custom-basepath/src/routes/__root.tsx | 6 +- .../scroll-restoration/src/routes/__root.tsx | 6 +- .../src/routes/index.tsx | 4 +- .../src/routes/ssr/stream.tsx | 4 +- .../server-functions/src/routes/__root.tsx | 6 +- .../server-routes/src/routes/__root.tsx | 6 +- e2e/vue-start/website/src/routes/__root.tsx | 6 +- examples/react/start-bare/src/router.tsx | 4 +- .../start-basic-auth/src/routes/_authed.tsx | 2 +- .../start-clerk-basic/src/routes/_authed.tsx | 2 +- .../react/start-material-ui/src/router.tsx | 4 +- .../src/router.tsx | 4 +- .../src/routes/_authed.tsx | 2 +- .../start-basic-auth/src/routes/_authed.tsx | 2 +- .../start-convex-better-auth/src/router.tsx | 4 +- examples/solid/start-counter/src/router.tsx | 4 +- .../src/router.tsx | 4 +- .../src/routes/_authed.tsx | 2 +- .../skills/compositions/router-query/SKILL.md | 4 +- .../react-router/skills/react-router/SKILL.md | 4 +- packages/react-router/src/CatchBoundary.tsx | 13 ++- .../tests/errorBoundary.test-d.tsx | 30 +++++++ .../tests/errorComponent.test.tsx | 84 +++++++++++++++++++ .../router-core/not-found-and-errors/SKILL.md | 21 +++-- .../references/validation-patterns.md | 7 +- .../solid-router/skills/solid-router/SKILL.md | 4 +- packages/solid-router/src/CatchBoundary.tsx | 13 ++- .../tests/errorBoundary.test-d.tsx | 30 +++++++ .../tests/errorComponent.test.tsx | 40 +++++++++ packages/vue-router/src/CatchBoundary.tsx | 19 +++-- packages/vue-router/src/Matches.tsx | 9 +- packages/vue-router/tests/Outlet.test.tsx | 83 +++++++++--------- .../vue-router/tests/errorBoundary.test-d.tsx | 30 +++++++ .../vue-router/tests/errorComponent.test.tsx | 83 ++++++++++++++++++ 60 files changed, 543 insertions(+), 123 deletions(-) create mode 100644 packages/react-router/tests/errorBoundary.test-d.tsx create mode 100644 packages/solid-router/tests/errorBoundary.test-d.tsx create mode 100644 packages/vue-router/tests/errorBoundary.test-d.tsx diff --git a/.changeset/calm-errors-listen.md b/.changeset/calm-errors-listen.md index 8dac6103b75..15ea7bb0d91 100644 --- a/.changeset/calm-errors-listen.md +++ b/.changeset/calm-errors-listen.md @@ -5,4 +5,6 @@ '@tanstack/vue-router': patch --- -Handle arbitrary thrown values in router error boundaries and type caught errors as `unknown`. +Preserve falsy thrown values in React and Vue error boundaries. Type boundary error components and `onCatch` callbacks as `unknown` across React, Solid, and Vue. Solid continues to wrap non-`Error` throws in an `Error` with the original value in `cause`. + +When upgrading, narrow boundary errors (for example, with `error instanceof Error`) before reading `message` or `stack`. `ErrorComponentProps` remains available for values narrowed to a specific error type. Route `onError` types are unchanged. Default error components display non-`Error` values and keep rendering the error notice when a value cannot be formatted. diff --git a/benchmarks/client-nav/scenarios/control-flow/react/src/routes/broken.tsx b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/broken.tsx index 2dc4a264861..b11bb845bcc 100644 --- a/benchmarks/client-nav/scenarios/control-flow/react/src/routes/broken.tsx +++ b/benchmarks/client-nav/scenarios/control-flow/react/src/routes/broken.tsx @@ -12,5 +12,9 @@ export const Route = createFileRoute('/broken')({ }) function BrokenError(props: ErrorComponentProps) { - return
{props.error.message}
+ return ( +
+ {props.error instanceof Error ? props.error.message : String(props.error)} +
+ ) } diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/broken.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/broken.tsx index 9931ef9c583..971231a11fe 100644 --- a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/broken.tsx +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/broken.tsx @@ -12,5 +12,9 @@ export const Route = createFileRoute('/broken')({ }) function BrokenError(props: ErrorComponentProps) { - return
{props.error.message}
+ return ( +
+ {props.error instanceof Error ? props.error.message : String(props.error)} +
+ ) } diff --git a/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index ac35cdc8452..5472401c341 100644 --- a/docs/router/api/router/RouteOptionsType.md +++ b/docs/router/api/router/RouteOptionsType.md @@ -295,7 +295,7 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record ### `onCatch` property -- Type: `(error: Error, errorInfo: ErrorInfo) => void` +- Type: `(error: unknown) => void` - Optional - Defaults to `routerOptions.defaultOnCatch` - A function that will be called when errors are caught when the route encounters an error. diff --git a/docs/router/api/router/catchBoundaryComponent.md b/docs/router/api/router/catchBoundaryComponent.md index deef0ffa22b..5897c616f0f 100644 --- a/docs/router/api/router/catchBoundaryComponent.md +++ b/docs/router/api/router/catchBoundaryComponent.md @@ -29,7 +29,7 @@ The `CatchBoundary` component accepts the following props: ### `props.onCatch` prop -- Type: `(error: any) => void` +- Type: `(error: unknown, errorInfo: ErrorInfo) => void` - Optional - A callback that will be called with the error that was thrown by the component's children. diff --git a/docs/router/api/router/errorComponentComponent.md b/docs/router/api/router/errorComponentComponent.md index 04399f124da..a72e6515541 100644 --- a/docs/router/api/router/errorComponentComponent.md +++ b/docs/router/api/router/errorComponentComponent.md @@ -3,7 +3,7 @@ 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 @@ -11,7 +11,7 @@ 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 ### `props.info` prop @@ -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 the thrown value's `message`, or its string representation when no message exists. If reading or formatting the value throws, the error notice still renders without details. - The error message can be toggled by clicking the "Show Error" button. - By default, the error message will be shown in development. diff --git a/docs/router/guide/data-loading.md b/docs/router/guide/data-loading.md index 1a67de33696..4d13c420fc0 100644 --- a/docs/router/guide/data-loading.md +++ b/docs/router/guide/data-loading.md @@ -584,6 +584,10 @@ The `routeOptions.errorComponent` option is a component that is rendered when an - `error` - The unknown value that was thrown - `reset` - A function to reset the internal `CatchBoundary` +`ErrorComponentProps` defaults its `error` property to `unknown`. Narrow the value before accessing properties such as `message`. `ErrorComponentProps` 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. + ```tsx // src/routes/posts.tsx export const Route = createFileRoute('/posts')({ diff --git a/docs/router/guide/external-data-loading.md b/docs/router/guide/external-data-loading.md index a636bdfd789..b9265facec6 100644 --- a/docs/router/guide/external-data-loading.md +++ b/docs/router/guide/external-data-loading.md @@ -118,7 +118,7 @@ export const Route = createFileRoute('/')({ return (
- {error.message} + {error instanceof Error ? error.message : String(error)} diff --git a/e2e/react-start/basic-auth/src/routes/_authed.tsx b/e2e/react-start/basic-auth/src/routes/_authed.tsx index c457cf5e57e..0d87cfbacbb 100644 --- a/e2e/react-start/basic-auth/src/routes/_authed.tsx +++ b/e2e/react-start/basic-auth/src/routes/_authed.tsx @@ -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 } diff --git a/e2e/react-start/clerk-basic/src/routes/_authed.tsx b/e2e/react-start/clerk-basic/src/routes/_authed.tsx index 226ae0829ff..440d0e3f3c1 100644 --- a/e2e/react-start/clerk-basic/src/routes/_authed.tsx +++ b/e2e/react-start/clerk-basic/src/routes/_authed.tsx @@ -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 (
diff --git a/e2e/react-start/rsc/src/routes/rsc-error.tsx b/e2e/react-start/rsc/src/routes/rsc-error.tsx index fd028414641..1d621076bbf 100644 --- a/e2e/react-start/rsc/src/routes/rsc-error.tsx +++ b/e2e/react-start/rsc/src/routes/rsc-error.tsx @@ -81,7 +81,7 @@ export const Route = createFileRoute('/rsc-error')({ component: RscErrorComponent, }) -function RouteErrorComponent({ error }: { error: Error }) { +function RouteErrorComponent({ error }: { error: unknown }) { return (

@@ -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)}

diff --git a/e2e/react-start/streaming-ssr/src/routes/deferred-rejection.tsx b/e2e/react-start/streaming-ssr/src/routes/deferred-rejection.tsx index cba1343a441..d9b53054800 100644 --- a/e2e/react-start/streaming-ssr/src/routes/deferred-rejection.tsx +++ b/e2e/react-start/streaming-ssr/src/routes/deferred-rejection.tsx @@ -14,7 +14,9 @@ export const Route = createFileRoute('/deferred-rejection')({ } }, errorComponent: ({ error }) => ( -
{error.message}
+
+ {error instanceof Error ? error.message : String(error)} +
), component: DeferredRejection, }) diff --git a/e2e/solid-start/basic-auth/src/routes/_authed.tsx b/e2e/solid-start/basic-auth/src/routes/_authed.tsx index a42aac212ab..e54e6de3aa1 100644 --- a/e2e/solid-start/basic-auth/src/routes/_authed.tsx +++ b/e2e/solid-start/basic-auth/src/routes/_authed.tsx @@ -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 } diff --git a/e2e/solid-start/basic/src/routes/__root.tsx b/e2e/solid-start/basic/src/routes/__root.tsx index c517867dab2..28b45ac6719 100644 --- a/e2e/solid-start/basic/src/routes/__root.tsx +++ b/e2e/solid-start/basic/src/routes/__root.tsx @@ -61,7 +61,11 @@ export const Route = createRootRoute({ }, ], }), - errorComponent: (props) =>

{props.error.stack}

, + errorComponent: (props) => ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ), notFoundComponent: () => , component: RootComponent, }) diff --git a/e2e/solid-start/custom-basepath/src/routes/__root.tsx b/e2e/solid-start/custom-basepath/src/routes/__root.tsx index bd7fa92f5e2..edfbf22a745 100644 --- a/e2e/solid-start/custom-basepath/src/routes/__root.tsx +++ b/e2e/solid-start/custom-basepath/src/routes/__root.tsx @@ -48,7 +48,11 @@ export const Route = createRootRoute({ { rel: 'icon', href: '/favicon.ico' }, ], }), - errorComponent: (props) =>

{props.error.stack}

, + errorComponent: (props) => ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ), notFoundComponent: () => , component: RootComponent, }) diff --git a/e2e/solid-start/scroll-restoration/src/routes/__root.tsx b/e2e/solid-start/scroll-restoration/src/routes/__root.tsx index edd079c0a8e..2febc8e9721 100644 --- a/e2e/solid-start/scroll-restoration/src/routes/__root.tsx +++ b/e2e/solid-start/scroll-restoration/src/routes/__root.tsx @@ -52,7 +52,11 @@ export const Route = createRootRoute({ ], }), errorComponent: (props) => { - return

{props.error.stack}

+ return ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ) }, notFoundComponent: () => , component: RootComponent, diff --git a/e2e/solid-start/serialization-adapters/src/routes/index.tsx b/e2e/solid-start/serialization-adapters/src/routes/index.tsx index 7a7fc72ce0e..cf49d223b46 100644 --- a/e2e/solid-start/serialization-adapters/src/routes/index.tsx +++ b/e2e/solid-start/serialization-adapters/src/routes/index.tsx @@ -2,7 +2,9 @@ import { Link, createFileRoute } from '@tanstack/solid-router' export const Route = createFileRoute('/')({ component: Home, - errorComponent: (e) =>
{e.error.message}
, + errorComponent: (e) => ( +
{e.error instanceof Error ? e.error.message : String(e.error)}
+ ), }) function Home() { diff --git a/e2e/solid-start/serialization-adapters/src/routes/ssr/stream.tsx b/e2e/solid-start/serialization-adapters/src/routes/ssr/stream.tsx index 473818eef6e..80e9de9dc22 100644 --- a/e2e/solid-start/serialization-adapters/src/routes/ssr/stream.tsx +++ b/e2e/solid-start/serialization-adapters/src/routes/ssr/stream.tsx @@ -13,7 +13,9 @@ export const Route = createFileRoute('/ssr/stream')({ } }, - errorComponent: (e) =>
{e.error.message}
, + errorComponent: (e) => ( +
{e.error instanceof Error ? e.error.message : String(e.error)}
+ ), component: RouteComponent, }) diff --git a/e2e/solid-start/server-functions/src/routes/__root.tsx b/e2e/solid-start/server-functions/src/routes/__root.tsx index d12851644bb..0188b7c6427 100644 --- a/e2e/solid-start/server-functions/src/routes/__root.tsx +++ b/e2e/solid-start/server-functions/src/routes/__root.tsx @@ -24,7 +24,11 @@ export const Route = createRootRoute({ links: [{ rel: 'stylesheet', href: appCss }], }), errorComponent: (props) => { - return

{props.error.stack}

+ return ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ) }, notFoundComponent: () => , component: RootComponent, diff --git a/e2e/solid-start/server-routes/src/routes/__root.tsx b/e2e/solid-start/server-routes/src/routes/__root.tsx index d12851644bb..0188b7c6427 100644 --- a/e2e/solid-start/server-routes/src/routes/__root.tsx +++ b/e2e/solid-start/server-routes/src/routes/__root.tsx @@ -24,7 +24,11 @@ export const Route = createRootRoute({ links: [{ rel: 'stylesheet', href: appCss }], }), errorComponent: (props) => { - return

{props.error.stack}

+ return ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ) }, notFoundComponent: () => , component: RootComponent, diff --git a/e2e/solid-start/website/src/routes/__root.tsx b/e2e/solid-start/website/src/routes/__root.tsx index d91fca5749d..6a02504229a 100644 --- a/e2e/solid-start/website/src/routes/__root.tsx +++ b/e2e/solid-start/website/src/routes/__root.tsx @@ -50,7 +50,11 @@ export const Route = createRootRoute({ ], }), errorComponent: (props) => { - return

{props.error.stack}

+ return ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ) }, notFoundComponent: () => , component: RootComponent, diff --git a/e2e/vue-start/basic-auth/src/routes/_authed.tsx b/e2e/vue-start/basic-auth/src/routes/_authed.tsx index 153efc39eb8..ba88e3beaca 100644 --- a/e2e/vue-start/basic-auth/src/routes/_authed.tsx +++ b/e2e/vue-start/basic-auth/src/routes/_authed.tsx @@ -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 } diff --git a/e2e/vue-start/basic/src/routes/__root.tsx b/e2e/vue-start/basic/src/routes/__root.tsx index 843ec05893d..4a4bd08afe0 100644 --- a/e2e/vue-start/basic/src/routes/__root.tsx +++ b/e2e/vue-start/basic/src/routes/__root.tsx @@ -62,7 +62,11 @@ export const Route = createRootRoute({ }, ], }), - errorComponent: (props) =>

{props.error.stack}

, + errorComponent: (props) => ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ), notFoundComponent: () => , component: RootComponent, }) diff --git a/e2e/vue-start/custom-basepath/src/routes/__root.tsx b/e2e/vue-start/custom-basepath/src/routes/__root.tsx index 8bc947b03a5..c6d1eb197cf 100644 --- a/e2e/vue-start/custom-basepath/src/routes/__root.tsx +++ b/e2e/vue-start/custom-basepath/src/routes/__root.tsx @@ -49,7 +49,11 @@ export const Route = createRootRoute({ { rel: 'icon', href: '/favicon.ico' }, ], }), - errorComponent: (props) =>

{props.error.stack}

, + errorComponent: (props) => ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ), notFoundComponent: () => , component: RootComponent, }) diff --git a/e2e/vue-start/scroll-restoration/src/routes/__root.tsx b/e2e/vue-start/scroll-restoration/src/routes/__root.tsx index a28ab59f797..cd8a5583593 100644 --- a/e2e/vue-start/scroll-restoration/src/routes/__root.tsx +++ b/e2e/vue-start/scroll-restoration/src/routes/__root.tsx @@ -53,7 +53,11 @@ export const Route = createRootRoute({ ], }), errorComponent: (props) => { - return

{props.error.stack}

+ return ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ) }, notFoundComponent: () => , component: RootComponent, diff --git a/e2e/vue-start/serialization-adapters/src/routes/index.tsx b/e2e/vue-start/serialization-adapters/src/routes/index.tsx index 2d90e4b073f..d6bacefdc02 100644 --- a/e2e/vue-start/serialization-adapters/src/routes/index.tsx +++ b/e2e/vue-start/serialization-adapters/src/routes/index.tsx @@ -2,7 +2,9 @@ import { Link, createFileRoute } from '@tanstack/vue-router' export const Route = createFileRoute('/')({ component: Home, - errorComponent: (e) =>
{e.error.message}
, + errorComponent: (e) => ( +
{e.error instanceof Error ? e.error.message : String(e.error)}
+ ), }) function Home() { diff --git a/e2e/vue-start/serialization-adapters/src/routes/ssr/stream.tsx b/e2e/vue-start/serialization-adapters/src/routes/ssr/stream.tsx index 4fce3271f8e..ea1e770e5b2 100644 --- a/e2e/vue-start/serialization-adapters/src/routes/ssr/stream.tsx +++ b/e2e/vue-start/serialization-adapters/src/routes/ssr/stream.tsx @@ -13,7 +13,9 @@ export const Route = createFileRoute('/ssr/stream')({ } }, - errorComponent: (e) =>
{e.error.message}
, + errorComponent: (e) => ( +
{e.error instanceof Error ? e.error.message : String(e.error)}
+ ), component: RouteComponent, }) diff --git a/e2e/vue-start/server-functions/src/routes/__root.tsx b/e2e/vue-start/server-functions/src/routes/__root.tsx index 342f7a79b39..a52b12deae1 100644 --- a/e2e/vue-start/server-functions/src/routes/__root.tsx +++ b/e2e/vue-start/server-functions/src/routes/__root.tsx @@ -25,7 +25,11 @@ export const Route = createRootRoute({ links: [{ rel: 'stylesheet', href: appCss }], }), errorComponent: (props) => { - return

{props.error.stack}

+ return ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ) }, notFoundComponent: () => , component: RootComponent, diff --git a/e2e/vue-start/server-routes/src/routes/__root.tsx b/e2e/vue-start/server-routes/src/routes/__root.tsx index 999d80fff1f..aa4b1f31faf 100644 --- a/e2e/vue-start/server-routes/src/routes/__root.tsx +++ b/e2e/vue-start/server-routes/src/routes/__root.tsx @@ -25,7 +25,11 @@ export const Route = createRootRoute({ links: [{ rel: 'stylesheet', href: appCss }], }), errorComponent: (props) => { - return

{props.error.stack}

+ return ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ) }, notFoundComponent: () => , component: RootComponent, diff --git a/e2e/vue-start/website/src/routes/__root.tsx b/e2e/vue-start/website/src/routes/__root.tsx index 64228c166ee..744cac4b11b 100644 --- a/e2e/vue-start/website/src/routes/__root.tsx +++ b/e2e/vue-start/website/src/routes/__root.tsx @@ -51,7 +51,11 @@ export const Route = createRootRoute({ ], }), errorComponent: (props) => { - return

{props.error.stack}

+ return ( +

+ {props.error instanceof Error ? props.error.stack : String(props.error)} +

+ ) }, notFoundComponent: () => , component: RootComponent, diff --git a/examples/react/start-bare/src/router.tsx b/examples/react/start-bare/src/router.tsx index a464233af90..6f82cbf378b 100644 --- a/examples/react/start-bare/src/router.tsx +++ b/examples/react/start-bare/src/router.tsx @@ -5,7 +5,9 @@ export function getRouter() { const router = createRouter({ routeTree, defaultPreload: 'intent', - defaultErrorComponent: (err) =>

{err.error.stack}

, + defaultErrorComponent: (err) => ( +

{err.error instanceof Error ? err.error.stack : String(err.error)}

+ ), defaultNotFoundComponent: () =>

not found

, scrollRestoration: true, }) diff --git a/examples/react/start-basic-auth/src/routes/_authed.tsx b/examples/react/start-basic-auth/src/routes/_authed.tsx index 3944e590f9d..3af371e8443 100644 --- a/examples/react/start-basic-auth/src/routes/_authed.tsx +++ b/examples/react/start-basic-auth/src/routes/_authed.tsx @@ -49,7 +49,7 @@ export const Route = createFileRoute('/_authed')({ } }, errorComponent: ({ error }) => { - if (error.message === 'Not authenticated') { + if (error instanceof Error && error.message === 'Not authenticated') { return } diff --git a/examples/react/start-clerk-basic/src/routes/_authed.tsx b/examples/react/start-clerk-basic/src/routes/_authed.tsx index 1a97917c124..04f81968946 100644 --- a/examples/react/start-clerk-basic/src/routes/_authed.tsx +++ b/examples/react/start-clerk-basic/src/routes/_authed.tsx @@ -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 (
diff --git a/examples/react/start-material-ui/src/router.tsx b/examples/react/start-material-ui/src/router.tsx index a464233af90..6f82cbf378b 100644 --- a/examples/react/start-material-ui/src/router.tsx +++ b/examples/react/start-material-ui/src/router.tsx @@ -5,7 +5,9 @@ export function getRouter() { const router = createRouter({ routeTree, defaultPreload: 'intent', - defaultErrorComponent: (err) =>

{err.error.stack}

, + defaultErrorComponent: (err) => ( +

{err.error instanceof Error ? err.error.stack : String(err.error)}

+ ), defaultNotFoundComponent: () =>

not found

, scrollRestoration: true, }) diff --git a/examples/react/start-streaming-data-from-server-functions/src/router.tsx b/examples/react/start-streaming-data-from-server-functions/src/router.tsx index a464233af90..6f82cbf378b 100644 --- a/examples/react/start-streaming-data-from-server-functions/src/router.tsx +++ b/examples/react/start-streaming-data-from-server-functions/src/router.tsx @@ -5,7 +5,9 @@ export function getRouter() { const router = createRouter({ routeTree, defaultPreload: 'intent', - defaultErrorComponent: (err) =>

{err.error.stack}

, + defaultErrorComponent: (err) => ( +

{err.error instanceof Error ? err.error.stack : String(err.error)}

+ ), defaultNotFoundComponent: () =>

not found

, scrollRestoration: true, }) diff --git a/examples/react/start-supabase-basic/src/routes/_authed.tsx b/examples/react/start-supabase-basic/src/routes/_authed.tsx index ed97848e6aa..697e35e4274 100644 --- a/examples/react/start-supabase-basic/src/routes/_authed.tsx +++ b/examples/react/start-supabase-basic/src/routes/_authed.tsx @@ -27,7 +27,7 @@ export const Route = createFileRoute('/_authed')({ } }, errorComponent: ({ error }) => { - if (error.message === 'Not authenticated') { + if (error instanceof Error && error.message === 'Not authenticated') { return } diff --git a/examples/solid/start-basic-auth/src/routes/_authed.tsx b/examples/solid/start-basic-auth/src/routes/_authed.tsx index 60ad613e568..59bb376a2a3 100644 --- a/examples/solid/start-basic-auth/src/routes/_authed.tsx +++ b/examples/solid/start-basic-auth/src/routes/_authed.tsx @@ -49,7 +49,7 @@ export const Route = createFileRoute('/_authed')({ } }, errorComponent: ({ error }) => { - if (error.message === 'Not authenticated') { + if (error instanceof Error && error.message === 'Not authenticated') { return } diff --git a/examples/solid/start-convex-better-auth/src/router.tsx b/examples/solid/start-convex-better-auth/src/router.tsx index 84312aad0d3..a65b610e702 100644 --- a/examples/solid/start-convex-better-auth/src/router.tsx +++ b/examples/solid/start-convex-better-auth/src/router.tsx @@ -5,7 +5,9 @@ export function getRouter() { const router = createRouter({ routeTree, defaultPreload: 'intent', - defaultErrorComponent: (err) =>

{err.error.stack}

, + defaultErrorComponent: (err) => ( +

{err.error instanceof Error ? err.error.stack : String(err.error)}

+ ), defaultNotFoundComponent: () =>

not found

, scrollRestoration: true, }) diff --git a/examples/solid/start-counter/src/router.tsx b/examples/solid/start-counter/src/router.tsx index 84312aad0d3..a65b610e702 100644 --- a/examples/solid/start-counter/src/router.tsx +++ b/examples/solid/start-counter/src/router.tsx @@ -5,7 +5,9 @@ export function getRouter() { const router = createRouter({ routeTree, defaultPreload: 'intent', - defaultErrorComponent: (err) =>

{err.error.stack}

, + defaultErrorComponent: (err) => ( +

{err.error instanceof Error ? err.error.stack : String(err.error)}

+ ), defaultNotFoundComponent: () =>

not found

, scrollRestoration: true, }) diff --git a/examples/solid/start-streaming-data-from-server-functions/src/router.tsx b/examples/solid/start-streaming-data-from-server-functions/src/router.tsx index 16d91de054e..6b81f2b3be4 100644 --- a/examples/solid/start-streaming-data-from-server-functions/src/router.tsx +++ b/examples/solid/start-streaming-data-from-server-functions/src/router.tsx @@ -5,7 +5,9 @@ export function getRouter() { const router = createRouter({ routeTree, defaultPreload: 'intent', - defaultErrorComponent: (err) =>

{err.error.stack}

, + defaultErrorComponent: (err) => ( +

{err.error instanceof Error ? err.error.stack : String(err.error)}

+ ), defaultNotFoundComponent: () =>

not found

, scrollRestoration: true, }) diff --git a/examples/solid/start-supabase-basic/src/routes/_authed.tsx b/examples/solid/start-supabase-basic/src/routes/_authed.tsx index 72e7052f567..17292bfde48 100644 --- a/examples/solid/start-supabase-basic/src/routes/_authed.tsx +++ b/examples/solid/start-supabase-basic/src/routes/_authed.tsx @@ -27,7 +27,7 @@ export const Route = createFileRoute('/_authed')({ } }, errorComponent: ({ error }) => { - if (error.message === 'Not authenticated') { + if (error instanceof Error && error.message === 'Not authenticated') { return } throw error diff --git a/packages/react-router/skills/compositions/router-query/SKILL.md b/packages/react-router/skills/compositions/router-query/SKILL.md index bcb2f939cdd..9bda851b1c2 100644 --- a/packages/react-router/skills/compositions/router-query/SKILL.md +++ b/packages/react-router/skills/compositions/router-query/SKILL.md @@ -313,7 +313,7 @@ function PostsErrorComponent({ error, reset, }: { - error: Error + error: unknown reset: () => void }) { const router = useRouter() @@ -325,7 +325,7 @@ function PostsErrorComponent({ return (
-

{error.message}

+

{error instanceof Error ? error.message : String(error)}

) diff --git a/packages/react-router/skills/react-router/SKILL.md b/packages/react-router/skills/react-router/SKILL.md index 53f41353164..71142db4ba1 100644 --- a/packages/react-router/skills/react-router/SKILL.md +++ b/packages/react-router/skills/react-router/SKILL.md @@ -346,7 +346,9 @@ import { CatchBoundary } from '@tanstack/react-router' ; 'widget'} onCatch={(error) => console.error(error)} - errorComponent={({ error }) =>
Error: {error.message}
} + errorComponent={({ error }) => ( +
Error: {error instanceof Error ? error.message : String(error)}
+ )} >
diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx index 1015a9e116e..37c5a99b9c2 100644 --- a/packages/react-router/src/CatchBoundary.tsx +++ b/packages/react-router/src/CatchBoundary.tsx @@ -89,12 +89,19 @@ export function ErrorComponent({ error }: { error: unknown }) { overflow: 'auto', }} > - {(error as { message?: string } | null)?.message ? ( - {(error as { message: string }).message} - ) : null} + {getErrorMessage(error)}
) : null}
) } + +function getErrorMessage(error: unknown) { + try { + return String((error as { message?: unknown } | null)?.message ?? error) + } catch { + // The thrown value may not support property access or string conversion. + return '' + } +} diff --git a/packages/react-router/tests/errorBoundary.test-d.tsx b/packages/react-router/tests/errorBoundary.test-d.tsx new file mode 100644 index 00000000000..ccb66929dde --- /dev/null +++ b/packages/react-router/tests/errorBoundary.test-d.tsx @@ -0,0 +1,30 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRouter } from '../src' +import type { ErrorComponentProps } from '../src' + +test('only boundary errors default to unknown', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf['error']>().toEqualTypeOf() + const routeTree = createRootRoute({ + errorComponent: ({ error }) => { + expectTypeOf(error).toEqualTypeOf() + return null + }, + onCatch: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + onError: (error) => { + expectTypeOf(error).toBeAny() + }, + }) + createRouter({ + routeTree, + defaultErrorComponent: ({ error }) => { + expectTypeOf(error).toEqualTypeOf() + return null + }, + defaultOnCatch: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + }) +}) diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index 6ad5a4e4a26..ed514d696fc 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { CatchBoundary, + ErrorComponent, HeadContent, Link, Outlet, @@ -933,3 +934,86 @@ describe('notFoundComponent is rendered when an error is thrown in params.parse' expect(notFoundComponent).toBeInTheDocument() }) }) + +test.each([ + [new Error('Error message'), 'Error message'], + [{ message: 'Serialized message' }, 'Serialized message'], + [{ message: 0 }, '0'], + [{ message: { detail: 'nested' } }, '[object Object]'], + ['Thrown string', 'Thrown string'], + [false, 'false'], + [0, '0'], + [0n, '0'], + ['', ''], + [null, 'null'], + [undefined, 'undefined'], + [NaN, 'NaN'], + [Symbol('error'), 'Symbol(error)'], +])('default error details render %s', (error, expected) => { + const { container } = render() + + expect(screen.getByText('Something went wrong!')).toBeInTheDocument() + expect(container.querySelector('code')?.textContent).toBe(expected) +}) + +test.each([false, 0, -0, 0n, '', null, undefined, NaN])( + 'CatchBoundary resets after throwing %s', + (thrown) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + let shouldThrow = true + let resetKey = 0 + const onCatch = vi.fn() + + function Child() { + if (shouldThrow) { + throw thrown + } + return
Recovered child
+ } + + function App() { + return ( + resetKey} + onCatch={onCatch} + errorComponent={({ reset }) => } + > + + + ) + } + + const { rerender } = render() + shouldThrow = false + rerender() + expect(screen.queryByText('Recovered child')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('Reset')) + expect(screen.getByText('Recovered child')).toBeInTheDocument() + + shouldThrow = true + rerender() + shouldThrow = false + resetKey++ + rerender() + expect(screen.getByText('Recovered child')).toBeInTheDocument() + expect(onCatch).toHaveBeenCalledTimes(2) + }, +) + +test.each([ + Object.create(null), + { + toString() { + throw new Error('Cannot stringify') + }, + }, + { + get message() { + throw new Error('Cannot read message') + }, + }, +])('default error UI survives an unprintable thrown value', (error) => { + const { container } = render() + expect(screen.getByText('Something went wrong!')).toBeInTheDocument() + expect(container.querySelector('code')?.textContent).toBe('') +}) diff --git a/packages/router-core/skills/router-core/not-found-and-errors/SKILL.md b/packages/router-core/skills/router-core/not-found-and-errors/SKILL.md index 2a69f168886..45b231f41e2 100644 --- a/packages/router-core/skills/router-core/not-found-and-errors/SKILL.md +++ b/packages/router-core/skills/router-core/not-found-and-errors/SKILL.md @@ -169,15 +169,15 @@ export const Route = createFileRoute('/posts/$postId')({ function PostErrorComponent({ error, }: { - error: Error - info: { componentStack: string } + error: unknown + info?: { componentStack: string } reset: () => void }) { const router = useRouter() return (
-

Error: {error.message}

+

Error: {error instanceof Error ? error.message : String(error)}

} // CORRECT — invalidate re-runs loaders and resets the error boundary -function ErrorFallback({ error }: { error: Error; reset: () => void }) { +function ErrorFallback({ error }: { error: unknown; reset: () => void }) { const router = useRouter() return (
) : null} ) } + +function getErrorMessage(error: unknown) { + try { + return String((error as { message?: unknown } | null)?.message ?? error) + } catch { + // The thrown value may not support property access or string conversion. + return '' + } +} diff --git a/packages/solid-router/tests/errorBoundary.test-d.tsx b/packages/solid-router/tests/errorBoundary.test-d.tsx new file mode 100644 index 00000000000..ccb66929dde --- /dev/null +++ b/packages/solid-router/tests/errorBoundary.test-d.tsx @@ -0,0 +1,30 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRouter } from '../src' +import type { ErrorComponentProps } from '../src' + +test('only boundary errors default to unknown', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf['error']>().toEqualTypeOf() + const routeTree = createRootRoute({ + errorComponent: ({ error }) => { + expectTypeOf(error).toEqualTypeOf() + return null + }, + onCatch: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + onError: (error) => { + expectTypeOf(error).toBeAny() + }, + }) + createRouter({ + routeTree, + defaultErrorComponent: ({ error }) => { + expectTypeOf(error).toEqualTypeOf() + return null + }, + defaultOnCatch: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + }) +}) diff --git a/packages/solid-router/tests/errorComponent.test.tsx b/packages/solid-router/tests/errorComponent.test.tsx index c1cb59db9d4..3a368e2a89e 100644 --- a/packages/solid-router/tests/errorComponent.test.tsx +++ b/packages/solid-router/tests/errorComponent.test.tsx @@ -4,6 +4,7 @@ import { createControlledPromise } from '@tanstack/router-core' import { CatchBoundary, + ErrorComponent, Link, Outlet, RouterProvider, @@ -308,3 +309,42 @@ test.each([ expect(caught).toBeInstanceOf(Error) expect(Object.is((caught as Error).cause, thrown)).toBe(true) }) + +test.each([ + [new Error('Error message'), 'Error message'], + [{ message: 'Serialized message' }, 'Serialized message'], + [{ message: 0 }, '0'], + [{ message: { detail: 'nested' } }, '[object Object]'], + ['Thrown string', 'Thrown string'], + [false, 'false'], + [0, '0'], + [0n, '0'], + ['', ''], + [null, 'null'], + [undefined, 'undefined'], + [NaN, 'NaN'], + [Symbol('error'), 'Symbol(error)'], +])('default error details render %s', (error, expected) => { + const { container } = render(() => ) + + expect(screen.getByText('Something went wrong!')).toBeInTheDocument() + expect(container.querySelector('code')?.textContent).toBe(expected) +}) + +test.each([ + Object.create(null), + { + toString() { + throw new Error('Cannot stringify') + }, + }, + { + get message() { + throw new Error('Cannot read message') + }, + }, +])('default error UI survives an unprintable thrown value', (error) => { + const { container } = render(() => ) + expect(screen.getByText('Something went wrong!')).toBeInTheDocument() + expect(container.querySelector('code')?.textContent).toBe('') +}) diff --git a/packages/vue-router/src/CatchBoundary.tsx b/packages/vue-router/src/CatchBoundary.tsx index 2b4f66e78f4..7a3965ef4db 100644 --- a/packages/vue-router/src/CatchBoundary.tsx +++ b/packages/vue-router/src/CatchBoundary.tsx @@ -139,18 +139,19 @@ export const ErrorComponent = Vue.defineComponent({ overflow: 'auto', }, }, - [ - (props.error as { message?: string } | null)?.message - ? Vue.h( - 'code', - {}, - (props.error as { message: string }).message, - ) - : null, - ], + [Vue.h('code', {}, getErrorMessage(props.error))], ), ]) : null, ]) }, }) + +export function getErrorMessage(error: unknown) { + try { + return String((error as { message?: unknown } | null)?.message ?? error) + } catch { + // The thrown value may not support property access or string conversion. + return '' + } +} diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx index 7edd5c7593e..1420b128fe2 100644 --- a/packages/vue-router/src/Matches.tsx +++ b/packages/vue-router/src/Matches.tsx @@ -1,7 +1,7 @@ import * as Vue from 'vue' import { isServer } from '@tanstack/router-core/isServer' import { useStore } from '@tanstack/vue-store' -import { CatchBoundary } from './CatchBoundary' +import { CatchBoundary, getErrorMessage } from './CatchBoundary' import { useRouter } from './useRouter' import { useTransitionerSetup } from './Transitioner' import { routeIdContext } from './matchContext' @@ -76,12 +76,7 @@ const errorComponentFn: ErrorRouteComponentType = ( ) => { return Vue.h('div', { class: 'error' }, [ Vue.h('h1', null, 'Error'), - Vue.h( - 'p', - null, - (props.error as { message?: string } | null)?.message || - String(props.error), - ), + Vue.h('p', null, getErrorMessage(props.error)), Vue.h('button', { onClick: props.reset }, 'Try Again'), ]) } diff --git a/packages/vue-router/tests/Outlet.test.tsx b/packages/vue-router/tests/Outlet.test.tsx index fe140d02b9b..6bf904ba982 100644 --- a/packages/vue-router/tests/Outlet.test.tsx +++ b/packages/vue-router/tests/Outlet.test.tsx @@ -111,46 +111,49 @@ test('warns when Outlet is rendered inside an errorComponent', async () => { expect(warn).toHaveBeenCalledWith(outletWarning('errorComponent')) }) -test('warns with the current component after a fallback transition', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const pending = createControlledPromise() - const FallbackComponent = (props: { error?: unknown }) => ( - <> - {props.error ? 'Error route' : 'Pending route'} - - - ) - const rootRoute = createRootRoute({ component: Outlet }) - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - component: () => Index route, - }) - const transitionRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/transition', - loader: () => pending, - pendingMs: 0, - pendingComponent: FallbackComponent, - errorComponent: FallbackComponent, - }) - const router = createRouter({ - routeTree: rootRoute.addChildren([indexRoute, transitionRoute]), - history: createMemoryHistory({ initialEntries: ['/'] }), - }) - - render() - await screen.findByText('Index route') - - const navigation = router.navigate({ to: '/transition' }) - expect(await screen.findByText('Pending route')).toBeInTheDocument() - pending.reject(new Error('Loader failed')) - await navigation - - expect(await screen.findByText('Error route')).toBeInTheDocument() - expect(warn).toHaveBeenCalledWith(outletWarning('pendingComponent')) - expect(warn).toHaveBeenCalledWith(outletWarning('errorComponent')) -}) +test.each([new Error('Loader failed'), false, 0, '', null, undefined])( + 'warns with the current component after a fallback transition with %s', + async (error) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const pending = createControlledPromise() + const FallbackComponent = (props: { error?: unknown }) => ( + <> + {'error' in props ? 'Error route' : 'Pending route'} + + + ) + const rootRoute = createRootRoute({ component: Outlet }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => Index route, + }) + const transitionRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/transition', + loader: () => pending, + pendingMs: 0, + pendingComponent: FallbackComponent, + errorComponent: FallbackComponent, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, transitionRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + + render() + await screen.findByText('Index route') + + const navigation = router.navigate({ to: '/transition' }) + expect(await screen.findByText('Pending route')).toBeInTheDocument() + pending.reject(error) + await navigation + + expect(await screen.findByText('Error route')).toBeInTheDocument() + expect(warn).toHaveBeenCalledWith(outletWarning('pendingComponent')) + expect(warn).toHaveBeenCalledWith(outletWarning('errorComponent')) + }, +) test('warns when Outlet is rendered inside a notFoundComponent', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/packages/vue-router/tests/errorBoundary.test-d.tsx b/packages/vue-router/tests/errorBoundary.test-d.tsx new file mode 100644 index 00000000000..36db897af3b --- /dev/null +++ b/packages/vue-router/tests/errorBoundary.test-d.tsx @@ -0,0 +1,30 @@ +import { expectTypeOf, test } from 'vitest' +import { createRootRoute, createRouter } from '../src' +import type { ErrorComponentProps } from '../src' + +test('only boundary errors default to unknown', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf['error']>().toEqualTypeOf() + const routeTree = createRootRoute({ + errorComponent: ({ error }) => { + expectTypeOf(error).toEqualTypeOf() + return
+ }, + onCatch: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + onError: (error) => { + expectTypeOf(error).toBeAny() + }, + }) + createRouter({ + routeTree, + defaultErrorComponent: ({ error }) => { + expectTypeOf(error).toEqualTypeOf() + return
+ }, + defaultOnCatch: (error) => { + expectTypeOf(error).toEqualTypeOf() + }, + }) +}) diff --git a/packages/vue-router/tests/errorComponent.test.tsx b/packages/vue-router/tests/errorComponent.test.tsx index 6d88d19778f..1fc7892d9c6 100644 --- a/packages/vue-router/tests/errorComponent.test.tsx +++ b/packages/vue-router/tests/errorComponent.test.tsx @@ -1,8 +1,11 @@ import { afterEach, describe, expect, test, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/vue' +import { defineComponent, ref } from 'vue' import { createControlledPromise } from '@tanstack/router-core' import { + CatchBoundary, + ErrorComponent, Link, Outlet, RouterProvider, @@ -307,3 +310,83 @@ test.each([ expect(Object.is(caught, thrown)).toBe(true) }, ) + +test.each([ + [new Error('Error message'), 'Error message'], + [{ message: 'Serialized message' }, 'Serialized message'], + [{ message: 0 }, '0'], + [{ message: { detail: 'nested' } }, '[object Object]'], + ['Thrown string', 'Thrown string'], + [false, 'false'], + [0, '0'], + [0n, '0'], + ['', ''], + [null, 'null'], + [undefined, 'undefined'], + [NaN, 'NaN'], + [Symbol('error'), 'Symbol(error)'], +])('default error details render %s', (error, expected) => { + const { container } = render() + + expect(screen.getByText('Something went wrong!')).toBeInTheDocument() + expect(container.querySelector('code')?.textContent).toBe(expected) +}) + +test.each([false, 0, -0, 0n, '', null, undefined, NaN, { message: 'object' }])( + 'CatchBoundary retains the original value and resets after throwing %s', + async (thrown) => { + const shouldThrow = ref(true) + const resetKey = ref(0) + const onCatch = vi.fn() + const Child = defineComponent(() => () => { + if (shouldThrow.value) { + throw thrown + } + return
Recovered child
+ }) + const App = defineComponent(() => () => ( + resetKey.value} + onCatch={onCatch} + errorComponent={({ error, reset }: ErrorComponentProps) => ( + + )} + children={} + /> + )) + + render() + await screen.findByText('Reset') + shouldThrow.value = false + await fireEvent.click(screen.getByText('Reset')) + expect(await screen.findByText('Recovered child')).toBeInTheDocument() + + shouldThrow.value = true + await screen.findByText('Reset') + shouldThrow.value = false + resetKey.value++ + expect(await screen.findByText('Recovered child')).toBeInTheDocument() + expect(onCatch).toHaveBeenCalledTimes(2) + expect(onCatch).toHaveBeenLastCalledWith(thrown) + }, +) + +test.each([ + Object.create(null), + { + toString() { + throw new Error('Cannot stringify') + }, + }, + { + get message() { + throw new Error('Cannot read message') + }, + }, +])('default error UI survives an unprintable thrown value', (error) => { + const { container } = render() + expect(screen.getByText('Something went wrong!')).toBeInTheDocument() + expect(container.querySelector('code')?.textContent).toBe('') +}) From b0441a3b4ab346c9f0c82bfbc9aa938dfb34ba79 Mon Sep 17 00:00:00 2001 From: Flo Date: Sat, 5 Sep 2026 10:31:18 +0200 Subject: [PATCH 3/5] docs(router): keep boundary examples within skill limits --- packages/react-router/skills/react-router/SKILL.md | 4 +--- packages/solid-router/skills/solid-router/SKILL.md | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/react-router/skills/react-router/SKILL.md b/packages/react-router/skills/react-router/SKILL.md index 71142db4ba1..ba628f70031 100644 --- a/packages/react-router/skills/react-router/SKILL.md +++ b/packages/react-router/skills/react-router/SKILL.md @@ -346,9 +346,7 @@ import { CatchBoundary } from '@tanstack/react-router' ; 'widget'} onCatch={(error) => console.error(error)} - errorComponent={({ error }) => ( -
Error: {error instanceof Error ? error.message : String(error)}
- )} + errorComponent={({ error }) =>
{String(error)}
} >
diff --git a/packages/solid-router/skills/solid-router/SKILL.md b/packages/solid-router/skills/solid-router/SKILL.md index 446444a0016..b80c0fd9eca 100644 --- a/packages/solid-router/skills/solid-router/SKILL.md +++ b/packages/solid-router/skills/solid-router/SKILL.md @@ -314,9 +314,7 @@ Error boundary wrapping `Solid.ErrorBoundary`: import { CatchBoundary } from '@tanstack/solid-router' ; 'widget'} - errorComponent={({ error }) => ( -
Error: {error instanceof Error ? error.message : String(error)}
- )} + errorComponent={({ error }) =>
{String(error)}
} >
From bd1068ad9b44bf2a536b86d5e2f196ffe24ffe59 Mon Sep 17 00:00:00 2001 From: Flo Date: Sat, 5 Sep 2026 11:51:49 +0200 Subject: [PATCH 4/5] fix(router): restore minimal boundary error display --- .changeset/calm-errors-listen.md | 2 +- .../api/router/errorComponentComponent.md | 2 +- packages/react-router/src/CatchBoundary.tsx | 13 +-- .../tests/errorComponent.test.tsx | 83 +++++++++++++------ packages/solid-router/src/CatchBoundary.tsx | 13 +-- .../tests/errorComponent.test.tsx | 40 +++------ packages/vue-router/src/CatchBoundary.tsx | 19 ++--- packages/vue-router/src/Matches.tsx | 9 +- .../vue-router/tests/errorComponent.test.tsx | 40 +++------ 9 files changed, 102 insertions(+), 119 deletions(-) diff --git a/.changeset/calm-errors-listen.md b/.changeset/calm-errors-listen.md index 15ea7bb0d91..2a1aefc91b8 100644 --- a/.changeset/calm-errors-listen.md +++ b/.changeset/calm-errors-listen.md @@ -7,4 +7,4 @@ Preserve falsy thrown values in React and Vue error boundaries. Type boundary error components and `onCatch` callbacks as `unknown` across React, Solid, and Vue. Solid continues to wrap non-`Error` throws in an `Error` with the original value in `cause`. -When upgrading, narrow boundary errors (for example, with `error instanceof Error`) before reading `message` or `stack`. `ErrorComponentProps` remains available for values narrowed to a specific error type. Route `onError` types are unchanged. Default error components display non-`Error` values and keep rendering the error notice when a value cannot be formatted. +When upgrading, narrow boundary errors (for example, with `error instanceof Error`) before reading `message` or `stack`. `ErrorComponentProps` remains available for values narrowed to a specific error type. Route `onError` types are unchanged. diff --git a/docs/router/api/router/errorComponentComponent.md b/docs/router/api/router/errorComponentComponent.md index a72e6515541..5d76ac1a041 100644 --- a/docs/router/api/router/errorComponentComponent.md +++ b/docs/router/api/router/errorComponentComponent.md @@ -27,6 +27,6 @@ The `ErrorComponent` component accepts the following props: ## ErrorComponent returns -- Displays the thrown value's `message`, or its string representation when no message exists. If reading or formatting the value throws, the error notice still renders without details. +- 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. diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx index 37c5a99b9c2..1015a9e116e 100644 --- a/packages/react-router/src/CatchBoundary.tsx +++ b/packages/react-router/src/CatchBoundary.tsx @@ -89,19 +89,12 @@ export function ErrorComponent({ error }: { error: unknown }) { overflow: 'auto', }} > - {getErrorMessage(error)} + {(error as { message?: string } | null)?.message ? ( + {(error as { message: string }).message} + ) : null}
) : null}
) } - -function getErrorMessage(error: unknown) { - try { - return String((error as { message?: unknown } | null)?.message ?? error) - } catch { - // The thrown value may not support property access or string conversion. - return '' - } -} diff --git a/packages/react-router/tests/errorComponent.test.tsx b/packages/react-router/tests/errorComponent.test.tsx index ed514d696fc..442a94bc103 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -938,17 +938,17 @@ describe('notFoundComponent is rendered when an error is thrown in params.parse' test.each([ [new Error('Error message'), 'Error message'], [{ message: 'Serialized message' }, 'Serialized message'], - [{ message: 0 }, '0'], - [{ message: { detail: 'nested' } }, '[object Object]'], - ['Thrown string', 'Thrown string'], - [false, 'false'], - [0, '0'], - [0n, '0'], - ['', ''], - [null, 'null'], - [undefined, 'undefined'], - [NaN, 'NaN'], - [Symbol('error'), 'Symbol(error)'], + [{ message: 0 }, undefined], + ['Thrown string', undefined], + [false, undefined], + [0, undefined], + [0n, undefined], + ['', undefined], + [null, undefined], + [undefined, undefined], + [NaN, undefined], + [Symbol('error'), undefined], + [Object.create(null), undefined], ])('default error details render %s', (error, expected) => { const { container } = render() @@ -1000,20 +1000,49 @@ test.each([false, 0, -0, 0n, '', null, undefined, NaN])( }, ) -test.each([ - Object.create(null), - { - toString() { - throw new Error('Cannot stringify') - }, - }, - { - get message() { - throw new Error('Cannot read message') - }, - }, -])('default error UI survives an unprintable thrown value', (error) => { - const { container } = render() - expect(screen.getByText('Something went wrong!')).toBeInTheDocument() - expect(container.querySelector('code')?.textContent).toBe('') +test('CatchBoundary tracks reset keys while healthy', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + let resetKey: unknown + let shouldThrow = false + + function Child() { + if (shouldThrow) { + throw null + } + return
Healthy child
+ } + + function App() { + return ( + resetKey} + errorComponent={() =>
Caught error
} + > + +
+ ) + } + + const { rerender } = render() + expect(screen.getByText('Healthy child')).toBeInTheDocument() + + resetKey = {} + rerender() + expect(screen.getByText('Healthy child')).toBeInTheDocument() + + shouldThrow = true + rerender() + expect(screen.getByText('Caught error')).toBeInTheDocument() + + shouldThrow = false + rerender() + expect(screen.getByText('Caught error')).toBeInTheDocument() + + resetKey = undefined + rerender() + expect(screen.getByText('Healthy child')).toBeInTheDocument() + + shouldThrow = true + rerender() + expect(screen.getByText('Caught error')).toBeInTheDocument() }) diff --git a/packages/solid-router/src/CatchBoundary.tsx b/packages/solid-router/src/CatchBoundary.tsx index d44bb3be6b3..c0bb6519314 100644 --- a/packages/solid-router/src/CatchBoundary.tsx +++ b/packages/solid-router/src/CatchBoundary.tsx @@ -81,19 +81,12 @@ export function ErrorComponent({ error }: { error: unknown }) { overflow: 'auto', }} > - {getErrorMessage(error)} + {(error as { message?: string } | null)?.message ? ( + {(error as { message: string }).message} + ) : null} ) : null} ) } - -function getErrorMessage(error: unknown) { - try { - return String((error as { message?: unknown } | null)?.message ?? error) - } catch { - // The thrown value may not support property access or string conversion. - return '' - } -} diff --git a/packages/solid-router/tests/errorComponent.test.tsx b/packages/solid-router/tests/errorComponent.test.tsx index 3a368e2a89e..d09add9b4e8 100644 --- a/packages/solid-router/tests/errorComponent.test.tsx +++ b/packages/solid-router/tests/errorComponent.test.tsx @@ -313,38 +313,20 @@ test.each([ test.each([ [new Error('Error message'), 'Error message'], [{ message: 'Serialized message' }, 'Serialized message'], - [{ message: 0 }, '0'], - [{ message: { detail: 'nested' } }, '[object Object]'], - ['Thrown string', 'Thrown string'], - [false, 'false'], - [0, '0'], - [0n, '0'], - ['', ''], - [null, 'null'], - [undefined, 'undefined'], - [NaN, 'NaN'], - [Symbol('error'), 'Symbol(error)'], + [{ message: 0 }, undefined], + ['Thrown string', undefined], + [false, undefined], + [0, undefined], + [0n, undefined], + ['', undefined], + [null, undefined], + [undefined, undefined], + [NaN, undefined], + [Symbol('error'), undefined], + [Object.create(null), undefined], ])('default error details render %s', (error, expected) => { const { container } = render(() => ) expect(screen.getByText('Something went wrong!')).toBeInTheDocument() expect(container.querySelector('code')?.textContent).toBe(expected) }) - -test.each([ - Object.create(null), - { - toString() { - throw new Error('Cannot stringify') - }, - }, - { - get message() { - throw new Error('Cannot read message') - }, - }, -])('default error UI survives an unprintable thrown value', (error) => { - const { container } = render(() => ) - expect(screen.getByText('Something went wrong!')).toBeInTheDocument() - expect(container.querySelector('code')?.textContent).toBe('') -}) diff --git a/packages/vue-router/src/CatchBoundary.tsx b/packages/vue-router/src/CatchBoundary.tsx index 7a3965ef4db..2b4f66e78f4 100644 --- a/packages/vue-router/src/CatchBoundary.tsx +++ b/packages/vue-router/src/CatchBoundary.tsx @@ -139,19 +139,18 @@ export const ErrorComponent = Vue.defineComponent({ overflow: 'auto', }, }, - [Vue.h('code', {}, getErrorMessage(props.error))], + [ + (props.error as { message?: string } | null)?.message + ? Vue.h( + 'code', + {}, + (props.error as { message: string }).message, + ) + : null, + ], ), ]) : null, ]) }, }) - -export function getErrorMessage(error: unknown) { - try { - return String((error as { message?: unknown } | null)?.message ?? error) - } catch { - // The thrown value may not support property access or string conversion. - return '' - } -} diff --git a/packages/vue-router/src/Matches.tsx b/packages/vue-router/src/Matches.tsx index 1420b128fe2..7edd5c7593e 100644 --- a/packages/vue-router/src/Matches.tsx +++ b/packages/vue-router/src/Matches.tsx @@ -1,7 +1,7 @@ import * as Vue from 'vue' import { isServer } from '@tanstack/router-core/isServer' import { useStore } from '@tanstack/vue-store' -import { CatchBoundary, getErrorMessage } from './CatchBoundary' +import { CatchBoundary } from './CatchBoundary' import { useRouter } from './useRouter' import { useTransitionerSetup } from './Transitioner' import { routeIdContext } from './matchContext' @@ -76,7 +76,12 @@ const errorComponentFn: ErrorRouteComponentType = ( ) => { return Vue.h('div', { class: 'error' }, [ Vue.h('h1', null, 'Error'), - Vue.h('p', null, getErrorMessage(props.error)), + Vue.h( + 'p', + null, + (props.error as { message?: string } | null)?.message || + String(props.error), + ), Vue.h('button', { onClick: props.reset }, 'Try Again'), ]) } diff --git a/packages/vue-router/tests/errorComponent.test.tsx b/packages/vue-router/tests/errorComponent.test.tsx index 1fc7892d9c6..9d25cc9c80f 100644 --- a/packages/vue-router/tests/errorComponent.test.tsx +++ b/packages/vue-router/tests/errorComponent.test.tsx @@ -314,17 +314,17 @@ test.each([ test.each([ [new Error('Error message'), 'Error message'], [{ message: 'Serialized message' }, 'Serialized message'], - [{ message: 0 }, '0'], - [{ message: { detail: 'nested' } }, '[object Object]'], - ['Thrown string', 'Thrown string'], - [false, 'false'], - [0, '0'], - [0n, '0'], - ['', ''], - [null, 'null'], - [undefined, 'undefined'], - [NaN, 'NaN'], - [Symbol('error'), 'Symbol(error)'], + [{ message: 0 }, undefined], + ['Thrown string', undefined], + [false, undefined], + [0, undefined], + [0n, undefined], + ['', undefined], + [null, undefined], + [undefined, undefined], + [NaN, undefined], + [Symbol('error'), undefined], + [Object.create(null), undefined], ])('default error details render %s', (error, expected) => { const { container } = render() @@ -372,21 +372,3 @@ test.each([false, 0, -0, 0n, '', null, undefined, NaN, { message: 'object' }])( expect(onCatch).toHaveBeenLastCalledWith(thrown) }, ) - -test.each([ - Object.create(null), - { - toString() { - throw new Error('Cannot stringify') - }, - }, - { - get message() { - throw new Error('Cannot read message') - }, - }, -])('default error UI survives an unprintable thrown value', (error) => { - const { container } = render() - expect(screen.getByText('Something went wrong!')).toBeInTheDocument() - expect(container.querySelector('code')?.textContent).toBe('') -}) From 510434bd6ce7f96a0184f5008c8553333dfbd2d6 Mon Sep 17 00:00:00 2001 From: Flo Date: Sat, 5 Sep 2026 13:11:44 +0200 Subject: [PATCH 5/5] fix(solid-router): unify boundary errors across SSR and client --- .changeset/calm-errors-listen.md | 4 +- .../control-flow/solid/src/routes/broken.tsx | 6 +- docs/router/api/router/RouteOptionsType.md | 2 +- .../api/router/catchBoundaryComponent.md | 2 +- .../api/router/errorComponentComponent.md | 4 +- docs/router/guide/data-loading.md | 6 +- .../basic-auth/src/routes/_authed.tsx | 2 +- e2e/solid-start/basic/src/routeTree.gen.ts | 21 ++++ e2e/solid-start/basic/src/routes/__root.tsx | 6 +- .../basic/src/routes/error-normalization.tsx | 30 +++++ .../basic/tests/error-normalization.spec.ts | 35 ++++++ e2e/solid-start/basic/vite.config.ts | 1 + .../custom-basepath/src/routes/__root.tsx | 6 +- .../scroll-restoration/src/routes/__root.tsx | 6 +- .../src/routes/index.tsx | 4 +- .../src/routes/ssr/stream.tsx | 4 +- .../server-functions/src/routes/__root.tsx | 6 +- .../server-routes/src/routes/__root.tsx | 6 +- e2e/solid-start/website/src/routes/__root.tsx | 6 +- .../start-basic-auth/src/routes/_authed.tsx | 2 +- .../start-convex-better-auth/src/router.tsx | 4 +- examples/solid/start-counter/src/router.tsx | 4 +- .../src/router.tsx | 4 +- .../src/routes/_authed.tsx | 2 +- packages/router-core/src/index.ts | 2 + packages/router-core/src/route.ts | 11 +- .../solid-router/skills/solid-router/SKILL.md | 2 +- packages/solid-router/src/CatchBoundary.tsx | 8 +- packages/solid-router/src/Match.tsx | 17 ++- packages/solid-router/src/Matches.tsx | 2 +- packages/solid-router/src/route.tsx | 4 + packages/solid-router/src/router.ts | 2 +- .../tests/createLazyRoute.test.tsx | 4 +- .../tests/errorBoundary.test-d.tsx | 28 +++-- .../tests/errorComponent.test.tsx | 14 +-- packages/solid-router/tests/link.test.tsx | 8 +- packages/solid-router/tests/router.test.tsx | 8 +- .../tests/server/errorComponent.test.tsx | 105 ++++++++++++------ 38 files changed, 245 insertions(+), 143 deletions(-) create mode 100644 e2e/solid-start/basic/src/routes/error-normalization.tsx create mode 100644 e2e/solid-start/basic/tests/error-normalization.spec.ts diff --git a/.changeset/calm-errors-listen.md b/.changeset/calm-errors-listen.md index 2a1aefc91b8..029f880dc2e 100644 --- a/.changeset/calm-errors-listen.md +++ b/.changeset/calm-errors-listen.md @@ -5,6 +5,6 @@ '@tanstack/vue-router': patch --- -Preserve falsy thrown values in React and Vue error boundaries. Type boundary error components and `onCatch` callbacks as `unknown` across React, Solid, and Vue. Solid continues to wrap non-`Error` throws in an `Error` with the original value in `cause`. +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, narrow boundary errors (for example, with `error instanceof Error`) before reading `message` or `stack`. `ErrorComponentProps` remains available for values narrowed to a specific error type. Route `onError` types are unchanged. +When upgrading React or Vue, narrow boundary errors (for example, with `error instanceof Error`) before reading `message` or `stack`. `ErrorComponentProps` remains available for values narrowed to a specific error type. Route `onError` types are unchanged. diff --git a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/broken.tsx b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/broken.tsx index 971231a11fe..9931ef9c583 100644 --- a/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/broken.tsx +++ b/benchmarks/client-nav/scenarios/control-flow/solid/src/routes/broken.tsx @@ -12,9 +12,5 @@ export const Route = createFileRoute('/broken')({ }) function BrokenError(props: ErrorComponentProps) { - return ( -
- {props.error instanceof Error ? props.error.message : String(props.error)} -
- ) + return
{props.error.message}
} diff --git a/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index 5472401c341..41d9e575c94 100644 --- a/docs/router/api/router/RouteOptionsType.md +++ b/docs/router/api/router/RouteOptionsType.md @@ -295,7 +295,7 @@ type loaderDeps = (opts: { search: TFullSearchSchema }) => Record ### `onCatch` property -- Type: `(error: unknown) => 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. diff --git a/docs/router/api/router/catchBoundaryComponent.md b/docs/router/api/router/catchBoundaryComponent.md index 5897c616f0f..aa2f6eb7ce1 100644 --- a/docs/router/api/router/catchBoundaryComponent.md +++ b/docs/router/api/router/catchBoundaryComponent.md @@ -29,7 +29,7 @@ The `CatchBoundary` component accepts the following props: ### `props.onCatch` prop -- Type: `(error: unknown, errorInfo: ErrorInfo) => 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. diff --git a/docs/router/api/router/errorComponentComponent.md b/docs/router/api/router/errorComponentComponent.md index 5d76ac1a041..07d7187580c 100644 --- a/docs/router/api/router/errorComponentComponent.md +++ b/docs/router/api/router/errorComponentComponent.md @@ -11,8 +11,8 @@ The `ErrorComponent` component accepts the following props: ### `props.error` prop -- Type: `unknown` -- 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 diff --git a/docs/router/guide/data-loading.md b/docs/router/guide/data-loading.md index 4d13c420fc0..6b9fd427779 100644 --- a/docs/router/guide/data-loading.md +++ b/docs/router/guide/data-loading.md @@ -581,12 +581,12 @@ 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 unknown value that was thrown +- `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`. Narrow the value before accessing properties such as `message`. `ErrorComponentProps` 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. +`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` 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. +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 diff --git a/e2e/solid-start/basic-auth/src/routes/_authed.tsx b/e2e/solid-start/basic-auth/src/routes/_authed.tsx index e54e6de3aa1..a42aac212ab 100644 --- a/e2e/solid-start/basic-auth/src/routes/_authed.tsx +++ b/e2e/solid-start/basic-auth/src/routes/_authed.tsx @@ -52,7 +52,7 @@ export const Route = createFileRoute('/_authed')({ } }, errorComponent: ({ error }) => { - if (error instanceof Error && error.message === 'Not authenticated') { + if (error.message === 'Not authenticated') { return } diff --git a/e2e/solid-start/basic/src/routeTree.gen.ts b/e2e/solid-start/basic/src/routeTree.gen.ts index dc8390f6b4d..07ba58b219d 100644 --- a/e2e/solid-start/basic/src/routeTree.gen.ts +++ b/e2e/solid-start/basic/src/routeTree.gen.ts @@ -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' @@ -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', @@ -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 @@ -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 @@ -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 @@ -572,6 +581,7 @@ export interface FileRouteTypes { | '/specialChars' | '/deferred' | '/deferred-without-suspense' + | '/error-normalization' | '/inline-scripts' | '/links' | '/posts' @@ -630,6 +640,7 @@ export interface FileRouteTypes { | '/specialChars' | '/deferred' | '/deferred-without-suspense' + | '/error-normalization' | '/inline-scripts' | '/links' | '/scripts' @@ -687,6 +698,7 @@ export interface FileRouteTypes { | '/_layout' | '/deferred' | '/deferred-without-suspense' + | '/error-normalization' | '/inline-scripts' | '/links' | '/posts' @@ -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 @@ -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' @@ -1398,6 +1418,7 @@ const rootRouteChildren: RootRouteChildren = { LayoutRoute: LayoutRouteWithChildren, DeferredRoute: DeferredRoute, DeferredWithoutSuspenseRoute: DeferredWithoutSuspenseRoute, + ErrorNormalizationRoute: ErrorNormalizationRoute, InlineScriptsRoute: InlineScriptsRoute, LinksRoute: LinksRoute, PostsRoute: PostsRouteWithChildren, diff --git a/e2e/solid-start/basic/src/routes/__root.tsx b/e2e/solid-start/basic/src/routes/__root.tsx index 28b45ac6719..c517867dab2 100644 --- a/e2e/solid-start/basic/src/routes/__root.tsx +++ b/e2e/solid-start/basic/src/routes/__root.tsx @@ -61,11 +61,7 @@ export const Route = createRootRoute({ }, ], }), - errorComponent: (props) => ( -

- {props.error instanceof Error ? props.error.stack : String(props.error)} -

- ), + errorComponent: (props) =>

{props.error.stack}

, notFoundComponent: () => , component: RootComponent, }) diff --git a/e2e/solid-start/basic/src/routes/error-normalization.tsx b/e2e/solid-start/basic/src/routes/error-normalization.tsx new file mode 100644 index 00000000000..12e9d2f205b --- /dev/null +++ b/e2e/solid-start/basic/src/routes/error-normalization.tsx @@ -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 ( +
+ + Throw null + +
+ ) + }, +}) diff --git a/e2e/solid-start/basic/tests/error-normalization.spec.ts b/e2e/solid-start/basic/tests/error-normalization.spec.ts new file mode 100644 index 00000000000..e5e7e18a29e --- /dev/null +++ b/e2e/solid-start/basic/tests/error-normalization.spec.ts @@ -0,0 +1,35 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { isSpaMode } from './utils/isSpaMode' +import { isPrerender } from './utils/isPrerender' + +test.skip(isSpaMode || isPrerender, 'Requires dynamic SSR') + +test.use({ + whitelistErrors: [ + 'Failed to load resource: the server responded with a status of 500', + ], +}) + +test('normalizes errors during SSR, hydration, and client navigation', async ({ + page, +}) => { + const response = await page.goto('/error-normalization?kind=string') + expect(response?.status()).toBe(500) + const html = await response!.text() + expect(html).toContain('data-name="Error"') + expect(html).toContain('data-message="loader failure"') + expect(html).toContain('data-cause="loader failure"') + + const details = page.getByTestId('error-details') + await expect(details).toHaveAttribute('data-hydrated', 'true') + await expect(details).toHaveAttribute('data-name', 'Error') + await expect(details).toHaveAttribute('data-message', 'loader failure') + await expect(details).toHaveAttribute('data-cause', 'loader failure') + + await page.getByRole('link', { name: 'Throw null' }).click() + await expect(details).toHaveAttribute('data-hydrated', 'true') + await expect(details).toHaveAttribute('data-name', 'Error') + await expect(details).toHaveAttribute('data-message', 'Unknown error') + await expect(details).toHaveAttribute('data-cause', 'null') +}) diff --git a/e2e/solid-start/basic/vite.config.ts b/e2e/solid-start/basic/vite.config.ts index 9e26483d040..faca5763c83 100644 --- a/e2e/solid-start/basic/vite.config.ts +++ b/e2e/solid-start/basic/vite.config.ts @@ -17,6 +17,7 @@ const prerenderConfiguration = { filter: (page: { path: string }) => ![ '/this-route-does-not-exist', + '/error-normalization', '/redirect', '/i-do-not-exist', '/not-found', diff --git a/e2e/solid-start/custom-basepath/src/routes/__root.tsx b/e2e/solid-start/custom-basepath/src/routes/__root.tsx index edfbf22a745..bd7fa92f5e2 100644 --- a/e2e/solid-start/custom-basepath/src/routes/__root.tsx +++ b/e2e/solid-start/custom-basepath/src/routes/__root.tsx @@ -48,11 +48,7 @@ export const Route = createRootRoute({ { rel: 'icon', href: '/favicon.ico' }, ], }), - errorComponent: (props) => ( -

- {props.error instanceof Error ? props.error.stack : String(props.error)} -

- ), + errorComponent: (props) =>

{props.error.stack}

, notFoundComponent: () => , component: RootComponent, }) diff --git a/e2e/solid-start/scroll-restoration/src/routes/__root.tsx b/e2e/solid-start/scroll-restoration/src/routes/__root.tsx index 2febc8e9721..edd079c0a8e 100644 --- a/e2e/solid-start/scroll-restoration/src/routes/__root.tsx +++ b/e2e/solid-start/scroll-restoration/src/routes/__root.tsx @@ -52,11 +52,7 @@ export const Route = createRootRoute({ ], }), errorComponent: (props) => { - return ( -

- {props.error instanceof Error ? props.error.stack : String(props.error)} -

- ) + return

{props.error.stack}

}, notFoundComponent: () => , component: RootComponent, diff --git a/e2e/solid-start/serialization-adapters/src/routes/index.tsx b/e2e/solid-start/serialization-adapters/src/routes/index.tsx index cf49d223b46..7a7fc72ce0e 100644 --- a/e2e/solid-start/serialization-adapters/src/routes/index.tsx +++ b/e2e/solid-start/serialization-adapters/src/routes/index.tsx @@ -2,9 +2,7 @@ import { Link, createFileRoute } from '@tanstack/solid-router' export const Route = createFileRoute('/')({ component: Home, - errorComponent: (e) => ( -
{e.error instanceof Error ? e.error.message : String(e.error)}
- ), + errorComponent: (e) =>
{e.error.message}
, }) function Home() { diff --git a/e2e/solid-start/serialization-adapters/src/routes/ssr/stream.tsx b/e2e/solid-start/serialization-adapters/src/routes/ssr/stream.tsx index 80e9de9dc22..473818eef6e 100644 --- a/e2e/solid-start/serialization-adapters/src/routes/ssr/stream.tsx +++ b/e2e/solid-start/serialization-adapters/src/routes/ssr/stream.tsx @@ -13,9 +13,7 @@ export const Route = createFileRoute('/ssr/stream')({ } }, - errorComponent: (e) => ( -
{e.error instanceof Error ? e.error.message : String(e.error)}
- ), + errorComponent: (e) =>
{e.error.message}
, component: RouteComponent, }) diff --git a/e2e/solid-start/server-functions/src/routes/__root.tsx b/e2e/solid-start/server-functions/src/routes/__root.tsx index 0188b7c6427..d12851644bb 100644 --- a/e2e/solid-start/server-functions/src/routes/__root.tsx +++ b/e2e/solid-start/server-functions/src/routes/__root.tsx @@ -24,11 +24,7 @@ export const Route = createRootRoute({ links: [{ rel: 'stylesheet', href: appCss }], }), errorComponent: (props) => { - return ( -

- {props.error instanceof Error ? props.error.stack : String(props.error)} -

- ) + return

{props.error.stack}

}, notFoundComponent: () => , component: RootComponent, diff --git a/e2e/solid-start/server-routes/src/routes/__root.tsx b/e2e/solid-start/server-routes/src/routes/__root.tsx index 0188b7c6427..d12851644bb 100644 --- a/e2e/solid-start/server-routes/src/routes/__root.tsx +++ b/e2e/solid-start/server-routes/src/routes/__root.tsx @@ -24,11 +24,7 @@ export const Route = createRootRoute({ links: [{ rel: 'stylesheet', href: appCss }], }), errorComponent: (props) => { - return ( -

- {props.error instanceof Error ? props.error.stack : String(props.error)} -

- ) + return

{props.error.stack}

}, notFoundComponent: () => , component: RootComponent, diff --git a/e2e/solid-start/website/src/routes/__root.tsx b/e2e/solid-start/website/src/routes/__root.tsx index 6a02504229a..d91fca5749d 100644 --- a/e2e/solid-start/website/src/routes/__root.tsx +++ b/e2e/solid-start/website/src/routes/__root.tsx @@ -50,11 +50,7 @@ export const Route = createRootRoute({ ], }), errorComponent: (props) => { - return ( -

- {props.error instanceof Error ? props.error.stack : String(props.error)} -

- ) + return

{props.error.stack}

}, notFoundComponent: () => , component: RootComponent, diff --git a/examples/solid/start-basic-auth/src/routes/_authed.tsx b/examples/solid/start-basic-auth/src/routes/_authed.tsx index 59bb376a2a3..60ad613e568 100644 --- a/examples/solid/start-basic-auth/src/routes/_authed.tsx +++ b/examples/solid/start-basic-auth/src/routes/_authed.tsx @@ -49,7 +49,7 @@ export const Route = createFileRoute('/_authed')({ } }, errorComponent: ({ error }) => { - if (error instanceof Error && error.message === 'Not authenticated') { + if (error.message === 'Not authenticated') { return } diff --git a/examples/solid/start-convex-better-auth/src/router.tsx b/examples/solid/start-convex-better-auth/src/router.tsx index a65b610e702..84312aad0d3 100644 --- a/examples/solid/start-convex-better-auth/src/router.tsx +++ b/examples/solid/start-convex-better-auth/src/router.tsx @@ -5,9 +5,7 @@ export function getRouter() { const router = createRouter({ routeTree, defaultPreload: 'intent', - defaultErrorComponent: (err) => ( -

{err.error instanceof Error ? err.error.stack : String(err.error)}

- ), + defaultErrorComponent: (err) =>

{err.error.stack}

, defaultNotFoundComponent: () =>

not found

, scrollRestoration: true, }) diff --git a/examples/solid/start-counter/src/router.tsx b/examples/solid/start-counter/src/router.tsx index a65b610e702..84312aad0d3 100644 --- a/examples/solid/start-counter/src/router.tsx +++ b/examples/solid/start-counter/src/router.tsx @@ -5,9 +5,7 @@ export function getRouter() { const router = createRouter({ routeTree, defaultPreload: 'intent', - defaultErrorComponent: (err) => ( -

{err.error instanceof Error ? err.error.stack : String(err.error)}

- ), + defaultErrorComponent: (err) =>

{err.error.stack}

, defaultNotFoundComponent: () =>

not found

, scrollRestoration: true, }) diff --git a/examples/solid/start-streaming-data-from-server-functions/src/router.tsx b/examples/solid/start-streaming-data-from-server-functions/src/router.tsx index 6b81f2b3be4..16d91de054e 100644 --- a/examples/solid/start-streaming-data-from-server-functions/src/router.tsx +++ b/examples/solid/start-streaming-data-from-server-functions/src/router.tsx @@ -5,9 +5,7 @@ export function getRouter() { const router = createRouter({ routeTree, defaultPreload: 'intent', - defaultErrorComponent: (err) => ( -

{err.error instanceof Error ? err.error.stack : String(err.error)}

- ), + defaultErrorComponent: (err) =>

{err.error.stack}

, defaultNotFoundComponent: () =>

not found

, scrollRestoration: true, }) diff --git a/examples/solid/start-supabase-basic/src/routes/_authed.tsx b/examples/solid/start-supabase-basic/src/routes/_authed.tsx index 17292bfde48..72e7052f567 100644 --- a/examples/solid/start-supabase-basic/src/routes/_authed.tsx +++ b/examples/solid/start-supabase-basic/src/routes/_authed.tsx @@ -27,7 +27,7 @@ export const Route = createFileRoute('/_authed')({ } }, errorComponent: ({ error }) => { - if (error instanceof Error && error.message === 'Not authenticated') { + if (error.message === 'Not authenticated') { return } throw error diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index a296629ffa3..617f7143483 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -163,6 +163,8 @@ export type { DefaultSearchValidator, ErrorRouteProps, ErrorComponentProps, + DefaultErrorBoundaryTypes, + ErrorBoundaryTypes, NotFoundRouteProps, ResolveParams, ParseParamsFn, diff --git a/packages/router-core/src/route.ts b/packages/router-core/src/route.ts index b7e36880716..70bfdc46cdc 100644 --- a/packages/router-core/src/route.ts +++ b/packages/router-core/src/route.ts @@ -1298,7 +1298,7 @@ export interface UpdatableRouteOptions< postSearchFilters?: Array< SearchFilter> > - onCatch?: (error: unknown) => void + onCatch?: (error: ErrorBoundaryTypes['error']) => void onError?: (err: any) => void // These functions are called as route matches are loaded, stick around and leave the active // matches @@ -1601,7 +1601,14 @@ export type ErrorRouteProps = { reset: () => void } -export type ErrorComponentProps = { +export interface DefaultErrorBoundaryTypes { + error: unknown +} + +/** Frameworks can specialize the error exposed by boundary components and callbacks. */ +export interface ErrorBoundaryTypes extends DefaultErrorBoundaryTypes {} + +export type ErrorComponentProps = { error: TError info?: { componentStack: string } reset: () => void diff --git a/packages/solid-router/skills/solid-router/SKILL.md b/packages/solid-router/skills/solid-router/SKILL.md index b80c0fd9eca..0cfd22ad730 100644 --- a/packages/solid-router/skills/solid-router/SKILL.md +++ b/packages/solid-router/skills/solid-router/SKILL.md @@ -314,7 +314,7 @@ Error boundary wrapping `Solid.ErrorBoundary`: import { CatchBoundary } from '@tanstack/solid-router' ; 'widget'} - errorComponent={({ error }) =>
{String(error)}
} + errorComponent={({ error }) =>
Error: {error.message}
} >
diff --git a/packages/solid-router/src/CatchBoundary.tsx b/packages/solid-router/src/CatchBoundary.tsx index c0bb6519314..998115c10dc 100644 --- a/packages/solid-router/src/CatchBoundary.tsx +++ b/packages/solid-router/src/CatchBoundary.tsx @@ -8,7 +8,7 @@ export function CatchBoundary( getResetKey: () => unknown children: Solid.JSX.Element errorComponent?: ErrorRouteComponent - onCatch?: (error: unknown) => void + onCatch?: (error: Error) => void } & Solid.ParentProps, ) { return ( @@ -45,7 +45,7 @@ export function CatchBoundary( ) } -export function ErrorComponent({ error }: { error: unknown }) { +export function ErrorComponent({ error }: { error: Error }) { const [show, setShow] = Solid.createSignal( process.env.NODE_ENV !== 'production', ) @@ -81,9 +81,7 @@ export function ErrorComponent({ error }: { error: unknown }) { overflow: 'auto', }} > - {(error as { message?: string } | null)?.message ? ( - {(error as { message: string }).message} - ) : null} + {error.message ? {error.message} : null} ) : null} diff --git a/packages/solid-router/src/Match.tsx b/packages/solid-router/src/Match.tsx index 5e07eafa65d..37c4020c03f 100644 --- a/packages/solid-router/src/Match.tsx +++ b/packages/solid-router/src/Match.tsx @@ -117,7 +117,7 @@ export const Match = (props: { routeId: string }) => { component={routeErrorComponent() ? CatchBoundary : SafeFragment} getResetKey={currentMatch} errorComponent={routeErrorComponent() as any} - onCatch={(error: unknown) => { + onCatch={(error: Error) => { // Forward not found errors (we don't want to show the error component for these) const notFoundError = getNotFound(error) if (notFoundError) { @@ -241,6 +241,17 @@ export const MatchInner = (): any => { {(_) => { if (isServer ?? router.isServer) { + // Match Solid.ErrorBoundary's normalization without changing router state. + const thrown = currentMatch().error + const error = + thrown instanceof Error + ? thrown + : new Error( + typeof thrown === 'string' ? thrown : 'Unknown error', + { + cause: thrown, + }, + ) const RouteErrorComponent = (route.options.errorComponent ?? router.options.defaultErrorComponent) || @@ -250,7 +261,7 @@ export const MatchInner = (): any => { renderInNonRouteComponentContext( () => ( { ) ) : ( void + defaultOnCatch?: (error: Error) => void } } diff --git a/packages/solid-router/tests/createLazyRoute.test.tsx b/packages/solid-router/tests/createLazyRoute.test.tsx index 67c20a1cdc8..4dcbbbfc2c5 100644 --- a/packages/solid-router/tests/createLazyRoute.test.tsx +++ b/packages/solid-router/tests/createLazyRoute.test.tsx @@ -176,9 +176,7 @@ it('renders an eager loader error with a delayed lazy errorComponent', async () const lazyPageOptions = createLazyRoute('/page')({ component: () =>

Page

, errorComponent: ({ error }) => ( -

- Lazy error: {error instanceof Error ? error.message : String(error)} -

+

Lazy error: {error.message}

), }) const lazyOptions = createControlledPromise() diff --git a/packages/solid-router/tests/errorBoundary.test-d.tsx b/packages/solid-router/tests/errorBoundary.test-d.tsx index ccb66929dde..026437d7579 100644 --- a/packages/solid-router/tests/errorBoundary.test-d.tsx +++ b/packages/solid-router/tests/errorBoundary.test-d.tsx @@ -1,17 +1,29 @@ import { expectTypeOf, test } from 'vitest' -import { createRootRoute, createRouter } from '../src' +import { CatchBoundary, createRootRoute, createRouter } from '../src' import type { ErrorComponentProps } from '../src' -test('only boundary errors default to unknown', () => { - expectTypeOf().toEqualTypeOf() - expectTypeOf['error']>().toEqualTypeOf() +test('Solid boundary errors default to Error', () => { + expectTypeOf().toEqualTypeOf() + expectTypeOf['error']>().toEqualTypeOf() + ; 0} + onCatch={(error) => { + expectTypeOf(error).toEqualTypeOf() + }} + errorComponent={({ error }) => { + expectTypeOf(error).toEqualTypeOf() + return null + }} + > +
+ const routeTree = createRootRoute({ errorComponent: ({ error }) => { - expectTypeOf(error).toEqualTypeOf() + expectTypeOf(error).toEqualTypeOf() return null }, onCatch: (error) => { - expectTypeOf(error).toEqualTypeOf() + expectTypeOf(error).toEqualTypeOf() }, onError: (error) => { expectTypeOf(error).toBeAny() @@ -20,11 +32,11 @@ test('only boundary errors default to unknown', () => { createRouter({ routeTree, defaultErrorComponent: ({ error }) => { - expectTypeOf(error).toEqualTypeOf() + expectTypeOf(error).toEqualTypeOf() return null }, defaultOnCatch: (error) => { - expectTypeOf(error).toEqualTypeOf() + expectTypeOf(error).toEqualTypeOf() }, }) }) diff --git a/packages/solid-router/tests/errorComponent.test.tsx b/packages/solid-router/tests/errorComponent.test.tsx index d09add9b4e8..af7898c1219 100644 --- a/packages/solid-router/tests/errorComponent.test.tsx +++ b/packages/solid-router/tests/errorComponent.test.tsx @@ -312,18 +312,8 @@ test.each([ test.each([ [new Error('Error message'), 'Error message'], - [{ message: 'Serialized message' }, 'Serialized message'], - [{ message: 0 }, undefined], - ['Thrown string', undefined], - [false, undefined], - [0, undefined], - [0n, undefined], - ['', undefined], - [null, undefined], - [undefined, undefined], - [NaN, undefined], - [Symbol('error'), undefined], - [Object.create(null), undefined], + [new Error('Wrapped error', { cause: false }), 'Wrapped error'], + [new Error(''), undefined], ])('default error details render %s', (error, expected) => { const { container } = render(() => ) diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 80c25a9bb40..b7310a5405c 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -3699,9 +3699,7 @@ describe('Link', () => { test('when navigating from /invoices to ./invoiceId and the current route is /posts/$postId/details', async () => { const rootRoute = createRootRoute({ - errorComponent: ({ error }) => ( -
{error instanceof Error ? error.message : String(error)}
- ), + errorComponent: (err) =>
{err.error.message}
, }) const indexRoute = createRoute({ @@ -5799,9 +5797,7 @@ describe('search middleware', () => { test('search middlewares work', async () => { const rootRoute = createRootRoute({ - errorComponent: ({ error }) => ( -
{error instanceof Error ? error.stack : String(error)}
- ), + errorComponent: (error) =>
{error.error.stack}
, validateSearch: (input) => { return { root: input.root as string | undefined, diff --git a/packages/solid-router/tests/router.test.tsx b/packages/solid-router/tests/router.test.tsx index fa3ead12c77..ac0b2d145de 100644 --- a/packages/solid-router/tests/router.test.tsx +++ b/packages/solid-router/tests/router.test.tsx @@ -1380,7 +1380,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: unknown + let errorSpy: Error | undefined const rootRoute = createRootRoute({ validateSearch, errorComponent: ({ error }) => { @@ -1400,7 +1400,7 @@ describe('search params in URL', () => { }) it('throws an error when the search param is not valid', async () => { - let errorSpy: unknown + let errorSpy: Error | undefined const rootRoute = createRootRoute({ validateSearch, errorComponent: ({ error }) => { @@ -1415,9 +1415,7 @@ describe('search params in URL', () => { await router.load() expect(errorSpy).toBeInstanceOf(SearchParamError) - expect( - errorSpy instanceof Error ? errorSpy.cause : undefined, - ).toBeInstanceOf(TestValidationError) + expect(errorSpy?.cause).toBeInstanceOf(TestValidationError) }) }) }) diff --git a/packages/solid-router/tests/server/errorComponent.test.tsx b/packages/solid-router/tests/server/errorComponent.test.tsx index 7441ba7de8b..38c07310102 100644 --- a/packages/solid-router/tests/server/errorComponent.test.tsx +++ b/packages/solid-router/tests/server/errorComponent.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createRootRoute, createRoute, createRouter } from '../../src' import { RouterServer, @@ -7,41 +7,78 @@ import { } from '../../src/ssr/server' describe('errorComponent (server)', () => { - it('renders the route error component when a loader throws during SSR', async () => { - const rootRoute = createRootRoute() + it.each([ + ['Error', new Error('loader boom')], + ['Error with cause', new Error('loader boom', { cause: false })], + ['string', 'loader boom'], + ['empty string', ''], + ['false', false], + ['zero', 0], + ['negative zero', -0], + ['bigint zero', 0n], + ['null', null], + ['undefined', undefined], + ['NaN', NaN], + ['object', { message: 'loader boom' }], + ])( + 'normalizes a thrown %s for the SSR error component', + async (_, thrown) => { + const onError = vi.fn() + let renderedError: Error | undefined + const rootRoute = createRootRoute() - const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: '/', - loader: () => { - throw new Error('loader boom') - }, - component: () =>
Index route
, - errorComponent: ({ error }) => ( -
- Route error: {error instanceof Error ? error.message : String(error)} -
- ), - }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + loader: () => { + throw thrown + }, + component: () =>
Index route
, + onError, + errorComponent: ({ error }) => { + renderedError = error + return ( +
+ Route error: {error.message} +
+ ) + }, + }) - const routeTree = rootRoute.addChildren([indexRoute]) - const handler = createRequestHandler({ - request: new Request('http://localhost/'), - createRouter: () => createRouter({ routeTree, isServer: true }), - }) + const routeTree = rootRoute.addChildren([indexRoute]) + const handler = createRequestHandler({ + request: new Request('http://localhost/'), + createRouter: () => createRouter({ routeTree, isServer: true }), + }) - const response = await handler(({ router, responseHeaders }) => - renderRouterToString({ - router, - responseHeaders, - children: () => , - }), - ) + const response = await handler(async ({ router, responseHeaders }) => { + const result = await renderRouterToString({ + router, + responseHeaders, + children: () => , + }) + const match = router.stores.matches + .get() + .find((match) => match.routeId === indexRoute.id) + expect(match?.status).toBe('error') + expect(match?.error).toBe(thrown) + return result + }) - expect(response.status).toBe(500) - const html = await response.text() - expect(html).toContain('data-testid="error-component"') - expect(html).toContain('loader boom') - expect(html).not.toContain('Index route') - }) + expect(response.status).toBe(500) + const html = await response.text() + expect(html).toContain('data-testid="error-component"') + expect(renderedError).toBeInstanceOf(Error) + if (thrown instanceof Error) { + expect(renderedError).toBe(thrown) + } else { + expect(renderedError?.message).toBe( + typeof thrown === 'string' ? thrown : 'Unknown error', + ) + expect(renderedError?.cause).toBe(thrown) + } + expect(onError).toHaveBeenCalledWith(thrown) + expect(html).not.toContain('Index route') + }, + ) })