From b8b01b3614401b4ea12674f40e10aa51733e6075 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 14 Nov 2025 11:46:26 -0500 Subject: [PATCH 1/2] Stabilize client-side onError --- .changeset/small-flowers-drive.md | 5 +++ integration/browser-entry-test.ts | 2 +- .../__tests__/dom/client-on-error-test.tsx | 30 ++++++++--------- packages/react-router/index.ts | 2 +- packages/react-router/lib/components.tsx | 32 +++++++++---------- packages/react-router/lib/context.ts | 4 +-- .../lib/dom-export/hydrated-router.tsx | 10 +++--- packages/react-router/lib/hooks.tsx | 12 +++---- 8 files changed, 51 insertions(+), 46 deletions(-) create mode 100644 .changeset/small-flowers-drive.md diff --git a/.changeset/small-flowers-drive.md b/.changeset/small-flowers-drive.md new file mode 100644 index 0000000000..558916e84c --- /dev/null +++ b/.changeset/small-flowers-drive.md @@ -0,0 +1,5 @@ +--- +"react-router": minor +--- + +Stabilize ``/`` diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts index 5171b7bd93..ad984fd43d 100644 --- a/integration/browser-entry-test.ts +++ b/integration/browser-entry-test.ts @@ -146,7 +146,7 @@ test("allows users to pass an onError function to HydratedRouter", async ({ document, { + onError={(error, errorInfo) => { console.log(error.message, JSON.stringify(errorInfo)) }} /> diff --git a/packages/react-router/__tests__/dom/client-on-error-test.tsx b/packages/react-router/__tests__/dom/client-on-error-test.tsx index 09bdd48d31..0442815b68 100644 --- a/packages/react-router/__tests__/dom/client-on-error-test.tsx +++ b/packages/react-router/__tests__/dom/client-on-error-test.tsx @@ -44,7 +44,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await waitFor(() => screen.getByText("lazy error!")); @@ -75,7 +75,7 @@ describe(`handleError`, () => { }, ]); - render(); + render(); await waitFor(() => screen.getByText("Error:middleware error!")); @@ -104,7 +104,7 @@ describe(`handleError`, () => { }, ]); - render(); + render(); await waitFor(() => screen.getByText("Error:loader error!")); @@ -134,7 +134,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -170,7 +170,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -202,7 +202,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -234,7 +234,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => @@ -269,7 +269,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.fetch("key", "0", "/fetch")); @@ -299,7 +299,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => @@ -335,7 +335,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -380,7 +380,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -429,7 +429,7 @@ describe(`handleError`, () => { } let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -484,7 +484,7 @@ describe(`handleError`, () => { } let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -540,7 +540,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -591,7 +591,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index dc5c81704a..249baffc01 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -103,7 +103,7 @@ export { AwaitContextProvider as UNSAFE_AwaitContextProvider } from "./lib/conte export type { AwaitProps, IndexRouteProps, - unstable_ClientOnErrorFunction, + ClientOnErrorFunction, LayoutRouteProps, MemoryRouterOpts, MemoryRouterProps, diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index fa2d6b03e5..10fab45c43 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -333,7 +333,7 @@ class Deferred { * Function signature for client side error handling for loader/actions errors * and rendering errors via `componentDidCatch` */ -export interface unstable_ClientOnErrorFunction { +export interface ClientOnErrorFunction { ( error: unknown, info: { @@ -374,13 +374,13 @@ export interface RouterProviderProps { * and is only present for render errors. * * ```tsx - * { + * { * console.error(error, errorInfo); * reportToErrorService(error, errorInfo); * }} /> * ``` */ - unstable_onError?: unstable_ClientOnErrorFunction; + onError?: ClientOnErrorFunction; /** * Control whether router state updates are internally wrapped in * [`React.startTransition`](https://react.dev/reference/react/startTransition). @@ -429,7 +429,7 @@ export interface RouterProviderProps { * @mode data * @param props Props * @param {RouterProviderProps.flushSync} props.flushSync n/a - * @param {RouterProviderProps.unstable_onError} props.unstable_onError n/a + * @param {RouterProviderProps.onError} props.onError n/a * @param {RouterProviderProps.router} props.router n/a * @param {RouterProviderProps.unstable_useTransitions} props.unstable_useTransitions n/a * @returns React element for the rendered router @@ -437,7 +437,7 @@ export interface RouterProviderProps { export function RouterProvider({ router, flushSync: reactDomFlushSyncImpl, - unstable_onError, + onError, unstable_useTransitions, }: RouterProviderProps): React.ReactElement { let [_state, setStateImpl] = React.useState(router.state); @@ -461,9 +461,9 @@ export function RouterProvider({ { deletedFetchers, newErrors, flushSync, viewTransitionOpts }, ) => { // Send router errors through onError - if (newErrors && unstable_onError) { + if (newErrors && onError) { Object.values(newErrors).forEach((error) => - unstable_onError(error, { + onError(error, { location: newState.location, params: newState.matches[0]?.params ?? {}, unstable_pattern: getRoutePattern(newState.matches), @@ -583,7 +583,7 @@ export function RouterProvider({ renderDfd, unstable_useTransitions, setOptimisticState, - unstable_onError, + onError, ], ); @@ -689,9 +689,9 @@ export function RouterProvider({ navigator, static: false, basename, - unstable_onError, + onError, }), - [router, navigator, basename, unstable_onError], + [router, navigator, basename, onError], ); // The fragment and {null} here are important! We need them to keep React 18's @@ -717,7 +717,7 @@ export function RouterProvider({ routes={router.routes} future={router.future} state={state} - unstable_onError={unstable_onError} + onError={onError} /> @@ -763,14 +763,14 @@ function DataRoutes({ routes, future, state, - unstable_onError, + onError, }: { routes: DataRouteObject[]; future: DataRouter["future"]; state: RouterState; - unstable_onError: unstable_ClientOnErrorFunction | undefined; + onError: ClientOnErrorFunction | undefined; }): React.ReactElement | null { - return useRoutesImpl(routes, undefined, state, unstable_onError, future); + return useRoutesImpl(routes, undefined, state, onError, future); } /** @@ -1599,10 +1599,10 @@ export function Await({ (error: unknown, errorInfo?: React.ErrorInfo) => { if ( dataRouterContext && - dataRouterContext.unstable_onError && + dataRouterContext.onError && dataRouterStateContext ) { - dataRouterContext.unstable_onError(error, { + dataRouterContext.onError(error, { location: dataRouterStateContext.location, params: dataRouterStateContext.matches[0]?.params || {}, unstable_pattern: getRoutePattern(dataRouterStateContext.matches), diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index ed31101f3a..cc53bd4838 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -1,5 +1,5 @@ import * as React from "react"; -import type { unstable_ClientOnErrorFunction } from "./components"; +import type { ClientOnErrorFunction } from "./components"; import type { History, Location, @@ -92,7 +92,7 @@ export interface DataRouterContextObject extends Omit { router: Router; staticContext?: StaticHandlerContext; - unstable_onError?: unstable_ClientOnErrorFunction; + onError?: ClientOnErrorFunction; } export const DataRouterContext = diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 41437a02f3..0720a71a8b 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -6,7 +6,7 @@ import type { DataRouter, HydrationState, RouterInit, - unstable_ClientOnErrorFunction, + ClientOnErrorFunction, } from "react-router"; import { UNSAFE_getHydrationData as getHydrationData, @@ -291,13 +291,13 @@ export interface HydratedRouterProps { * and is only present for render errors. * * ```tsx - * { + * { * console.error(error, errorInfo); * reportToErrorService(error, errorInfo); * }} /> * ``` */ - unstable_onError?: unstable_ClientOnErrorFunction; + onError?: ClientOnErrorFunction; /** * Control whether router state updates are internally wrapped in * [`React.startTransition`](https://react.dev/reference/react/startTransition). @@ -328,7 +328,7 @@ export interface HydratedRouterProps { * @mode framework * @param props Props * @param {dom.HydratedRouterProps.getContext} props.getContext n/a - * @param {dom.HydratedRouterProps.unstable_onError} props.unstable_onError n/a + * @param {dom.HydratedRouterProps.onError} props.onError n/a * @returns A React element that represents the hydrated application. */ export function HydratedRouter(props: HydratedRouterProps) { @@ -424,7 +424,7 @@ export function HydratedRouter(props: HydratedRouterProps) { diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 4f6ffb16cb..d0bfe2dc78 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -56,7 +56,7 @@ import type { GetLoaderData, SerializeFrom, } from "./types/route-data"; -import type { unstable_ClientOnErrorFunction } from "./components"; +import type { ClientOnErrorFunction } from "./components"; import type { RouteModules } from "./types/register"; /** @@ -757,7 +757,7 @@ export function useRoutesImpl( routes: RouteObject[], locationArg?: Partial | string, dataRouterState?: DataRouter["state"], - unstable_onError?: unstable_ClientOnErrorFunction, + onError?: ClientOnErrorFunction, future?: DataRouter["future"], ): React.ReactElement | null { invariant( @@ -911,7 +911,7 @@ export function useRoutesImpl( ), parentMatches, dataRouterState, - unstable_onError, + onError, future, ); @@ -1106,7 +1106,7 @@ export function _renderMatches( matches: RouteMatch[] | null, parentMatches: RouteMatch[] = [], dataRouterState: DataRouter["state"] | null = null, - unstable_onError: unstable_ClientOnErrorFunction | null = null, + onErrorHandler: ClientOnErrorFunction | null = null, future: DataRouter["future"] | null = null, ): React.ReactElement | null { if (matches == null) { @@ -1190,9 +1190,9 @@ export function _renderMatches( } let onError = - dataRouterState && unstable_onError + dataRouterState && onErrorHandler ? (error: unknown, errorInfo?: React.ErrorInfo) => { - unstable_onError(error, { + onErrorHandler(error, { location: dataRouterState.location, params: dataRouterState.matches?.[0]?.params ?? {}, unstable_pattern: getRoutePattern(dataRouterState.matches), From 7bc3a2a495f9e81f6eef0dee9dddf09d96610e06 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 21 Nov 2025 14:18:21 -0500 Subject: [PATCH 2/2] Docs --- docs/how-to/error-boundary.md | 2 +- docs/how-to/error-reporting.md | 90 +++++++++++++++++-- packages/react-router/lib/components.tsx | 13 +-- .../lib/dom-export/hydrated-router.tsx | 13 +-- 4 files changed, 99 insertions(+), 19 deletions(-) diff --git a/docs/how-to/error-boundary.md b/docs/how-to/error-boundary.md index 0526973837..a12ab02420 100644 --- a/docs/how-to/error-boundary.md +++ b/docs/how-to/error-boundary.md @@ -11,7 +11,7 @@ title: Error Boundaries To avoid rendering an empty page to users, route modules will automatically catch errors in your code and render the closest `ErrorBoundary`. -Error boundaries are not intended for error reporting or rendering form validation errors. Please see [Form Validation](./form-validation) and [Error Reporting](./error-reporting) instead. +Error boundaries are not intended for rendering form validation errors or error reporting. Please see [Form Validation](./form-validation) and [Error Reporting](./error-reporting) instead. ## 1. Add a root error boundary diff --git a/docs/how-to/error-reporting.md b/docs/how-to/error-reporting.md index bd42d2b5fa..424bd6db41 100644 --- a/docs/how-to/error-reporting.md +++ b/docs/how-to/error-reporting.md @@ -4,22 +4,28 @@ title: Error Reporting # Error Reporting -[MODES: framework] +[MODES: framework,data]

-React Router catches errors in your route modules and sends them to [error boundaries](./error-boundary) to prevent blank pages when errors occur. However, ErrorBoundary isn't sufficient for logging and reporting errors. To access these caught errors, use the handleError export of the server entry module. +React Router catches errors in your route modules and sends them to [error boundaries](./error-boundary) to prevent blank pages when errors occur. However, `ErrorBoundary` isn't sufficient for logging and reporting errors. -## 1. Reveal the server entry +## Server Errors -If you don't see `entry.server.tsx` in your app directory, you're using a default entry. Reveal it with this cli command: +[modes: framework] + +To access these caught errors on the server, use the `handleError` export of the server entry module. + +### 1. Reveal the server entry + +If you don't see [`entry.server.tsx`][entryserver] in your app directory, you're using a default entry. Reveal it with this cli command: ```shellscript nonumber -react-router reveal +react-router reveal entry.server ``` -## 2. Export your error handler +### 2. Export your error handler This function is called whenever React Router catches an error in your application on the server. @@ -39,3 +45,75 @@ export const handleError: HandleErrorFunction = ( } }; ``` + +## Client Errors + +To access these caught errors on the client, use the `onError` prop on your [`HydratedRouter`][hydratedrouter] or [`RouterProvider`][routerprovider] component. + +### Framework Mode + +[modes: framework] + +#### 1. Reveal the client entry + +If you don't see [`entry.client.tsx`][entryclient] in your app directory, you're using a default entry. Reveal it with this cli command: + +```shellscript nonumber +react-router reveal entry.client +``` + +#### 2. Add your error handler + +This function is called whenever React Router catches an error in your application on the client. + +```tsx filename=entry.client.tsx +import { type ClientOnErrorFunction } from "react-router"; + +const onError: ClientOnErrorFunction = ( + error, + { location, params, unstable_pattern, errorInfo }, +) => { + myReportError(error, location, errorInfo); + + // make sure to still log the error so you can see it + console.error(error, errorInfo); +}; + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); +``` + +### Data Mode + +[modes: data] + +This function is called whenever React Router catches an error in your application on the client. + +```tsx +import { type ClientOnErrorFunction } from "react-router"; + +const onError: ClientOnErrorFunction = ( + error, + { location, params, unstable_pattern, errorInfo }, +) => { + myReportError(error, location, errorInfo); + + // make sure to still log the error so you can see it + console.error(error, errorInfo); +}; + +function App() { + return ; +} +``` + +[entryserver]: ../api/framework-conventions/entry.server.tsx +[entryclient]: ../api/framework-conventions/entry.client.tsx +[hydratedrouter]: ../api//framework-routers/HydratedRouter +[routerprovider]: ../api/data-routers/RouterProvider diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 10fab45c43..0764509772 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -364,9 +364,9 @@ export interface RouterProviderProps { */ flushSync?: (fn: () => unknown) => undefined; /** - * An error handler function that will be called for any loader/action/render - * errors that are encountered in your application. This is useful for - * logging or reporting errors instead of the `ErrorBoundary` because it's not + * An error handler function that will be called for any middleware, loader, action, + * or render errors that are encountered in your application. This is useful for + * logging or reporting errors instead of in the {@link ErrorBoundary} because it's not * subject to re-rendering and will only run one time per error. * * The `errorInfo` parameter is passed along from @@ -374,9 +374,10 @@ export interface RouterProviderProps { * and is only present for render errors. * * ```tsx - * { - * console.error(error, errorInfo); - * reportToErrorService(error, errorInfo); + * { + * let { location, params, unstable_pattern, errorInfo } = info; + * console.error(error, location, errorInfo); + * reportToErrorService(error, location, errorInfo); * }} /> * ``` */ diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 0720a71a8b..125aa6018a 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -281,9 +281,9 @@ export interface HydratedRouterProps { */ unstable_instrumentations?: unstable_ClientInstrumentation[]; /** - * An error handler function that will be called for any loader/action/render - * errors that are encountered in your application. This is useful for - * logging or reporting errors instead of the `ErrorBoundary` because it's not + * An error handler function that will be called for any middleware, loader, action, + * or render errors that are encountered in your application. This is useful for + * logging or reporting errors instead of in the {@link ErrorBoundary} because it's not * subject to re-rendering and will only run one time per error. * * The `errorInfo` parameter is passed along from @@ -291,9 +291,10 @@ export interface HydratedRouterProps { * and is only present for render errors. * * ```tsx - * { - * console.error(error, errorInfo); - * reportToErrorService(error, errorInfo); + * { + * let { location, params, unstable_pattern, errorInfo } = info; + * console.error(error, location, errorInfo); + * reportToErrorService(error, location, errorInfo); * }} /> * ``` */