From 4e1bb14b1576da4578a558cc8432993d6911bf0a Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Tue, 12 Aug 2025 09:35:49 -0400
Subject: [PATCH 01/13] Add client-side unstable_handleError to data routers
---
.../__tests__/dom/handle-error-test.tsx | 338 ++++++++++++++++++
packages/react-router/index.ts | 1 +
packages/react-router/lib/components.tsx | 31 +-
packages/react-router/lib/dom/lib.tsx | 28 ++
packages/react-router/lib/dom/server.tsx | 5 +-
packages/react-router/lib/hooks.tsx | 24 +-
packages/react-router/lib/router/router.ts | 34 ++
7 files changed, 453 insertions(+), 8 deletions(-)
create mode 100644 packages/react-router/__tests__/dom/handle-error-test.tsx
diff --git a/packages/react-router/__tests__/dom/handle-error-test.tsx b/packages/react-router/__tests__/dom/handle-error-test.tsx
new file mode 100644
index 0000000000..c3b964d36e
--- /dev/null
+++ b/packages/react-router/__tests__/dom/handle-error-test.tsx
@@ -0,0 +1,338 @@
+import { act, fireEvent, render, waitFor } from "@testing-library/react";
+import * as React from "react";
+
+import {
+ Outlet,
+ RouterProvider,
+ createMemoryRouter,
+ useFetcher,
+} from "../../index";
+
+import getHtml from "../utils/getHtml";
+import { createFormData } from "../router/utils/utils";
+
+describe(`handleError`, () => {
+ let consoleError: jest.SpyInstance;
+
+ beforeEach(() => {
+ consoleError = jest.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ consoleError.mockRestore();
+ });
+
+ it("handles navigation loader errors", async () => {
+ let spy = jest.fn();
+ let router = createMemoryRouter(
+ [
+ {
+ path: "/",
+ Component: () => Home
,
+ },
+ {
+ path: "/page",
+ loader() {
+ throw new Error("loader error!");
+ },
+ Component: () => Page
,
+ ErrorBoundary: () => Error
,
+ },
+ ],
+ {
+ unstable_handleError: spy,
+ },
+ );
+
+ let { container } = render();
+
+ await act(() => router.navigate("/page"));
+
+ expect(spy.mock.calls).toEqual([
+ [
+ new Error("loader error!"),
+ {
+ location: expect.objectContaining({ pathname: "/page" }),
+ errorInfo: undefined,
+ },
+ ],
+ ]);
+ expect(getHtml(container)).toContain("Error");
+ });
+
+ it("handles navigation action errors", async () => {
+ let spy = jest.fn();
+ let router = createMemoryRouter(
+ [
+ {
+ path: "/",
+ Component: () => Home
,
+ },
+ {
+ path: "/page",
+ action() {
+ throw new Error("action error!");
+ },
+ Component: () => Page
,
+ ErrorBoundary: () => Error
,
+ },
+ ],
+ {
+ unstable_handleError: spy,
+ },
+ );
+
+ let { container } = render();
+
+ await act(() =>
+ router.navigate("/page", {
+ formMethod: "post",
+ formData: createFormData({}),
+ }),
+ );
+
+ expect(spy.mock.calls).toEqual([
+ [
+ new Error("action error!"),
+ {
+ location: expect.objectContaining({ pathname: "/page" }),
+ errorInfo: undefined,
+ },
+ ],
+ ]);
+ expect(getHtml(container)).toContain("Error");
+ });
+
+ it("handles fetcher loader errors", async () => {
+ let spy = jest.fn();
+ let router = createMemoryRouter(
+ [
+ {
+ path: "/",
+ Component: () => Home
,
+ },
+ {
+ path: "/fetch",
+ loader() {
+ throw new Error("loader error!");
+ },
+ },
+ ],
+ {
+ unstable_handleError: spy,
+ },
+ );
+
+ let { container } = render();
+
+ await act(() => router.fetch("key", "0", "/fetch"));
+
+ expect(spy.mock.calls).toEqual([
+ [
+ new Error("loader error!"),
+ {
+ location: expect.objectContaining({ pathname: "/" }),
+ errorInfo: undefined,
+ },
+ ],
+ ]);
+ expect(getHtml(container)).toContain("Error");
+ });
+
+ it("handles fetcher action errors", async () => {
+ let spy = jest.fn();
+ let router = createMemoryRouter(
+ [
+ {
+ path: "/",
+ Component: () => Home
,
+ },
+ {
+ path: "/fetch",
+ action() {
+ throw new Error("action error!");
+ },
+ },
+ ],
+ {
+ unstable_handleError: spy,
+ },
+ );
+
+ let { container } = render();
+
+ await act(() =>
+ router.fetch("key", "0", "/fetch", {
+ formMethod: "post",
+ formData: createFormData({}),
+ }),
+ );
+
+ expect(spy.mock.calls).toEqual([
+ [
+ new Error("action error!"),
+ {
+ location: expect.objectContaining({ pathname: "/" }),
+ errorInfo: undefined,
+ },
+ ],
+ ]);
+ expect(getHtml(container)).toContain("Error");
+ });
+
+ it("handles render errors", async () => {
+ let spy = jest.fn();
+ let router = createMemoryRouter(
+ [
+ {
+ path: "/",
+ Component: () => Home
,
+ },
+ {
+ path: "/page",
+ Component: () => {
+ throw new Error("render error!");
+ },
+ ErrorBoundary: () => Error
,
+ },
+ ],
+ {
+ unstable_handleError: spy,
+ },
+ );
+
+ let { container } = render();
+
+ await act(() => router.navigate("/page"));
+
+ expect(spy.mock.calls).toEqual([
+ [
+ new Error("render error!"),
+ {
+ location: expect.objectContaining({ pathname: "/page" }),
+ errorInfo: expect.objectContaining({
+ componentStack: expect.any(String),
+ }),
+ },
+ ],
+ ]);
+ expect(getHtml(container)).toContain("Error");
+ });
+
+ it("doesn't double report on state updates during an error boundary from a data error", async () => {
+ let spy = jest.fn();
+ let router = createMemoryRouter(
+ [
+ {
+ path: "/",
+ Component: () => Home
,
+ },
+ {
+ path: "/page",
+ loader() {
+ throw new Error("loader error!");
+ },
+ Component: () => Page
,
+ ErrorBoundary() {
+ let fetcher = useFetcher();
+ return (
+ <>
+ Error
+
+ {fetcher.data}
+ >
+ );
+ },
+ },
+ {
+ path: "/fetch",
+ loader() {
+ return "FETCH";
+ },
+ },
+ ],
+ {
+ unstable_handleError: spy,
+ },
+ );
+
+ let { container } = render();
+
+ await act(() => router.navigate("/page"));
+
+ expect(spy.mock.calls).toEqual([
+ [
+ new Error("loader error!"),
+ {
+ location: expect.objectContaining({ pathname: "/page" }),
+ errorInfo: undefined,
+ },
+ ],
+ ]);
+ expect(getHtml(container)).toContain("Error");
+
+ // Doesn't re-call on a fetcher update from a rendered error boundary
+ await fireEvent.click(container.querySelector("button")!);
+ await waitFor(() => (getHtml(container) as string).includes("FETCH"));
+ expect(spy.mock.calls.length).toBe(1);
+ });
+
+ it("doesn't double report on state updates during an error boundary from a render error", async () => {
+ let spy = jest.fn();
+ let router = createMemoryRouter(
+ [
+ {
+ path: "/",
+ Component: () => Home
,
+ },
+ {
+ path: "/page",
+ Component: () => {
+ throw new Error("render error!");
+ },
+ ErrorBoundary() {
+ let fetcher = useFetcher();
+ return (
+ <>
+ Error
+
+ {fetcher.data}
+ >
+ );
+ },
+ },
+ {
+ path: "/fetch",
+ loader() {
+ return "FETCH";
+ },
+ },
+ ],
+ {
+ unstable_handleError: spy,
+ },
+ );
+
+ let { container } = render();
+
+ await act(() => router.navigate("/page"));
+
+ expect(spy.mock.calls).toEqual([
+ [
+ new Error("render error!"),
+ {
+ location: expect.objectContaining({ pathname: "/page" }),
+ errorInfo: expect.objectContaining({
+ componentStack: expect.any(String),
+ }),
+ },
+ ],
+ ]);
+ expect(getHtml(container)).toContain("Error");
+
+ // Doesn't re-call on a fetcher update from a rendered error boundary
+ await fireEvent.click(container.querySelector("button")!);
+ await waitFor(() => (getHtml(container) as string).includes("FETCH"));
+ expect(spy.mock.calls.length).toBe(1);
+ });
+});
diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts
index 9920d59237..beea213601 100644
--- a/packages/react-router/index.ts
+++ b/packages/react-router/index.ts
@@ -12,6 +12,7 @@ export type {
StaticHandler,
GetScrollPositionFunction,
GetScrollRestorationKeyFunction,
+ unstable_HandleErrorFunction,
StaticHandlerContext,
Fetcher,
Navigation,
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index 1ba32f0150..9178a7beaf 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -21,6 +21,7 @@ import type {
RouterState,
RouterSubscriber,
RouterInit,
+ unstable_HandleErrorFunction,
} from "./router/router";
import { createRouter } from "./router/router";
import type {
@@ -177,6 +178,29 @@ export interface MemoryRouterOpts {
* Lazily define portions of the route tree on navigations.
*/
patchRoutesOnNavigation?: PatchRoutesOnNavigationFunction;
+ /**
+ * 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
+ * subject to re-rendering and will only run one time per error.
+ *
+ * The `errorInfo` parameter is passed along from
+ * [`componentDidCatch`](https://react.dev/reference/react/Component#componentdidcatch)
+ * and is only present for render errors.
+ *
+ * ```tsx
+ * let router = createMemoryRouter(routes, {
+ * unstable_handleError(error, { location, errorInfo }) {
+ * console.log(
+ * `Error at location ${location.pathname}`,
+ * error,
+ * errorInfo
+ * );
+ * }
+ * );
+ * ```
+ */
+ unstable_handleError?: unstable_HandleErrorFunction;
}
/**
@@ -193,6 +217,7 @@ export interface MemoryRouterOpts {
* @param {MemoryRouterOpts.dataStrategy} opts.dataStrategy n/a
* @param {MemoryRouterOpts.future} opts.future n/a
* @param {MemoryRouterOpts.unstable_getContext} opts.unstable_getContext n/a
+ * @param {MemoryRouterOpts.unstable_handleError} opts.unstable_handleError n/a
* @param {MemoryRouterOpts.hydrationData} opts.hydrationData n/a
* @param {MemoryRouterOpts.initialEntries} opts.initialEntries n/a
* @param {MemoryRouterOpts.initialIndex} opts.initialIndex n/a
@@ -211,6 +236,7 @@ export function createMemoryRouter(
initialEntries: opts?.initialEntries,
initialIndex: opts?.initialIndex,
}),
+ unstable_handleError: opts?.unstable_handleError,
hydrationData: opts?.hydrationData,
routes,
hydrationRouteProperties,
@@ -532,6 +558,7 @@ export function RouterProvider({
routes={router.routes}
future={router.future}
state={state}
+ unstable_handleError={router._internalHandleError}
/>
@@ -550,12 +577,14 @@ function DataRoutes({
routes,
future,
state,
+ unstable_handleError,
}: {
routes: DataRouteObject[];
future: DataRouter["future"];
state: RouterState;
+ unstable_handleError: unstable_HandleErrorFunction | undefined;
}): React.ReactElement | null {
- return useRoutesImpl(routes, undefined, state, future);
+ return useRoutesImpl(routes, undefined, state, unstable_handleError, future);
}
/**
diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx
index 4873b298a7..8ed5006ca1 100644
--- a/packages/react-router/lib/dom/lib.tsx
+++ b/packages/react-router/lib/dom/lib.tsx
@@ -24,6 +24,7 @@ import type {
RelativeRoutingType,
Router as DataRouter,
RouterInit,
+ unstable_HandleErrorFunction,
} from "../router/router";
import { IDLE_FETCHER, createRouter } from "../router/router";
import type {
@@ -719,6 +720,29 @@ export interface DOMRouterOpts {
*
*/
patchRoutesOnNavigation?: PatchRoutesOnNavigationFunction;
+ /**
+ * 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
+ * subject to re-rendering and will only run one time per error.
+ *
+ * The `errorInfo` parameter is passed along from
+ * [`componentDidCatch`](https://react.dev/reference/react/Component#componentdidcatch)
+ * and is only present for render errors.
+ *
+ * ```tsx
+ * let router = createBrowserRouter(routes, {
+ * unstable_handleError(error, { location, errorInfo }) {
+ * console.log(
+ * `Error at location ${location.pathname}`,
+ * error,
+ * errorInfo
+ * );
+ * }
+ * );
+ * ```
+ */
+ unstable_handleError?: unstable_HandleErrorFunction;
/**
* [`Window`](https://developer.mozilla.org/en-US/docs/Web/API/Window) object
* override. Defaults to the global `window` instance.
@@ -740,6 +764,7 @@ export interface DOMRouterOpts {
* @param {DOMRouterOpts.dataStrategy} opts.dataStrategy n/a
* @param {DOMRouterOpts.future} opts.future n/a
* @param {DOMRouterOpts.unstable_getContext} opts.unstable_getContext n/a
+ * @param {DOMRouterOpts.unstable_handleError} opts.unstable_handleError n/a
* @param {DOMRouterOpts.hydrationData} opts.hydrationData n/a
* @param {DOMRouterOpts.patchRoutesOnNavigation} opts.patchRoutesOnNavigation n/a
* @param {DOMRouterOpts.window} opts.window n/a
@@ -754,6 +779,7 @@ export function createBrowserRouter(
unstable_getContext: opts?.unstable_getContext,
future: opts?.future,
history: createBrowserHistory({ window: opts?.window }),
+ unstable_handleError: opts?.unstable_handleError,
hydrationData: opts?.hydrationData || parseHydrationData(),
routes,
mapRouteProperties,
@@ -776,6 +802,7 @@ export function createBrowserRouter(
* @param {DOMRouterOpts.basename} opts.basename n/a
* @param {DOMRouterOpts.future} opts.future n/a
* @param {DOMRouterOpts.unstable_getContext} opts.unstable_getContext n/a
+ * @param {DOMRouterOpts.unstable_handleError} opts.unstable_handleError n/a
* @param {DOMRouterOpts.hydrationData} opts.hydrationData n/a
* @param {DOMRouterOpts.dataStrategy} opts.dataStrategy n/a
* @param {DOMRouterOpts.patchRoutesOnNavigation} opts.patchRoutesOnNavigation n/a
@@ -791,6 +818,7 @@ export function createHashRouter(
unstable_getContext: opts?.unstable_getContext,
future: opts?.future,
history: createHashHistory({ window: opts?.window }),
+ unstable_handleError: opts?.unstable_handleError,
hydrationData: opts?.hydrationData || parseHydrationData(),
routes,
mapRouteProperties,
diff --git a/packages/react-router/lib/dom/server.tsx b/packages/react-router/lib/dom/server.tsx
index 985d1b5717..ae1c5b7dbf 100644
--- a/packages/react-router/lib/dom/server.tsx
+++ b/packages/react-router/lib/dom/server.tsx
@@ -235,7 +235,7 @@ function DataRoutes({
future: DataRouter["future"];
state: RouterState;
}): React.ReactElement | null {
- return useRoutesImpl(routes, undefined, state, future);
+ return useRoutesImpl(routes, undefined, state, undefined, future);
}
function serializeErrors(
@@ -484,6 +484,9 @@ export function createStaticRouter(
patchRoutes() {
throw msg("patchRoutes");
},
+ _internalHandleError() {
+ throw msg("_internalHandleError");
+ },
_internalFetchControllers: new Map(),
_internalSetRoutes() {
throw msg("_internalSetRoutes");
diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx
index fcdfa9b232..441a09cd32 100644
--- a/packages/react-router/lib/hooks.tsx
+++ b/packages/react-router/lib/hooks.tsx
@@ -30,6 +30,7 @@ import type {
Router as DataRouter,
RevalidationState,
Navigation,
+ unstable_HandleErrorFunction,
} from "./router/router";
import { IDLE_BLOCKER } from "./router/router";
import type {
@@ -707,6 +708,7 @@ export function useRoutesImpl(
routes: RouteObject[],
locationArg?: Partial | string,
dataRouterState?: DataRouter["state"],
+ unstable_handleError?: unstable_HandleErrorFunction,
future?: DataRouter["future"],
): React.ReactElement | null {
invariant(
@@ -848,6 +850,7 @@ export function useRoutesImpl(
),
parentMatches,
dataRouterState,
+ unstable_handleError,
future,
);
@@ -926,6 +929,7 @@ type RenderErrorBoundaryProps = React.PropsWithChildren<{
error: any;
component: React.ReactNode;
routeContext: RouteContextObject;
+ unstable_handleError: unstable_HandleErrorFunction | null;
}>;
type RenderErrorBoundaryState = {
@@ -985,12 +989,18 @@ export class RenderErrorBoundary extends React.Component<
};
}
- componentDidCatch(error: any, errorInfo: any) {
- console.error(
- "React Router caught the following error during render",
- error,
- errorInfo,
- );
+ componentDidCatch(error: any, errorInfo: React.ErrorInfo) {
+ if (this.props.unstable_handleError) {
+ this.props.unstable_handleError(error, {
+ location: this.props.location,
+ errorInfo,
+ });
+ } else {
+ console.error(
+ "React Router caught the following error during render",
+ error,
+ );
+ }
}
render() {
@@ -1038,6 +1048,7 @@ export function _renderMatches(
matches: RouteMatch[] | null,
parentMatches: RouteMatch[] = [],
dataRouterState: DataRouter["state"] | null = null,
+ unstable_handleError: unstable_HandleErrorFunction | null = null,
future: DataRouter["future"] | null = null,
): React.ReactElement | null {
if (matches == null) {
@@ -1194,6 +1205,7 @@ export function _renderMatches(
error={error}
children={getChildren()}
routeContext={{ outlet: null, matches, isDataRoute: true }}
+ unstable_handleError={unstable_handleError}
/>
) : (
getChildren()
diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts
index 09e03a69bd..5158c8510e 100644
--- a/packages/react-router/lib/router/router.ts
+++ b/packages/react-router/lib/router/router.ts
@@ -1,3 +1,5 @@
+import type * as React from "react";
+
import type { DataRouteMatch, RouteObject } from "../context";
import type { History, Location, Path, To } from "./history";
import {
@@ -269,6 +271,14 @@ export interface Router {
unstable_allowElementMutations?: boolean,
): void;
+ /**
+ * @private
+ * PRIVATE - DO NOT USE
+ *
+ * Error handler exposed for wiring up to `ErrorBoundary` `componentDidCatch`
+ */
+ _internalHandleError: unstable_HandleErrorFunction | undefined;
+
/**
* @private
* PRIVATE - DO NOT USE
@@ -403,6 +413,7 @@ export interface RouterInit {
window?: Window;
dataStrategy?: DataStrategyFunction;
patchRoutesOnNavigation?: AgnosticPatchRoutesOnNavigationFunction;
+ unstable_handleError?: unstable_HandleErrorFunction;
}
/**
@@ -471,6 +482,20 @@ export interface RouterSubscriber {
): void;
}
+/**
+ * Function signature for client side error handling for loader/actions errors
+ * and rendering errors via `componentDidCatch`
+ */
+export interface unstable_HandleErrorFunction {
+ (
+ error: unknown,
+ info: {
+ location: Location;
+ errorInfo?: React.ErrorInfo;
+ },
+ ): void;
+}
+
/**
* Function signature for determining the key to be used in scroll restoration
* for a given location
@@ -855,6 +880,7 @@ export function createRouter(init: RouterInit): Router {
let hydrationRouteProperties = init.hydrationRouteProperties || [];
let mapRouteProperties = init.mapRouteProperties || defaultMapRouteProperties;
+ let unstable_handleError = init.unstable_handleError;
// Routes keyed by ID
let manifest: RouteManifest = {};
@@ -1213,6 +1239,13 @@ export function createRouter(init: RouterInit): Router {
...newState,
};
+ // Send loader/action errors through handleError
+ if (newState.errors && unstable_handleError) {
+ Object.values(newState.errors).forEach((error) =>
+ unstable_handleError(error, { location: state.location }),
+ );
+ }
+
// Cleanup for all fetchers that have returned to idle since we only
// care about in-flight fetchers
// - If it's been unmounted then we can completely delete it
@@ -3449,6 +3482,7 @@ export function createRouter(init: RouterInit): Router {
getBlocker,
deleteBlocker,
patchRoutes,
+ _internalHandleError: unstable_handleError,
_internalFetchControllers: fetchControllers,
// TODO: Remove setRoutes, it's temporary to avoid dealing with
// updating the tree while validating the update algorithm.
From ee38f76ea10ef1846fa927b980355a3504d9ef65 Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Tue, 12 Aug 2025 12:35:45 -0400
Subject: [PATCH 02/13] Add changeset and wire into HydratedRouter
---
.changeset/cold-geese-turn.md | 5 ++
integration/browser-entry-test.ts | 61 +++++++++++++++++++
.../lib/dom-export/hydrated-router.tsx | 26 ++++++++
3 files changed, 92 insertions(+)
create mode 100644 .changeset/cold-geese-turn.md
diff --git a/.changeset/cold-geese-turn.md b/.changeset/cold-geese-turn.md
new file mode 100644
index 0000000000..c6ff4d1329
--- /dev/null
+++ b/.changeset/cold-geese-turn.md
@@ -0,0 +1,5 @@
+---
+"react-router": patch
+---
+
+[UNSTABLE] Add support for `unstable_handleError` for client side data routers
diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts
index c018893bac..19f5193fb9 100644
--- a/integration/browser-entry-test.ts
+++ b/integration/browser-entry-test.ts
@@ -129,3 +129,64 @@ test("allows users to pass a client side context to HydratedRouter", async ({
appFixture.close();
});
+
+test("allows users to pass a handleError function to HydratedRouter", async ({
+ page,
+}) => {
+ let fixture = await createFixture({
+ files: {
+ "app/entry.client.tsx": js`
+ import { HydratedRouter } from "react-router/dom";
+ import { startTransition, StrictMode } from "react";
+ import { hydrateRoot } from "react-dom/client";
+
+ startTransition(() => {
+ hydrateRoot(
+ document,
+
+ {
+ console.log(error.message, JSON.stringify(errorInfo), location.pathname)
+ }}
+ />
+
+ );
+ });
+ `,
+ "app/routes/_index.tsx": js`
+ import { Link } from "react-router";
+ export default function Index() {
+ return Go to Page;
+ }
+ `,
+ "app/routes/page.tsx": js`
+ export default function Page() {
+ throw new Error("Render error");
+ }
+ export function ErrorBoundary({ error }) {
+ return Error: {error.message}
+ }
+ `,
+ },
+ });
+
+ let logs: string[] = [];
+ page.on("console", (msg) => logs.push(msg.text()));
+
+ let appFixture = await createAppFixture(fixture);
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/", true);
+ await page.click('a[href="/page"]');
+ await page.waitForSelector("[data-error]");
+ expect(await app.getHtml()).toContain("Error: Render error");
+ expect(logs.length).toBe(2);
+ // First one is react logging the error
+ expect(logs[0]).toContain("Error: Render error");
+ expect(logs[0]).not.toContain("componentStack");
+ // Second one is ours
+ expect(logs[1]).toContain("Render error");
+ expect(logs[1]).toContain('"componentStack":');
+ expect(logs[1]).toContain("/page");
+
+ appFixture.close();
+});
diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx
index b6486d3952..712f607fcb 100644
--- a/packages/react-router/lib/dom-export/hydrated-router.tsx
+++ b/packages/react-router/lib/dom-export/hydrated-router.tsx
@@ -77,8 +77,10 @@ function initSsrInfo(): void {
function createHydratedRouter({
unstable_getContext,
+ unstable_handleError,
}: {
unstable_getContext?: RouterInit["unstable_getContext"];
+ unstable_handleError?: RouterInit["unstable_handleError"];
}): DataRouter {
initSsrInfo();
@@ -169,6 +171,7 @@ function createHydratedRouter({
history: createBrowserHistory(),
basename: ssrInfo.context.basename,
unstable_getContext,
+ unstable_handleError,
hydrationData,
hydrationRouteProperties,
mapRouteProperties,
@@ -222,6 +225,27 @@ export interface HydratedRouterProps {
* functions
*/
unstable_getContext?: RouterInit["unstable_getContext"];
+ /**
+ * 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
+ * subject to re-rendering and will only run one time per error.
+ *
+ * The `errorInfo` parameter is passed along from
+ * [`componentDidCatch`](https://react.dev/reference/react/Component#componentdidcatch)
+ * and is only present for render errors.
+ *
+ * ```tsx
+ * {
+ * console.log(
+ * `Error at location ${location.pathname}`,
+ * error,
+ * errorInfo
+ * );
+ * }} />
+ * ```
+ */
+ unstable_handleError?: RouterInit["unstable_handleError"];
}
/**
@@ -233,12 +257,14 @@ export interface HydratedRouterProps {
* @mode framework
* @param props Props
* @param {dom.HydratedRouterProps.unstable_getContext} props.unstable_getContext n/a
+ * @param {dom.HydratedRouterProps.unstable_handleError} props.unstable_handleError n/a
* @returns A React element that represents the hydrated application.
*/
export function HydratedRouter(props: HydratedRouterProps) {
if (!router) {
router = createHydratedRouter({
unstable_getContext: props.unstable_getContext,
+ unstable_handleError: props.unstable_handleError,
});
}
From 1ab7d52b9acf697e2666e3509c1545bc1942a949 Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Tue, 12 Aug 2025 14:53:36 -0400
Subject: [PATCH 03/13] Move to a prop on RouterProvider
---
integration/browser-entry-test.ts | 2 +
.../__tests__/dom/handle-error-test.tsx | 278 ++++++++----------
packages/react-router/lib/components.tsx | 59 +++-
.../lib/dom-export/hydrated-router.tsx | 5 +-
packages/react-router/lib/router/router.ts | 7 -
5 files changed, 184 insertions(+), 167 deletions(-)
diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts
index 19f5193fb9..6fd6c75ce5 100644
--- a/integration/browser-entry-test.ts
+++ b/integration/browser-entry-test.ts
@@ -175,9 +175,11 @@ test("allows users to pass a handleError function to HydratedRouter", async ({
let appFixture = await createAppFixture(fixture);
let app = new PlaywrightFixture(appFixture, page);
+
await app.goto("/", true);
await page.click('a[href="/page"]');
await page.waitForSelector("[data-error]");
+
expect(await app.getHtml()).toContain("Error: Render error");
expect(logs.length).toBe(2);
// First one is react logging the error
diff --git a/packages/react-router/__tests__/dom/handle-error-test.tsx b/packages/react-router/__tests__/dom/handle-error-test.tsx
index c3b964d36e..ecc1f2f34f 100644
--- a/packages/react-router/__tests__/dom/handle-error-test.tsx
+++ b/packages/react-router/__tests__/dom/handle-error-test.tsx
@@ -1,12 +1,7 @@
import { act, fireEvent, render, waitFor } from "@testing-library/react";
import * as React from "react";
-import {
- Outlet,
- RouterProvider,
- createMemoryRouter,
- useFetcher,
-} from "../../index";
+import { RouterProvider, createMemoryRouter, useFetcher } from "../../index";
import getHtml from "../utils/getHtml";
import { createFormData } from "../router/utils/utils";
@@ -24,27 +19,24 @@ describe(`handleError`, () => {
it("handles navigation loader errors", async () => {
let spy = jest.fn();
- let router = createMemoryRouter(
- [
- {
- path: "/",
- Component: () => Home
,
- },
- {
- path: "/page",
- loader() {
- throw new Error("loader error!");
- },
- Component: () => Page
,
- ErrorBoundary: () => Error
,
- },
- ],
+ let router = createMemoryRouter([
{
- unstable_handleError: spy,
+ path: "/",
+ Component: () => Home
,
},
- );
+ {
+ path: "/page",
+ loader() {
+ throw new Error("loader error!");
+ },
+ Component: () => Page
,
+ ErrorBoundary: () => Error
,
+ },
+ ]);
- let { container } = render();
+ let { container } = render(
+ ,
+ );
await act(() => router.navigate("/page"));
@@ -62,27 +54,24 @@ describe(`handleError`, () => {
it("handles navigation action errors", async () => {
let spy = jest.fn();
- let router = createMemoryRouter(
- [
- {
- path: "/",
- Component: () => Home
,
- },
- {
- path: "/page",
- action() {
- throw new Error("action error!");
- },
- Component: () => Page
,
- ErrorBoundary: () => Error
,
- },
- ],
+ let router = createMemoryRouter([
{
- unstable_handleError: spy,
+ path: "/",
+ Component: () => Home
,
},
- );
+ {
+ path: "/page",
+ action() {
+ throw new Error("action error!");
+ },
+ Component: () => Page
,
+ ErrorBoundary: () => Error
,
+ },
+ ]);
- let { container } = render();
+ let { container } = render(
+ ,
+ );
await act(() =>
router.navigate("/page", {
@@ -105,25 +94,22 @@ describe(`handleError`, () => {
it("handles fetcher loader errors", async () => {
let spy = jest.fn();
- let router = createMemoryRouter(
- [
- {
- path: "/",
- Component: () => Home
,
- },
- {
- path: "/fetch",
- loader() {
- throw new Error("loader error!");
- },
- },
- ],
+ let router = createMemoryRouter([
{
- unstable_handleError: spy,
+ path: "/",
+ Component: () => Home
,
},
- );
+ {
+ path: "/fetch",
+ loader() {
+ throw new Error("loader error!");
+ },
+ },
+ ]);
- let { container } = render();
+ let { container } = render(
+ ,
+ );
await act(() => router.fetch("key", "0", "/fetch"));
@@ -141,25 +127,22 @@ describe(`handleError`, () => {
it("handles fetcher action errors", async () => {
let spy = jest.fn();
- let router = createMemoryRouter(
- [
- {
- path: "/",
- Component: () => Home
,
- },
- {
- path: "/fetch",
- action() {
- throw new Error("action error!");
- },
- },
- ],
+ let router = createMemoryRouter([
{
- unstable_handleError: spy,
+ path: "/",
+ Component: () => Home
,
},
- );
+ {
+ path: "/fetch",
+ action() {
+ throw new Error("action error!");
+ },
+ },
+ ]);
- let { container } = render();
+ let { container } = render(
+ ,
+ );
await act(() =>
router.fetch("key", "0", "/fetch", {
@@ -182,26 +165,23 @@ describe(`handleError`, () => {
it("handles render errors", async () => {
let spy = jest.fn();
- let router = createMemoryRouter(
- [
- {
- path: "/",
- Component: () => Home
,
- },
- {
- path: "/page",
- Component: () => {
- throw new Error("render error!");
- },
- ErrorBoundary: () => Error
,
- },
- ],
+ let router = createMemoryRouter([
{
- unstable_handleError: spy,
+ path: "/",
+ Component: () => Home
,
},
- );
+ {
+ path: "/page",
+ Component: () => {
+ throw new Error("render error!");
+ },
+ ErrorBoundary: () => Error
,
+ },
+ ]);
- let { container } = render();
+ let { container } = render(
+ ,
+ );
await act(() => router.navigate("/page"));
@@ -221,42 +201,39 @@ describe(`handleError`, () => {
it("doesn't double report on state updates during an error boundary from a data error", async () => {
let spy = jest.fn();
- let router = createMemoryRouter(
- [
- {
- path: "/",
- Component: () => Home
,
- },
- {
- path: "/page",
- loader() {
- throw new Error("loader error!");
- },
- Component: () => Page
,
- ErrorBoundary() {
- let fetcher = useFetcher();
- return (
- <>
- Error
-
- {fetcher.data}
- >
- );
- },
+ let router = createMemoryRouter([
+ {
+ path: "/",
+ Component: () => Home
,
+ },
+ {
+ path: "/page",
+ loader() {
+ throw new Error("loader error!");
},
- {
- path: "/fetch",
- loader() {
- return "FETCH";
- },
+ Component: () => Page
,
+ ErrorBoundary() {
+ let fetcher = useFetcher();
+ return (
+ <>
+ Error
+
+ {fetcher.data}
+ >
+ );
},
- ],
+ },
{
- unstable_handleError: spy,
+ path: "/fetch",
+ loader() {
+ return "FETCH";
+ },
},
- );
+ ]);
- let { container } = render();
+ let { container } = render(
+ ,
+ );
await act(() => router.navigate("/page"));
@@ -279,41 +256,38 @@ describe(`handleError`, () => {
it("doesn't double report on state updates during an error boundary from a render error", async () => {
let spy = jest.fn();
- let router = createMemoryRouter(
- [
- {
- path: "/",
- Component: () => Home
,
- },
- {
- path: "/page",
- Component: () => {
- throw new Error("render error!");
- },
- ErrorBoundary() {
- let fetcher = useFetcher();
- return (
- <>
- Error
-
- {fetcher.data}
- >
- );
- },
+ let router = createMemoryRouter([
+ {
+ path: "/",
+ Component: () => Home
,
+ },
+ {
+ path: "/page",
+ Component: () => {
+ throw new Error("render error!");
},
- {
- path: "/fetch",
- loader() {
- return "FETCH";
- },
+ ErrorBoundary() {
+ let fetcher = useFetcher();
+ return (
+ <>
+ Error
+
+ {fetcher.data}
+ >
+ );
},
- ],
+ },
{
- unstable_handleError: spy,
+ path: "/fetch",
+ loader() {
+ return "FETCH";
+ },
},
- );
+ ]);
- let { container } = render();
+ let { container } = render(
+ ,
+ );
await act(() => router.navigate("/page"));
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index 9178a7beaf..2f839e89f6 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -289,6 +289,27 @@ export interface RouterProviderProps {
* `RouterProvider` from `react-router` and ignore this prop
*/
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
+ * subject to re-rendering and will only run one time per error.
+ *
+ * The `errorInfo` parameter is passed along from
+ * [`componentDidCatch`](https://react.dev/reference/react/Component#componentdidcatch)
+ * and is only present for render errors.
+ *
+ * ```tsx
+ * {
+ * console.log(
+ * `Error at location ${location.pathname}`,
+ * error,
+ * errorInfo
+ * );
+ * }} />
+ * ```
+ */
+ unstable_handleError?: unstable_HandleErrorFunction;
}
/**
@@ -319,11 +340,13 @@ export interface RouterProviderProps {
* @param props Props
* @param {RouterProviderProps.flushSync} props.flushSync n/a
* @param {RouterProviderProps.router} props.router n/a
+ * @param {RouterProviderProps.unstable_handleError} props.unstable_handleError n/a
* @returns React element for the rendered router
*/
export function RouterProvider({
router,
flushSync: reactDomFlushSyncImpl,
+ unstable_handleError,
}: RouterProviderProps): React.ReactElement {
let [state, setStateImpl] = React.useState(router.state);
let [pendingState, setPendingState] = React.useState();
@@ -338,6 +361,22 @@ export function RouterProvider({
nextLocation: Location;
}>();
let fetcherData = React.useRef
}>
+ Await Error}>
+
+
+
+ );
+ },
+ },
+ ]);
+
+ function RenderAwait() {
+ throw new Error("await error!");
+ // eslint-disable-next-line no-unreachable
+ return should not see me
;
+ }
+
+ let { container } = render(
+ ,
+ );
+
+ await act(() => router.navigate("/page"));
+ await waitFor(() => screen.getByText("Await Error"));
+
+ expect(spy.mock.calls).toEqual([
+ [
+ new Error("await error!"),
+ {
+ location: expect.objectContaining({ pathname: "/page" }),
+ errorInfo: expect.objectContaining({
+ componentStack: expect.any(String),
+ }),
+ },
+ ],
+ ]);
+ expect(getHtml(container)).toContain("Await Error");
+ });
+
+ it("handles render errors from Await errorElement", async () => {
+ let spy = jest.fn();
+ let router = createMemoryRouter([
+ {
+ path: "/",
+ Component: () => Home
,
+ },
+ {
+ path: "/page",
+ loader() {
+ return {
+ promise: new Promise((_, r) =>
+ setTimeout(() => r(new Error("await error!")), 10),
+ ),
+ };
+ },
+ Component() {
+ let data = useLoaderData();
+ return (
+ Loading...}>
+ }>
+ {() => Should not see me
}
+
+
+ );
+ },
+ ErrorBoundary: () => Route Error
,
+ },
+ ]);
+
+ function RenderError() {
+ throw new Error("errorElement error!");
+ // eslint-disable-next-line no-unreachable
+ return should not see me
;
+ }
+
+ let { container } = render(
+ ,
+ );
+
+ await act(() => router.navigate("/page"));
+ await waitFor(() => screen.getByText("Route Error"));
+
+ expect(spy.mock.calls).toEqual([
+ [
+ new Error("await error!"),
+ {
+ location: expect.objectContaining({ pathname: "/page" }),
+ },
+ ],
+ [
+ new Error("errorElement error!"),
+ {
+ location: expect.objectContaining({ pathname: "/page" }),
+ errorInfo: expect.objectContaining({
+ componentStack: expect.any(String),
+ }),
+ },
+ ],
+ ]);
+ expect(getHtml(container)).toContain("Route Error");
+ });
+
it("doesn't double report on state updates during an error boundary from a data error", async () => {
let spy = jest.fn();
let router = createMemoryRouter([
@@ -250,7 +424,7 @@ describe(`handleError`, () => {
// Doesn't re-call on a fetcher update from a rendered error boundary
await fireEvent.click(container.querySelector("button")!);
- await waitFor(() => (getHtml(container) as string).includes("FETCH"));
+ await waitFor(() => screen.getByText("FETCH"));
expect(spy.mock.calls.length).toBe(1);
});
@@ -306,7 +480,7 @@ describe(`handleError`, () => {
// Doesn't re-call on a fetcher update from a rendered error boundary
await fireEvent.click(container.querySelector("button")!);
- await waitFor(() => (getHtml(container) as string).includes("FETCH"));
+ await waitFor(() => screen.getByText("FETCH"));
expect(spy.mock.calls.length).toBe(1);
});
});
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index 80bb2e9048..3f66ec0cdd 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -565,8 +565,9 @@ export function RouterProvider({
navigator,
static: false,
basename,
+ unstable_handleError,
}),
- [router, navigator, basename],
+ [router, navigator, basename, unstable_handleError],
);
// The fragment and {null} here are important! We need them to keep React 18's
@@ -1400,8 +1401,17 @@ export function Await({
errorElement,
resolve,
}: AwaitProps) {
+ let dataRouterContext = React.useContext(DataRouterContext);
+ // Use this instead of useLocation() so that Await can still be used standalone
+ // and not inside of a
+ let dataRouterStateContext = React.useContext(DataRouterStateContext);
return (
-
+
{children}
);
@@ -1410,6 +1420,8 @@ export function Await({
type AwaitErrorBoundaryProps = React.PropsWithChildren<{
errorElement?: React.ReactNode;
resolve: TrackedPromise | any;
+ location?: Location;
+ unstable_handleError?: unstable_HandleErrorFunction;
}>;
type AwaitErrorBoundaryState = {
@@ -1435,12 +1447,20 @@ class AwaitErrorBoundary extends React.Component<
return { error };
}
- componentDidCatch(error: any, errorInfo: any) {
- console.error(
- " caught the following error during render",
- error,
- errorInfo,
- );
+ componentDidCatch(error: any, errorInfo: React.ErrorInfo) {
+ if (this.props.unstable_handleError && this.props.location) {
+ // Log render errors
+ this.props.unstable_handleError(error, {
+ location: this.props.location,
+ errorInfo,
+ });
+ } else {
+ console.error(
+ " caught the following error during render",
+ error,
+ errorInfo,
+ );
+ }
}
render() {
@@ -1478,8 +1498,13 @@ class AwaitErrorBoundary extends React.Component<
promise = resolve.then(
(data: any) =>
Object.defineProperty(resolve, "_data", { get: () => data }),
- (error: any) =>
- Object.defineProperty(resolve, "_error", { get: () => error }),
+ (error: any) => {
+ // Log promise rejections
+ this.props.unstable_handleError?.(error, {
+ location: this.props.location,
+ });
+ Object.defineProperty(resolve, "_error", { get: () => error });
+ },
);
}
diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts
index 21b9c380a4..14abf17eb1 100644
--- a/packages/react-router/lib/context.ts
+++ b/packages/react-router/lib/context.ts
@@ -1,8 +1,9 @@
import * as React from "react";
+import type { unstable_HandleErrorFunction } from "./components";
import type {
History,
- Action as NavigationType,
Location,
+ Action as NavigationType,
To,
} from "./router/history";
import type {
@@ -90,6 +91,7 @@ export interface DataRouterContextObject
extends Omit {
router: Router;
staticContext?: StaticHandlerContext;
+ unstable_handleError?: unstable_HandleErrorFunction;
}
export const DataRouterContext =
From baa7543a040690319185cc6ea4e116bff3823b9e Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Tue, 12 Aug 2025 15:57:55 -0400
Subject: [PATCH 07/13] Rename type to unstable_ClientHandleErrorFunction
---
packages/react-router/index.ts | 2 +-
packages/react-router/lib/components.tsx | 8 ++++----
packages/react-router/lib/context.ts | 4 ++--
packages/react-router/lib/dom-export/hydrated-router.tsx | 4 ++--
packages/react-router/lib/hooks.tsx | 8 ++++----
5 files changed, 13 insertions(+), 13 deletions(-)
diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts
index 1008db9ea9..62c320bb91 100644
--- a/packages/react-router/index.ts
+++ b/packages/react-router/index.ts
@@ -97,7 +97,7 @@ export type {
export type {
AwaitProps,
IndexRouteProps,
- unstable_HandleErrorFunction,
+ unstable_ClientHandleErrorFunction,
LayoutRouteProps,
MemoryRouterOpts,
MemoryRouterProps,
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index 3f66ec0cdd..b5210e5ab1 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -249,7 +249,7 @@ class Deferred {
* Function signature for client side error handling for loader/actions errors
* and rendering errors via `componentDidCatch`
*/
-export interface unstable_HandleErrorFunction {
+export interface unstable_ClientHandleErrorFunction {
(
error: unknown,
info: {
@@ -297,7 +297,7 @@ export interface RouterProviderProps {
* }} />
* ```
*/
- unstable_handleError?: unstable_HandleErrorFunction;
+ unstable_handleError?: unstable_ClientHandleErrorFunction;
}
/**
@@ -616,7 +616,7 @@ function DataRoutes({
routes: DataRouteObject[];
future: DataRouter["future"];
state: RouterState;
- unstable_handleError: unstable_HandleErrorFunction | undefined;
+ unstable_handleError: unstable_ClientHandleErrorFunction | undefined;
}): React.ReactElement | null {
return useRoutesImpl(routes, undefined, state, unstable_handleError, future);
}
@@ -1421,7 +1421,7 @@ type AwaitErrorBoundaryProps = React.PropsWithChildren<{
errorElement?: React.ReactNode;
resolve: TrackedPromise | any;
location?: Location;
- unstable_handleError?: unstable_HandleErrorFunction;
+ unstable_handleError?: unstable_ClientHandleErrorFunction;
}>;
type AwaitErrorBoundaryState = {
diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts
index 14abf17eb1..9be3dad8b2 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_HandleErrorFunction } from "./components";
+import type { unstable_ClientHandleErrorFunction } from "./components";
import type {
History,
Location,
@@ -91,7 +91,7 @@ export interface DataRouterContextObject
extends Omit {
router: Router;
staticContext?: StaticHandlerContext;
- unstable_handleError?: unstable_HandleErrorFunction;
+ unstable_handleError?: unstable_ClientHandleErrorFunction;
}
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 fb76e50679..a980314f62 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_HandleErrorFunction,
+ unstable_ClientHandleErrorFunction,
} from "react-router";
import {
UNSAFE_getHydrationData as getHydrationData,
@@ -243,7 +243,7 @@ export interface HydratedRouterProps {
* }} />
* ```
*/
- unstable_handleError?: unstable_HandleErrorFunction;
+ unstable_handleError?: unstable_ClientHandleErrorFunction;
}
/**
diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx
index 0ef46df260..d7884daf93 100644
--- a/packages/react-router/lib/hooks.tsx
+++ b/packages/react-router/lib/hooks.tsx
@@ -51,7 +51,7 @@ import {
stripBasename,
} from "./router/utils";
import type { SerializeFrom } from "./types/route-data";
-import { unstable_HandleErrorFunction } from "./components";
+import { unstable_ClientHandleErrorFunction } from "./components";
/**
* Resolves a URL against the current {@link Location}.
@@ -708,7 +708,7 @@ export function useRoutesImpl(
routes: RouteObject[],
locationArg?: Partial | string,
dataRouterState?: DataRouter["state"],
- unstable_handleError?: unstable_HandleErrorFunction,
+ unstable_handleError?: unstable_ClientHandleErrorFunction,
future?: DataRouter["future"],
): React.ReactElement | null {
invariant(
@@ -929,7 +929,7 @@ type RenderErrorBoundaryProps = React.PropsWithChildren<{
error: any;
component: React.ReactNode;
routeContext: RouteContextObject;
- unstable_handleError: unstable_HandleErrorFunction | null;
+ unstable_handleError: unstable_ClientHandleErrorFunction | null;
}>;
type RenderErrorBoundaryState = {
@@ -1048,7 +1048,7 @@ export function _renderMatches(
matches: RouteMatch[] | null,
parentMatches: RouteMatch[] = [],
dataRouterState: DataRouter["state"] | null = null,
- unstable_handleError: unstable_HandleErrorFunction | null = null,
+ unstable_handleError: unstable_ClientHandleErrorFunction | null = null,
future: DataRouter["future"] | null = null,
): React.ReactElement | null {
if (matches == null) {
From 2eb71643f10dc375e46f12a7e9b409ac1b16142a Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Tue, 12 Aug 2025 16:10:47 -0400
Subject: [PATCH 08/13] Align to RFC API
---
.../__tests__/dom/handle-error-test.tsx | 144 +++++-------------
packages/react-router/lib/components.tsx | 35 +----
.../lib/dom-export/hydrated-router.tsx | 9 +-
packages/react-router/lib/hooks.tsx | 5 +-
4 files changed, 53 insertions(+), 140 deletions(-)
diff --git a/packages/react-router/__tests__/dom/handle-error-test.tsx b/packages/react-router/__tests__/dom/handle-error-test.tsx
index 1aa867a500..709bf0b207 100644
--- a/packages/react-router/__tests__/dom/handle-error-test.tsx
+++ b/packages/react-router/__tests__/dom/handle-error-test.tsx
@@ -52,15 +52,8 @@ describe(`handleError`, () => {
await act(() => router.navigate("/page"));
- expect(spy.mock.calls).toEqual([
- [
- new Error("loader error!"),
- {
- location: expect.objectContaining({ pathname: "/page" }),
- errorInfo: undefined,
- },
- ],
- ]);
+ expect(spy).toHaveBeenCalledWith(new Error("loader error!"));
+ expect(spy).toHaveBeenCalledTimes(1);
expect(getHtml(container)).toContain("Error");
});
@@ -92,15 +85,8 @@ describe(`handleError`, () => {
}),
);
- expect(spy.mock.calls).toEqual([
- [
- new Error("action error!"),
- {
- location: expect.objectContaining({ pathname: "/page" }),
- errorInfo: undefined,
- },
- ],
- ]);
+ expect(spy).toHaveBeenCalledWith(new Error("action error!"));
+ expect(spy).toHaveBeenCalledTimes(1);
expect(getHtml(container)).toContain("Error");
});
@@ -125,15 +111,8 @@ describe(`handleError`, () => {
await act(() => router.fetch("key", "0", "/fetch"));
- expect(spy.mock.calls).toEqual([
- [
- new Error("loader error!"),
- {
- location: expect.objectContaining({ pathname: "/" }),
- errorInfo: undefined,
- },
- ],
- ]);
+ expect(spy).toHaveBeenCalledWith(new Error("loader error!"));
+ expect(spy).toHaveBeenCalledTimes(1);
expect(getHtml(container)).toContain("Error");
});
@@ -163,15 +142,8 @@ describe(`handleError`, () => {
}),
);
- expect(spy.mock.calls).toEqual([
- [
- new Error("action error!"),
- {
- location: expect.objectContaining({ pathname: "/" }),
- errorInfo: undefined,
- },
- ],
- ]);
+ expect(spy).toHaveBeenCalledWith(new Error("action error!"));
+ expect(spy).toHaveBeenCalledTimes(1);
expect(getHtml(container)).toContain("Error");
});
@@ -197,17 +169,13 @@ describe(`handleError`, () => {
await act(() => router.navigate("/page"));
- expect(spy.mock.calls).toEqual([
- [
- new Error("render error!"),
- {
- location: expect.objectContaining({ pathname: "/page" }),
- errorInfo: expect.objectContaining({
- componentStack: expect.any(String),
- }),
- },
- ],
- ]);
+ expect(spy).toHaveBeenCalledWith(
+ new Error("render error!"),
+ expect.objectContaining({
+ componentStack: expect.any(String),
+ }),
+ );
+ expect(spy).toHaveBeenCalledTimes(1);
expect(getHtml(container)).toContain("Error");
});
@@ -245,14 +213,8 @@ describe(`handleError`, () => {
await act(() => router.navigate("/page"));
await waitFor(() => screen.getByText("Await Error"));
- expect(spy.mock.calls).toEqual([
- [
- new Error("await error!"),
- {
- location: expect.objectContaining({ pathname: "/page" }),
- },
- ],
- ]);
+ expect(spy).toHaveBeenCalledWith(new Error("await error!"));
+ expect(spy).toHaveBeenCalledTimes(1);
expect(getHtml(container)).toContain("Await Error");
});
@@ -296,17 +258,13 @@ describe(`handleError`, () => {
await act(() => router.navigate("/page"));
await waitFor(() => screen.getByText("Await Error"));
- expect(spy.mock.calls).toEqual([
- [
- new Error("await error!"),
- {
- location: expect.objectContaining({ pathname: "/page" }),
- errorInfo: expect.objectContaining({
- componentStack: expect.any(String),
- }),
- },
- ],
- ]);
+ expect(spy).toHaveBeenCalledWith(
+ new Error("await error!"),
+ expect.objectContaining({
+ componentStack: expect.any(String),
+ }),
+ );
+ expect(spy).toHaveBeenCalledTimes(1);
expect(getHtml(container)).toContain("Await Error");
});
@@ -353,23 +311,14 @@ describe(`handleError`, () => {
await act(() => router.navigate("/page"));
await waitFor(() => screen.getByText("Route Error"));
- expect(spy.mock.calls).toEqual([
- [
- new Error("await error!"),
- {
- location: expect.objectContaining({ pathname: "/page" }),
- },
- ],
- [
- new Error("errorElement error!"),
- {
- location: expect.objectContaining({ pathname: "/page" }),
- errorInfo: expect.objectContaining({
- componentStack: expect.any(String),
- }),
- },
- ],
- ]);
+ expect(spy).toHaveBeenCalledWith(new Error("await error!"));
+ expect(spy).toHaveBeenCalledWith(
+ new Error("errorElement error!"),
+ expect.objectContaining({
+ componentStack: expect.any(String),
+ }),
+ );
+ expect(spy).toHaveBeenCalledTimes(2);
expect(getHtml(container)).toContain("Route Error");
});
@@ -411,15 +360,8 @@ describe(`handleError`, () => {
await act(() => router.navigate("/page"));
- expect(spy.mock.calls).toEqual([
- [
- new Error("loader error!"),
- {
- location: expect.objectContaining({ pathname: "/page" }),
- errorInfo: undefined,
- },
- ],
- ]);
+ expect(spy).toHaveBeenCalledWith(new Error("loader error!"));
+ expect(spy).toHaveBeenCalledTimes(1);
expect(getHtml(container)).toContain("Error");
// Doesn't re-call on a fetcher update from a rendered error boundary
@@ -465,17 +407,13 @@ describe(`handleError`, () => {
await act(() => router.navigate("/page"));
- expect(spy.mock.calls).toEqual([
- [
- new Error("render error!"),
- {
- location: expect.objectContaining({ pathname: "/page" }),
- errorInfo: expect.objectContaining({
- componentStack: expect.any(String),
- }),
- },
- ],
- ]);
+ expect(spy).toHaveBeenCalledWith(
+ new Error("render error!"),
+ expect.objectContaining({
+ componentStack: expect.any(String),
+ }),
+ );
+ expect(spy).toHaveBeenCalledTimes(1);
expect(getHtml(container)).toContain("Error");
// Doesn't re-call on a fetcher update from a rendered error boundary
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index b5210e5ab1..2881bdae83 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -250,13 +250,7 @@ class Deferred {
* and rendering errors via `componentDidCatch`
*/
export interface unstable_ClientHandleErrorFunction {
- (
- error: unknown,
- info: {
- location: Location;
- errorInfo?: React.ErrorInfo;
- },
- ): void;
+ (error: unknown, errorInfo?: React.ErrorInfo): void;
}
/**
@@ -288,12 +282,9 @@ export interface RouterProviderProps {
* and is only present for render errors.
*
* ```tsx
- * {
- * console.log(
- * `Error at location ${location.pathname}`,
- * error,
- * errorInfo
- * );
+ * {
+ * console.error(error, errorInfo);
+ * reportToErrorService(error, errorInfo);
* }} />
* ```
*/
@@ -356,7 +347,7 @@ export function RouterProvider({
if (newState.errors && unstable_handleError) {
Object.entries(newState.errors).forEach(([routeId, error]) => {
if (prevState.errors?.[routeId] !== error) {
- unstable_handleError(error, { location: newState.location });
+ unstable_handleError(error);
}
});
}
@@ -1402,14 +1393,10 @@ export function Await({
resolve,
}: AwaitProps) {
let dataRouterContext = React.useContext(DataRouterContext);
- // Use this instead of useLocation() so that Await can still be used standalone
- // and not inside of a
- let dataRouterStateContext = React.useContext(DataRouterStateContext);
return (
{children}
@@ -1420,7 +1407,6 @@ export function Await({
type AwaitErrorBoundaryProps = React.PropsWithChildren<{
errorElement?: React.ReactNode;
resolve: TrackedPromise | any;
- location?: Location;
unstable_handleError?: unstable_ClientHandleErrorFunction;
}>;
@@ -1448,12 +1434,9 @@ class AwaitErrorBoundary extends React.Component<
}
componentDidCatch(error: any, errorInfo: React.ErrorInfo) {
- if (this.props.unstable_handleError && this.props.location) {
+ if (this.props.unstable_handleError) {
// Log render errors
- this.props.unstable_handleError(error, {
- location: this.props.location,
- errorInfo,
- });
+ this.props.unstable_handleError(error, errorInfo);
} else {
console.error(
" caught the following error during render",
@@ -1500,9 +1483,7 @@ class AwaitErrorBoundary extends React.Component<
Object.defineProperty(resolve, "_data", { get: () => data }),
(error: any) => {
// Log promise rejections
- this.props.unstable_handleError?.(error, {
- location: this.props.location,
- });
+ this.props.unstable_handleError?.(error);
Object.defineProperty(resolve, "_error", { get: () => error });
},
);
diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx
index a980314f62..616a821a18 100644
--- a/packages/react-router/lib/dom-export/hydrated-router.tsx
+++ b/packages/react-router/lib/dom-export/hydrated-router.tsx
@@ -234,12 +234,9 @@ export interface HydratedRouterProps {
* and is only present for render errors.
*
* ```tsx
- * {
- * console.log(
- * `Error at location ${location.pathname}`,
- * error,
- * errorInfo
- * );
+ * {
+ * console.error(error, errorInfo);
+ * reportToErrorService(error, errorInfo);
* }} />
* ```
*/
diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx
index d7884daf93..3364f5c9de 100644
--- a/packages/react-router/lib/hooks.tsx
+++ b/packages/react-router/lib/hooks.tsx
@@ -991,10 +991,7 @@ export class RenderErrorBoundary extends React.Component<
componentDidCatch(error: any, errorInfo: React.ErrorInfo) {
if (this.props.unstable_handleError) {
- this.props.unstable_handleError(error, {
- location: this.props.location,
- errorInfo,
- });
+ this.props.unstable_handleError(error, errorInfo);
} else {
console.error(
"React Router caught the following error during render",
From 8028c8c0391eaf0b809d538ad77ed926e8615265 Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Tue, 12 Aug 2025 16:16:42 -0400
Subject: [PATCH 09/13] Updates
---
.changeset/cold-geese-turn.md | 2 +-
packages/react-router/lib/hooks.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.changeset/cold-geese-turn.md b/.changeset/cold-geese-turn.md
index ac57f25148..7e62303f8c 100644
--- a/.changeset/cold-geese-turn.md
+++ b/.changeset/cold-geese-turn.md
@@ -2,4 +2,4 @@
"react-router": patch
---
-[UNSTABLE] Add `` prop for client side error reporting
+[UNSTABLE] Add ``/`` prop for client side error reporting
diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx
index 3364f5c9de..b1cecda846 100644
--- a/packages/react-router/lib/hooks.tsx
+++ b/packages/react-router/lib/hooks.tsx
@@ -51,7 +51,7 @@ import {
stripBasename,
} from "./router/utils";
import type { SerializeFrom } from "./types/route-data";
-import { unstable_ClientHandleErrorFunction } from "./components";
+import type { unstable_ClientHandleErrorFunction } from "./components";
/**
* Resolves a URL against the current {@link Location}.
From 443d3d900829f1250ec2618eea18fddb61614fe7 Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Tue, 12 Aug 2025 16:47:38 -0400
Subject: [PATCH 10/13] Fix e2e test
---
integration/browser-entry-test.ts | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts
index 6fd6c75ce5..a50b435043 100644
--- a/integration/browser-entry-test.ts
+++ b/integration/browser-entry-test.ts
@@ -145,8 +145,8 @@ test("allows users to pass a handleError function to HydratedRouter", async ({
document,
{
- console.log(error.message, JSON.stringify(errorInfo), location.pathname)
+ unstable_handleError={(error, errorInfo) => {
+ console.log(error.message, JSON.stringify(errorInfo))
}}
/>
@@ -188,7 +188,6 @@ test("allows users to pass a handleError function to HydratedRouter", async ({
// Second one is ours
expect(logs[1]).toContain("Render error");
expect(logs[1]).toContain('"componentStack":');
- expect(logs[1]).toContain("/page");
appFixture.close();
});
From 79e42e4d13d76d0f856ec977e9e7489e4bf0ec8e Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Wed, 13 Aug 2025 12:05:23 -0400
Subject: [PATCH 11/13] Rename to onError
---
.changeset/cold-geese-turn.md | 2 +-
integration/browser-entry-test.ts | 2 +-
...rror-test.tsx => client-on-error-test.tsx} | 20 +++++-----
packages/react-router/index.ts | 2 +-
packages/react-router/lib/components.tsx | 40 +++++++++----------
packages/react-router/lib/context.ts | 4 +-
.../lib/dom-export/hydrated-router.tsx | 10 ++---
packages/react-router/lib/hooks.tsx | 16 ++++----
8 files changed, 48 insertions(+), 48 deletions(-)
rename packages/react-router/__tests__/dom/{handle-error-test.tsx => client-on-error-test.tsx} (93%)
diff --git a/.changeset/cold-geese-turn.md b/.changeset/cold-geese-turn.md
index 7e62303f8c..5a89283859 100644
--- a/.changeset/cold-geese-turn.md
+++ b/.changeset/cold-geese-turn.md
@@ -2,4 +2,4 @@
"react-router": patch
---
-[UNSTABLE] Add ``/`` prop for client side error reporting
+[UNSTABLE] Add ``/`` prop for client side error reporting
diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts
index a50b435043..d662d8c505 100644
--- a/integration/browser-entry-test.ts
+++ b/integration/browser-entry-test.ts
@@ -145,7 +145,7 @@ test("allows users to pass a handleError function to HydratedRouter", async ({
document,
{
+ unstable_onError={(error, errorInfo) => {
console.log(error.message, JSON.stringify(errorInfo))
}}
/>
diff --git a/packages/react-router/__tests__/dom/handle-error-test.tsx b/packages/react-router/__tests__/dom/client-on-error-test.tsx
similarity index 93%
rename from packages/react-router/__tests__/dom/handle-error-test.tsx
rename to packages/react-router/__tests__/dom/client-on-error-test.tsx
index 709bf0b207..405397224c 100644
--- a/packages/react-router/__tests__/dom/handle-error-test.tsx
+++ b/packages/react-router/__tests__/dom/client-on-error-test.tsx
@@ -47,7 +47,7 @@ describe(`handleError`, () => {
]);
let { container } = render(
- ,
+ ,
);
await act(() => router.navigate("/page"));
@@ -75,7 +75,7 @@ describe(`handleError`, () => {
]);
let { container } = render(
- ,
+ ,
);
await act(() =>
@@ -106,7 +106,7 @@ describe(`handleError`, () => {
]);
let { container } = render(
- ,
+ ,
);
await act(() => router.fetch("key", "0", "/fetch"));
@@ -132,7 +132,7 @@ describe(`handleError`, () => {
]);
let { container } = render(
- ,
+ ,
);
await act(() =>
@@ -164,7 +164,7 @@ describe(`handleError`, () => {
]);
let { container } = render(
- ,
+ ,
);
await act(() => router.navigate("/page"));
@@ -207,7 +207,7 @@ describe(`handleError`, () => {
]);
let { container } = render(
- ,
+ ,
);
await act(() => router.navigate("/page"));
@@ -252,7 +252,7 @@ describe(`handleError`, () => {
}
let { container } = render(
- ,
+ ,
);
await act(() => router.navigate("/page"));
@@ -305,7 +305,7 @@ describe(`handleError`, () => {
}
let { container } = render(
- ,
+ ,
);
await act(() => router.navigate("/page"));
@@ -355,7 +355,7 @@ describe(`handleError`, () => {
]);
let { container } = render(
- ,
+ ,
);
await act(() => router.navigate("/page"));
@@ -402,7 +402,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 62c320bb91..b873d7a3d3 100644
--- a/packages/react-router/index.ts
+++ b/packages/react-router/index.ts
@@ -97,7 +97,7 @@ export type {
export type {
AwaitProps,
IndexRouteProps,
- unstable_ClientHandleErrorFunction,
+ unstable_ClientOnErrorFunction,
LayoutRouteProps,
MemoryRouterOpts,
MemoryRouterProps,
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index 2881bdae83..896e3510a4 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -249,7 +249,7 @@ class Deferred {
* Function signature for client side error handling for loader/actions errors
* and rendering errors via `componentDidCatch`
*/
-export interface unstable_ClientHandleErrorFunction {
+export interface unstable_ClientOnErrorFunction {
(error: unknown, errorInfo?: React.ErrorInfo): void;
}
@@ -282,13 +282,13 @@ export interface RouterProviderProps {
* and is only present for render errors.
*
* ```tsx
- * {
+ * {
* console.error(error, errorInfo);
* reportToErrorService(error, errorInfo);
* }} />
* ```
*/
- unstable_handleError?: unstable_ClientHandleErrorFunction;
+ unstable_onError?: unstable_ClientOnErrorFunction;
}
/**
@@ -317,15 +317,15 @@ export interface RouterProviderProps {
* @category Data Routers
* @mode data
* @param props Props
- * @param {RouterProviderProps.flushSync} props.flushSync n/a
* @param {RouterProviderProps.router} props.router n/a
- * @param {RouterProviderProps.unstable_handleError} props.unstable_handleError n/a
+ * @param {RouterProviderProps.flushSync} props.flushSync n/a
+ * @param {RouterProviderProps.unstable_onError} props.unstable_onError n/a
* @returns React element for the rendered router
*/
export function RouterProvider({
router,
flushSync: reactDomFlushSyncImpl,
- unstable_handleError,
+ unstable_onError,
}: RouterProviderProps): React.ReactElement {
let [state, setStateImpl] = React.useState(router.state);
let [pendingState, setPendingState] = React.useState();
@@ -344,17 +344,17 @@ export function RouterProvider({
(newState: RouterState) => {
setStateImpl((prevState) => {
// Send loader/action errors through handleError
- if (newState.errors && unstable_handleError) {
+ if (newState.errors && unstable_onError) {
Object.entries(newState.errors).forEach(([routeId, error]) => {
if (prevState.errors?.[routeId] !== error) {
- unstable_handleError(error);
+ unstable_onError(error);
}
});
}
return newState;
});
},
- [unstable_handleError],
+ [unstable_onError],
);
let setState = React.useCallback(
@@ -556,9 +556,9 @@ export function RouterProvider({
navigator,
static: false,
basename,
- unstable_handleError,
+ unstable_onError,
}),
- [router, navigator, basename, unstable_handleError],
+ [router, navigator, basename, unstable_onError],
);
// The fragment and {null} here are important! We need them to keep React 18's
@@ -583,7 +583,7 @@ export function RouterProvider({
routes={router.routes}
future={router.future}
state={state}
- unstable_handleError={unstable_handleError}
+ unstable_onError={unstable_onError}
/>
@@ -602,14 +602,14 @@ function DataRoutes({
routes,
future,
state,
- unstable_handleError,
+ unstable_onError,
}: {
routes: DataRouteObject[];
future: DataRouter["future"];
state: RouterState;
- unstable_handleError: unstable_ClientHandleErrorFunction | undefined;
+ unstable_onError: unstable_ClientOnErrorFunction | undefined;
}): React.ReactElement | null {
- return useRoutesImpl(routes, undefined, state, unstable_handleError, future);
+ return useRoutesImpl(routes, undefined, state, unstable_onError, future);
}
/**
@@ -1397,7 +1397,7 @@ export function Await({
{children}
@@ -1407,7 +1407,7 @@ export function Await({
type AwaitErrorBoundaryProps = React.PropsWithChildren<{
errorElement?: React.ReactNode;
resolve: TrackedPromise | any;
- unstable_handleError?: unstable_ClientHandleErrorFunction;
+ unstable_onError?: unstable_ClientOnErrorFunction;
}>;
type AwaitErrorBoundaryState = {
@@ -1434,9 +1434,9 @@ class AwaitErrorBoundary extends React.Component<
}
componentDidCatch(error: any, errorInfo: React.ErrorInfo) {
- if (this.props.unstable_handleError) {
+ if (this.props.unstable_onError) {
// Log render errors
- this.props.unstable_handleError(error, errorInfo);
+ this.props.unstable_onError(error, errorInfo);
} else {
console.error(
" caught the following error during render",
@@ -1483,7 +1483,7 @@ class AwaitErrorBoundary extends React.Component<
Object.defineProperty(resolve, "_data", { get: () => data }),
(error: any) => {
// Log promise rejections
- this.props.unstable_handleError?.(error);
+ this.props.unstable_onError?.(error);
Object.defineProperty(resolve, "_error", { get: () => error });
},
);
diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts
index 9be3dad8b2..5c1e09c425 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_ClientHandleErrorFunction } from "./components";
+import type { unstable_ClientOnErrorFunction } from "./components";
import type {
History,
Location,
@@ -91,7 +91,7 @@ export interface DataRouterContextObject
extends Omit {
router: Router;
staticContext?: StaticHandlerContext;
- unstable_handleError?: unstable_ClientHandleErrorFunction;
+ unstable_onError?: unstable_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 616a821a18..f7b1df4dbf 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_ClientHandleErrorFunction,
+ unstable_ClientOnErrorFunction,
} from "react-router";
import {
UNSAFE_getHydrationData as getHydrationData,
@@ -234,13 +234,13 @@ export interface HydratedRouterProps {
* and is only present for render errors.
*
* ```tsx
- * {
+ * {
* console.error(error, errorInfo);
* reportToErrorService(error, errorInfo);
* }} />
* ```
*/
- unstable_handleError?: unstable_ClientHandleErrorFunction;
+ unstable_onError?: unstable_ClientOnErrorFunction;
}
/**
@@ -252,7 +252,7 @@ export interface HydratedRouterProps {
* @mode framework
* @param props Props
* @param {dom.HydratedRouterProps.unstable_getContext} props.unstable_getContext n/a
- * @param {dom.HydratedRouterProps.unstable_handleError} props.unstable_handleError n/a
+ * @param {dom.HydratedRouterProps.unstable_onError} props.unstable_onError n/a
* @returns A React element that represents the hydrated application.
*/
export function HydratedRouter(props: HydratedRouterProps) {
@@ -346,7 +346,7 @@ export function HydratedRouter(props: HydratedRouterProps) {
diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx
index b1cecda846..ce264d853b 100644
--- a/packages/react-router/lib/hooks.tsx
+++ b/packages/react-router/lib/hooks.tsx
@@ -51,7 +51,7 @@ import {
stripBasename,
} from "./router/utils";
import type { SerializeFrom } from "./types/route-data";
-import type { unstable_ClientHandleErrorFunction } from "./components";
+import type { unstable_ClientOnErrorFunction } from "./components";
/**
* Resolves a URL against the current {@link Location}.
@@ -708,7 +708,7 @@ export function useRoutesImpl(
routes: RouteObject[],
locationArg?: Partial | string,
dataRouterState?: DataRouter["state"],
- unstable_handleError?: unstable_ClientHandleErrorFunction,
+ unstable_onError?: unstable_ClientOnErrorFunction,
future?: DataRouter["future"],
): React.ReactElement | null {
invariant(
@@ -850,7 +850,7 @@ export function useRoutesImpl(
),
parentMatches,
dataRouterState,
- unstable_handleError,
+ unstable_onError,
future,
);
@@ -929,7 +929,7 @@ type RenderErrorBoundaryProps = React.PropsWithChildren<{
error: any;
component: React.ReactNode;
routeContext: RouteContextObject;
- unstable_handleError: unstable_ClientHandleErrorFunction | null;
+ unstable_onError: unstable_ClientOnErrorFunction | null;
}>;
type RenderErrorBoundaryState = {
@@ -990,8 +990,8 @@ export class RenderErrorBoundary extends React.Component<
}
componentDidCatch(error: any, errorInfo: React.ErrorInfo) {
- if (this.props.unstable_handleError) {
- this.props.unstable_handleError(error, errorInfo);
+ if (this.props.unstable_onError) {
+ this.props.unstable_onError(error, errorInfo);
} else {
console.error(
"React Router caught the following error during render",
@@ -1045,7 +1045,7 @@ export function _renderMatches(
matches: RouteMatch[] | null,
parentMatches: RouteMatch[] = [],
dataRouterState: DataRouter["state"] | null = null,
- unstable_handleError: unstable_ClientHandleErrorFunction | null = null,
+ unstable_onError: unstable_ClientOnErrorFunction | null = null,
future: DataRouter["future"] | null = null,
): React.ReactElement | null {
if (matches == null) {
@@ -1202,7 +1202,7 @@ export function _renderMatches(
error={error}
children={getChildren()}
routeContext={{ outlet: null, matches, isDataRoute: true }}
- unstable_handleError={unstable_handleError}
+ unstable_onError={unstable_onError}
/>
) : (
getChildren()
From 23b3f977a018308e38486303a88de8b00e95da9e Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Wed, 13 Aug 2025 14:03:26 -0400
Subject: [PATCH 12/13] fix firefox e2e test
---
integration/browser-entry-test.ts | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts
index d662d8c505..ae4e831296 100644
--- a/integration/browser-entry-test.ts
+++ b/integration/browser-entry-test.ts
@@ -130,8 +130,9 @@ test("allows users to pass a client side context to HydratedRouter", async ({
appFixture.close();
});
-test("allows users to pass a handleError function to HydratedRouter", async ({
+test("allows users to pass an onError function to HydratedRouter", async ({
page,
+ browserName,
}) => {
let fixture = await createFixture({
files: {
@@ -183,7 +184,11 @@ test("allows users to pass a handleError function to HydratedRouter", async ({
expect(await app.getHtml()).toContain("Error: Render error");
expect(logs.length).toBe(2);
// First one is react logging the error
- expect(logs[0]).toContain("Error: Render error");
+ if (browserName === "firefox") {
+ expect(logs[0]).toContain("Error");
+ } else {
+ expect(logs[0]).toContain("Error: Render error");
+ }
expect(logs[0]).not.toContain("componentStack");
// Second one is ours
expect(logs[1]).toContain("Render error");
From 3d7dcee242dcf2b190f1bb3ca3727f54580398a7 Mon Sep 17 00:00:00 2001
From: Matt Brophy
Date: Fri, 15 Aug 2025 15:00:52 -0400
Subject: [PATCH 13/13] Update packages/react-router/lib/components.tsx
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Michaƫl De Boey
---
packages/react-router/lib/components.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx
index 896e3510a4..c2c7e733c9 100644
--- a/packages/react-router/lib/components.tsx
+++ b/packages/react-router/lib/components.tsx
@@ -317,9 +317,9 @@ export interface RouterProviderProps {
* @category Data Routers
* @mode data
* @param props Props
- * @param {RouterProviderProps.router} props.router n/a
* @param {RouterProviderProps.flushSync} props.flushSync n/a
* @param {RouterProviderProps.unstable_onError} props.unstable_onError n/a
+ * @param {RouterProviderProps.router} props.router n/a
* @returns React element for the rendered router
*/
export function RouterProvider({