From ca9b64c792031c45dbb3d227adb9335149111b6a Mon Sep 17 00:00:00 2001 From: Sheraff Date: Tue, 1 Sep 2026 10:04:41 +0200 Subject: [PATCH] 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) }) }) })