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>(new Map()); + let logErrorsAndSetState = React.useCallback( + (newState: RouterState) => { + setStateImpl((prevState) => { + // Send loader/action errors through handleError + if (newState.errors && unstable_handleError) { + Object.entries(newState.errors).forEach(([routeId, error]) => { + if (prevState.errors?.[routeId] !== error) { + unstable_handleError(error, { location: newState.location }); + } + }); + } + return newState; + }); + }, + [unstable_handleError], + ); let setState = React.useCallback( ( @@ -377,9 +416,9 @@ export function RouterProvider({ // just update and be done with it if (!viewTransitionOpts || !isViewTransitionAvailable) { if (reactDomFlushSyncImpl && flushSync) { - reactDomFlushSyncImpl(() => setStateImpl(newState)); + reactDomFlushSyncImpl(() => logErrorsAndSetState(newState)); } else { - React.startTransition(() => setStateImpl(newState)); + React.startTransition(() => logErrorsAndSetState(newState)); } return; } @@ -403,7 +442,7 @@ export function RouterProvider({ // Update the DOM let t = router.window!.document.startViewTransition(() => { - reactDomFlushSyncImpl(() => setStateImpl(newState)); + reactDomFlushSyncImpl(() => logErrorsAndSetState(newState)); }); // Clean up after the animation completes @@ -442,7 +481,13 @@ export function RouterProvider({ }); } }, - [router.window, reactDomFlushSyncImpl, transition, renderDfd], + [ + router.window, + reactDomFlushSyncImpl, + transition, + renderDfd, + logErrorsAndSetState, + ], ); // Need to use a layout effect here so we are subscribed early enough to @@ -465,7 +510,7 @@ export function RouterProvider({ let newState = pendingState; let renderPromise = renderDfd.promise; let transition = router.window.document.startViewTransition(async () => { - React.startTransition(() => setStateImpl(newState)); + React.startTransition(() => logErrorsAndSetState(newState)); await renderPromise; }); transition.finished.finally(() => { @@ -476,7 +521,7 @@ export function RouterProvider({ }); setTransition(transition); } - }, [pendingState, renderDfd, router.window]); + }, [pendingState, renderDfd, router.window, logErrorsAndSetState]); // When the new location finally renders and is committed to the DOM, this // effect will run to resolve the transition @@ -558,7 +603,7 @@ export function RouterProvider({ routes={router.routes} future={router.future} state={state} - unstable_handleError={router._internalHandleError} + unstable_handleError={unstable_handleError} /> diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 712f607fcb..33226ec081 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -350,7 +350,10 @@ export function HydratedRouter(props: HydratedRouterProps) { }} > - + {/* diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 5158c8510e..c5c82e681c 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1239,13 +1239,6 @@ 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 From 01e9b6535e40141a34b9dd5661e642d7ebc684f1 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 12 Aug 2025 14:57:45 -0400 Subject: [PATCH 04/13] Remove implementation from createRouter --- packages/react-router/index.ts | 2 +- packages/react-router/lib/components.tsx | 40 +++++++------------ .../lib/dom-export/hydrated-router.tsx | 7 +--- packages/react-router/lib/dom/lib.tsx | 28 ------------- packages/react-router/lib/dom/server.tsx | 3 -- packages/react-router/lib/hooks.tsx | 2 +- packages/react-router/lib/router/router.ts | 25 ------------ 7 files changed, 18 insertions(+), 89 deletions(-) diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index beea213601..1008db9ea9 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -12,7 +12,6 @@ export type { StaticHandler, GetScrollPositionFunction, GetScrollRestorationKeyFunction, - unstable_HandleErrorFunction, StaticHandlerContext, Fetcher, Navigation, @@ -98,6 +97,7 @@ export type { export type { AwaitProps, IndexRouteProps, + unstable_HandleErrorFunction, LayoutRouteProps, MemoryRouterOpts, MemoryRouterProps, diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 2f839e89f6..80bb2e9048 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -21,7 +21,6 @@ import type { RouterState, RouterSubscriber, RouterInit, - unstable_HandleErrorFunction, } from "./router/router"; import { createRouter } from "./router/router"; import type { @@ -178,29 +177,6 @@ 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; } /** @@ -217,7 +193,6 @@ 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 @@ -236,7 +211,6 @@ export function createMemoryRouter( initialEntries: opts?.initialEntries, initialIndex: opts?.initialIndex, }), - unstable_handleError: opts?.unstable_handleError, hydrationData: opts?.hydrationData, routes, hydrationRouteProperties, @@ -271,6 +245,20 @@ class Deferred { } } +/** + * 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; +} + /** * @category Types */ diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index 33226ec081..fb76e50679 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -6,6 +6,7 @@ import type { DataRouter, HydrationState, RouterInit, + unstable_HandleErrorFunction, } from "react-router"; import { UNSAFE_getHydrationData as getHydrationData, @@ -77,10 +78,8 @@ function initSsrInfo(): void { function createHydratedRouter({ unstable_getContext, - unstable_handleError, }: { unstable_getContext?: RouterInit["unstable_getContext"]; - unstable_handleError?: RouterInit["unstable_handleError"]; }): DataRouter { initSsrInfo(); @@ -171,7 +170,6 @@ function createHydratedRouter({ history: createBrowserHistory(), basename: ssrInfo.context.basename, unstable_getContext, - unstable_handleError, hydrationData, hydrationRouteProperties, mapRouteProperties, @@ -245,7 +243,7 @@ export interface HydratedRouterProps { * }} /> * ``` */ - unstable_handleError?: RouterInit["unstable_handleError"]; + unstable_handleError?: unstable_HandleErrorFunction; } /** @@ -264,7 +262,6 @@ export function HydratedRouter(props: HydratedRouterProps) { if (!router) { router = createHydratedRouter({ unstable_getContext: props.unstable_getContext, - unstable_handleError: props.unstable_handleError, }); } diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index 8ed5006ca1..4873b298a7 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -24,7 +24,6 @@ import type { RelativeRoutingType, Router as DataRouter, RouterInit, - unstable_HandleErrorFunction, } from "../router/router"; import { IDLE_FETCHER, createRouter } from "../router/router"; import type { @@ -720,29 +719,6 @@ 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. @@ -764,7 +740,6 @@ 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 @@ -779,7 +754,6 @@ 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, @@ -802,7 +776,6 @@ 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 @@ -818,7 +791,6 @@ 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 ae1c5b7dbf..b294433999 100644 --- a/packages/react-router/lib/dom/server.tsx +++ b/packages/react-router/lib/dom/server.tsx @@ -484,9 +484,6 @@ 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 441a09cd32..0ef46df260 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -30,7 +30,6 @@ import type { Router as DataRouter, RevalidationState, Navigation, - unstable_HandleErrorFunction, } from "./router/router"; import { IDLE_BLOCKER } from "./router/router"; import type { @@ -52,6 +51,7 @@ import { stripBasename, } from "./router/utils"; import type { SerializeFrom } from "./types/route-data"; +import { unstable_HandleErrorFunction } from "./components"; /** * Resolves a URL against the current {@link Location}. diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index c5c82e681c..7a15cccbde 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -271,14 +271,6 @@ 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 @@ -413,7 +405,6 @@ export interface RouterInit { window?: Window; dataStrategy?: DataStrategyFunction; patchRoutesOnNavigation?: AgnosticPatchRoutesOnNavigationFunction; - unstable_handleError?: unstable_HandleErrorFunction; } /** @@ -482,20 +473,6 @@ 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 @@ -880,7 +857,6 @@ 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 = {}; @@ -3475,7 +3451,6 @@ 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 39e00091c711e59934b40d5cc9d00b3c8fa05576 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 12 Aug 2025 15:00:20 -0400 Subject: [PATCH 05/13] Cleanup --- .changeset/cold-geese-turn.md | 2 +- packages/react-router/lib/router/router.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.changeset/cold-geese-turn.md b/.changeset/cold-geese-turn.md index c6ff4d1329..ac57f25148 100644 --- a/.changeset/cold-geese-turn.md +++ b/.changeset/cold-geese-turn.md @@ -2,4 +2,4 @@ "react-router": patch --- -[UNSTABLE] Add support for `unstable_handleError` for client side data routers +[UNSTABLE] Add `` prop for client side error reporting diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 7a15cccbde..09e03a69bd 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -1,5 +1,3 @@ -import type * as React from "react"; - import type { DataRouteMatch, RouteObject } from "../context"; import type { History, Location, Path, To } from "./history"; import { From 7f2b363220d54c009c107ea3c4b6ebea4dba2c68 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 12 Aug 2025 15:57:10 -0400 Subject: [PATCH 06/13] Add support for Await --- .../__tests__/dom/handle-error-test.tsx | 184 +++++++++++++++++- packages/react-router/lib/components.tsx | 45 ++++- packages/react-router/lib/context.ts | 4 +- 3 files changed, 217 insertions(+), 16 deletions(-) diff --git a/packages/react-router/__tests__/dom/handle-error-test.tsx b/packages/react-router/__tests__/dom/handle-error-test.tsx index ecc1f2f34f..1aa867a500 100644 --- a/packages/react-router/__tests__/dom/handle-error-test.tsx +++ b/packages/react-router/__tests__/dom/handle-error-test.tsx @@ -1,10 +1,22 @@ -import { act, fireEvent, render, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import * as React from "react"; -import { RouterProvider, createMemoryRouter, useFetcher } from "../../index"; +import { + Await, + RouterProvider, + createMemoryRouter, + useFetcher, + useLoaderData, +} from "../../index"; -import getHtml from "../utils/getHtml"; import { createFormData } from "../router/utils/utils"; +import getHtml from "../utils/getHtml"; describe(`handleError`, () => { let consoleError: jest.SpyInstance; @@ -199,6 +211,168 @@ describe(`handleError`, () => { expect(getHtml(container)).toContain("Error"); }); + it("handles deferred data rejections from ", 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!")), 1), + ), + }; + }, + Component() { + let data = useLoaderData(); + return ( + Await Error}> + {() =>

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" }), + }, + ], + ]); + expect(getHtml(container)).toContain("Await Error"); + }); + + it("handles render errors from Await components", async () => { + let spy = jest.fn(); + let router = createMemoryRouter([ + { + path: "/", + Component: () =>

Home

, + }, + { + path: "/page", + loader() { + return { + promise: new Promise((r) => setTimeout(() => r("data"), 10)), + }; + }, + Component() { + let data = useLoaderData(); + return ( + Loading...

}> + 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({