diff --git a/.changeset/calm-errors-listen.md b/.changeset/calm-errors-listen.md new file mode 100644 index 00000000000..029f880dc2e --- /dev/null +++ b/.changeset/calm-errors-listen.md @@ -0,0 +1,10 @@ +--- +'@tanstack/react-router': patch +'@tanstack/router-core': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +--- + +Preserve falsy thrown values in React and Vue error boundaries. Type React and Vue boundary error components and `onCatch` callbacks as `unknown`. Solid boundary errors remain typed as `Error`; SSR now wraps non-`Error` loader errors to match Solid’s native boundary behavior, preserving the original value in `cause`. Router state and loader `onError` values are unchanged. + +When upgrading React or Vue, narrow boundary errors (for example, with `error instanceof Error`) before reading `message` or `stack`. `ErrorComponentProps` remains available for values narrowed to a specific error type. Route `onError` types are unchanged. 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/docs/router/api/router/RouteOptionsType.md b/docs/router/api/router/RouteOptionsType.md index ac35cdc8452..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: Error, errorInfo: ErrorInfo) => void` +- Type: `(error: unknown) => void` in React and Vue; `(error: Error) => void` in Solid - Optional - Defaults to `routerOptions.defaultOnCatch` - A function that will be called when errors are caught when the route encounters an error. 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/api/router/catchBoundaryComponent.md b/docs/router/api/router/catchBoundaryComponent.md index deef0ffa22b..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: any) => void` +- Type: `(error: unknown, errorInfo: ErrorInfo) => void` in React; `(error: unknown) => void` in Vue; `(error: Error) => void` in Solid - Optional - A callback that will be called with the error that was thrown by the component's children. diff --git a/docs/router/api/router/errorComponentComponent.md b/docs/router/api/router/errorComponentComponent.md index 04399f124da..07d7187580c 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,8 +11,8 @@ The `ErrorComponent` component accepts the following props: ### `props.error` prop -- Type: `TError` (defaults to `Error`) -- The error that was thrown by the component's children +- Type: `unknown` in React and Vue; `Error` in Solid +- The caught error. Solid normalizes non-`Error` values, including SSR loader errors, into an `Error` with the original value in `cause`. ### `props.info` prop @@ -27,6 +27,6 @@ The `ErrorComponent` component accepts the following props: ## ErrorComponent returns -- Returns a formatted error message with the error's message if it exists. +- Displays an error notice, with details when the thrown value has a nonempty `message`. Values without a message still display the error notice. - The error message can be toggled by clicking the "Show Error" button. - By default, the error message will be shown in development. diff --git a/docs/router/guide/data-loading.md b/docs/router/guide/data-loading.md index 26a82eaad68..6b9fd427779 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,16 +581,20 @@ export const Route = createFileRoute('/posts')({ The `routeOptions.errorComponent` option is a component that is rendered when an error occurs during the route loading or rendering lifecycle. It is rendered with the following props: -- `error` - The error that occurred +- `error` - The caught value (`unknown` in React and Vue; `Error` in Solid) - `reset` - A function to reset the internal `CatchBoundary` +`ErrorComponentProps` defaults its `error` property to `unknown` in React and Vue, where you must narrow the value before accessing properties such as `message`. In Solid, it defaults to `Error`. `ErrorComponentProps` can describe an error after you have narrowed it; it does not restrict what a route can throw. This applies to boundary components and `onCatch` callbacks, including `defaultOnCatch`; it does not change the `onError` callback. + +React and Vue boundaries pass through the caught value, including falsy values. Solid wraps non-`Error` throws in an `Error` whose `cause` contains the original value. This also applies to Solid error components rendered during SSR: existing `Error` instances are preserved, strings become the error message, and other values use the message `Unknown error`. Router state and loader `onError` callbacks retain the original value. + ```tsx // src/routes/posts.tsx export const Route = createFileRoute('/posts')({ loader: () => fetchPosts(), errorComponent: ({ error }) => { // Render an error message - return
{error.message}
+ return
{error instanceof Error ? error.message : String(error)}
}, }) ``` @@ -604,7 +608,7 @@ export const Route = createFileRoute('/posts')({ errorComponent: ({ error, reset }) => { 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/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/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/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/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..ba628f70031 100644 --- a/packages/react-router/skills/react-router/SKILL.md +++ b/packages/react-router/skills/react-router/SKILL.md @@ -346,7 +346,7 @@ import { CatchBoundary } from '@tanstack/react-router' ; 'widget'} onCatch={(error) => console.error(error)} - errorComponent={({ error }) =>
Error: {error.message}
} + errorComponent={({ error }) =>
{String(error)}
} >
diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx index f55a250ab1d..1015a9e116e 100644 --- a/packages/react-router/src/CatchBoundary.tsx +++ b/packages/react-router/src/CatchBoundary.tsx @@ -9,29 +9,30 @@ export class CatchBoundary extends React.Component<{ getResetKey: () => unknown children: React.ReactNode errorComponent?: ErrorRouteComponent - onCatch?: (error: Error, errorInfo: ErrorInfo) => void + onCatch?: (error: unknown, errorInfo: ErrorInfo) => void }> { - state = { error: null } as { error: Error | null; resetKey?: unknown } + // Wrapping caught values keeps every possible thrown value truthy. + state = { error: 0 } as { error: [unknown] | 0; resetKey?: unknown } static getDerivedStateFromProps( props: { getResetKey: () => unknown }, - state: { resetKey?: unknown; error: Error | null }, + state: { resetKey?: unknown; error: [unknown] | 0 }, ) { const resetKey = props.getResetKey() if (state.error && state.resetKey !== resetKey) { - return { resetKey, error: null } + return { resetKey, error: 0 } } return { resetKey } } - static getDerivedStateFromError(error: Error) { - return { error } + static getDerivedStateFromError(error: unknown) { + return { error: [error] } } reset = () => { - this.setState({ error: null }) + this.setState({ error: 0 }) } - componentDidCatch(error: Error, errorInfo: ErrorInfo) { + componentDidCatch(error: unknown, errorInfo: ErrorInfo) { this.props.onCatch?.(error, errorInfo) } render() { @@ -40,7 +41,7 @@ export class CatchBoundary extends React.Component<{ const element = React.createElement( this.props.errorComponent ?? ErrorComponent, { - error, + error: error[0], reset: this.reset, }, ) @@ -54,7 +55,7 @@ export class CatchBoundary extends React.Component<{ } } -export function ErrorComponent({ error }: { error: any }) { +export function ErrorComponent({ error }: { error: unknown }) { const [show, setShow] = React.useState(process.env.NODE_ENV !== 'production') return ( @@ -88,7 +89,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/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/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 b5ee4b6709c..442a94bc103 100644 --- a/packages/react-router/tests/errorComponent.test.tsx +++ b/packages/react-router/tests/errorComponent.test.tsx @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { + CatchBoundary, + ErrorComponent, HeadContent, Link, Outlet, @@ -23,7 +25,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 +330,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 +430,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 +800,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({ @@ -868,3 +934,115 @@ 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 }, 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([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('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/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/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 ( + )} + 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) + }, +) 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) }) }) })