From 4a0ca636ef19c360aeba892bc4028b51281df171 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 14 Nov 2025 11:46:26 -0500 Subject: [PATCH 1/2] Stabilize client-side onError --- .changeset/small-flowers-drive.md | 5 +++ integration/browser-entry-test.ts | 2 +- .../__tests__/dom/client-on-error-test.tsx | 30 +++++++-------- packages/react-router/lib/components.tsx | 38 +++++++++---------- packages/react-router/lib/context.ts | 2 +- .../lib/dom-export/hydrated-router.tsx | 11 ++---- packages/react-router/lib/hooks.tsx | 10 ++--- 7 files changed, 50 insertions(+), 48 deletions(-) create mode 100644 .changeset/small-flowers-drive.md diff --git a/.changeset/small-flowers-drive.md b/.changeset/small-flowers-drive.md new file mode 100644 index 0000000000..558916e84c --- /dev/null +++ b/.changeset/small-flowers-drive.md @@ -0,0 +1,5 @@ +--- +"react-router": minor +--- + +Stabilize ``/`` diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts index 5171b7bd93..ad984fd43d 100644 --- a/integration/browser-entry-test.ts +++ b/integration/browser-entry-test.ts @@ -146,7 +146,7 @@ test("allows users to pass an onError function to HydratedRouter", async ({ document, { + onError={(error, errorInfo) => { console.log(error.message, JSON.stringify(errorInfo)) }} /> diff --git a/packages/react-router/__tests__/dom/client-on-error-test.tsx b/packages/react-router/__tests__/dom/client-on-error-test.tsx index 9a26bded73..24577fe0e9 100644 --- a/packages/react-router/__tests__/dom/client-on-error-test.tsx +++ b/packages/react-router/__tests__/dom/client-on-error-test.tsx @@ -44,7 +44,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await waitFor(() => screen.getByText("lazy error!")); @@ -74,7 +74,7 @@ describe(`handleError`, () => { }, ]); - render(); + render(); await waitFor(() => screen.getByText("Error:middleware error!")); @@ -102,7 +102,7 @@ describe(`handleError`, () => { }, ]); - render(); + render(); await waitFor(() => screen.getByText("Error:loader error!")); @@ -131,7 +131,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -166,7 +166,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -197,7 +197,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -228,7 +228,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => @@ -262,7 +262,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.fetch("key", "0", "/fetch")); @@ -291,7 +291,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => @@ -326,7 +326,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -370,7 +370,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -418,7 +418,7 @@ describe(`handleError`, () => { } let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -472,7 +472,7 @@ describe(`handleError`, () => { } let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -526,7 +526,7 @@ describe(`handleError`, () => { ]); let { container } = render( - , + , ); await act(() => router.navigate("/page")); @@ -576,7 +576,7 @@ describe(`handleError`, () => { ]); 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 4be51e5cc5..b5a5ba0dc9 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -347,13 +347,13 @@ export interface RouterProviderProps { * and is only present for render errors. * * ```tsx - * { + * { * console.error(error, errorInfo); * reportToErrorService(error, errorInfo); * }} /> * ``` */ - unstable_onError?: unstable_ClientOnErrorFunction; + onError?: unstable_ClientOnErrorFunction; } function shallowDiff(a: any, b: any) { @@ -376,7 +376,7 @@ function shallowDiff(a: any, b: any) { export function UNSTABLE_TransitionEnabledRouterProvider({ router, flushSync: reactDomFlushSyncImpl, - unstable_onError, + onError, }: RouterProviderProps) { let fetcherData = React.useRef>(new Map()); let [revalidating, startRevalidation] = React.useTransition(); @@ -420,9 +420,9 @@ export function UNSTABLE_TransitionEnabledRouterProvider({ navigator, static: false, basename, - unstable_onError, + onError, }), - [router, navigator, basename, unstable_onError], + [router, navigator, basename, onError], ); React.useLayoutEffect(() => { @@ -481,7 +481,7 @@ export function UNSTABLE_TransitionEnabledRouterProvider({ routes={router.routes} future={router.future} state={state} - unstable_onError={unstable_onError} + onError={onError} /> {/* */} @@ -520,14 +520,14 @@ export function UNSTABLE_TransitionEnabledRouterProvider({ * @mode data * @param props Props * @param {RouterProviderProps.flushSync} props.flushSync n/a - * @param {RouterProviderProps.unstable_onError} props.unstable_onError n/a + * @param {RouterProviderProps.onError} props.onError n/a * @param {RouterProviderProps.router} props.router n/a * @returns React element for the rendered router */ export function RouterProvider({ router, flushSync: reactDomFlushSyncImpl, - unstable_onError, + onError, }: RouterProviderProps): React.ReactElement { let [state, setStateImpl] = React.useState(router.state); let [pendingState, setPendingState] = React.useState(); @@ -546,10 +546,10 @@ export function RouterProvider({ (newState: RouterState) => { setStateImpl((prevState) => { // Send loader/action errors through handleError - if (newState.errors && unstable_onError) { + if (newState.errors && onError) { Object.entries(newState.errors).forEach(([routeId, error]) => { if (prevState.errors?.[routeId] !== error) { - unstable_onError(error, { + onError(error, { location: newState.location, params: newState.matches[0]?.params ?? {}, }); @@ -559,7 +559,7 @@ export function RouterProvider({ return newState; }); }, - [unstable_onError], + [onError], ); let setState = React.useCallback( @@ -761,9 +761,9 @@ export function RouterProvider({ navigator, static: false, basename, - unstable_onError, + onError, }), - [router, navigator, basename, unstable_onError], + [router, navigator, basename, onError], ); // The fragment and {null} here are important! We need them to keep React 18's @@ -788,7 +788,7 @@ export function RouterProvider({ routes={router.routes} future={router.future} state={state} - unstable_onError={unstable_onError} + onError={onError} /> @@ -807,14 +807,14 @@ function DataRoutes({ routes, future, state, - unstable_onError, + onError, }: { routes: DataRouteObject[]; future: DataRouter["future"]; state: RouterState; - unstable_onError: unstable_ClientOnErrorFunction | undefined; + onError: unstable_ClientOnErrorFunction | undefined; }): React.ReactElement | null { - return useRoutesImpl(routes, undefined, state, unstable_onError, future); + return useRoutesImpl(routes, undefined, state, onError, future); } /** @@ -1614,10 +1614,10 @@ export function Await({ (error: unknown, errorInfo?: React.ErrorInfo) => { if ( dataRouterContext && - dataRouterContext.unstable_onError && + dataRouterContext.onError && dataRouterStateContext ) { - dataRouterContext.unstable_onError(error, { + dataRouterContext.onError(error, { location: dataRouterStateContext.location, params: dataRouterStateContext.matches?.[0]?.params || {}, errorInfo, diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index fa00d6c6a2..bde1001c36 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -91,7 +91,7 @@ export interface DataRouterContextObject extends Omit { router: Router; staticContext?: StaticHandlerContext; - unstable_onError?: unstable_ClientOnErrorFunction; + 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 ba2c81012c..7aaf6749a4 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -291,13 +291,13 @@ export interface HydratedRouterProps { * and is only present for render errors. * * ```tsx - * { + * { * console.error(error, errorInfo); * reportToErrorService(error, errorInfo); * }} /> * ``` */ - unstable_onError?: unstable_ClientOnErrorFunction; + onError?: unstable_ClientOnErrorFunction; } /** @@ -309,7 +309,7 @@ export interface HydratedRouterProps { * @mode framework * @param props Props * @param {dom.HydratedRouterProps.getContext} props.getContext n/a - * @param {dom.HydratedRouterProps.unstable_onError} props.unstable_onError n/a + * @param {dom.HydratedRouterProps.onError} props.onError n/a * @returns A React element that represents the hydrated application. */ export function HydratedRouter(props: HydratedRouterProps) { @@ -402,10 +402,7 @@ export function HydratedRouter(props: HydratedRouterProps) { }} > - + {/* diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index e240287d2c..25b701ac8b 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -754,7 +754,7 @@ export function useRoutesImpl( routes: RouteObject[], locationArg?: Partial | string, dataRouterState?: DataRouter["state"], - unstable_onError?: unstable_ClientOnErrorFunction, + onError?: unstable_ClientOnErrorFunction, future?: DataRouter["future"], ): React.ReactElement | null { invariant( @@ -908,7 +908,7 @@ export function useRoutesImpl( ), parentMatches, dataRouterState, - unstable_onError, + onError, future, ); @@ -1103,7 +1103,7 @@ export function _renderMatches( matches: RouteMatch[] | null, parentMatches: RouteMatch[] = [], dataRouterState: DataRouter["state"] | null = null, - unstable_onError: unstable_ClientOnErrorFunction | null = null, + onErrorHandler: unstable_ClientOnErrorFunction | null = null, future: DataRouter["future"] | null = null, ): React.ReactElement | null { if (matches == null) { @@ -1187,9 +1187,9 @@ export function _renderMatches( } let onError = - dataRouterState && unstable_onError + dataRouterState && onErrorHandler ? (error: unknown, errorInfo?: React.ErrorInfo) => { - unstable_onError(error, { + onErrorHandler(error, { location: dataRouterState.location, params: dataRouterState.matches?.[0]?.params ?? {}, errorInfo, From 14884d65839c0dbc0607980f1453c0a404003f03 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 19 Nov 2025 12:25:48 -0500 Subject: [PATCH 2/2] Refactor how router errors are reported to avoid strict mode issues --- packages/react-router/lib/components.tsx | 49 ++++++++-------------- packages/react-router/lib/router/router.ts | 2 + 2 files changed, 19 insertions(+), 32 deletions(-) diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index b5a5ba0dc9..b8c43b14b3 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -542,31 +542,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 && onError) { - Object.entries(newState.errors).forEach(([routeId, error]) => { - if (prevState.errors?.[routeId] !== error) { - onError(error, { - location: newState.location, - params: newState.matches[0]?.params ?? {}, - }); - } - }); - } - return newState; - }); - }, - [onError], - ); let setState = React.useCallback( ( newState: RouterState, - { deletedFetchers, flushSync, viewTransitionOpts }, + { deletedFetchers, newErrors, flushSync, viewTransitionOpts }, ) => { + // Send router errors through onError + if (newErrors && onError) { + Object.values(newErrors).forEach((error) => + onError(error, { + location: newState.location, + params: newState.matches[0]?.params ?? {}, + }), + ); + } + newState.fetchers.forEach((fetcher, key) => { if (fetcher.data !== undefined) { fetcherData.current.set(key, fetcher.data); @@ -600,9 +591,9 @@ export function RouterProvider({ // just update and be done with it if (!viewTransitionOpts || !isViewTransitionAvailable) { if (reactDomFlushSyncImpl && flushSync) { - reactDomFlushSyncImpl(() => logErrorsAndSetState(newState)); + reactDomFlushSyncImpl(() => setStateImpl(newState)); } else { - React.startTransition(() => logErrorsAndSetState(newState)); + React.startTransition(() => setStateImpl(newState)); } return; } @@ -626,7 +617,7 @@ export function RouterProvider({ // Update the DOM let t = router.window!.document.startViewTransition(() => { - reactDomFlushSyncImpl(() => logErrorsAndSetState(newState)); + reactDomFlushSyncImpl(() => setStateImpl(newState)); }); // Clean up after the animation completes @@ -665,13 +656,7 @@ export function RouterProvider({ }); } }, - [ - router.window, - reactDomFlushSyncImpl, - transition, - renderDfd, - logErrorsAndSetState, - ], + [router.window, reactDomFlushSyncImpl, transition, renderDfd, onError], ); // Need to use a layout effect here so we are subscribed early enough to @@ -694,7 +679,7 @@ export function RouterProvider({ let newState = pendingState; let renderPromise = renderDfd.promise; let transition = router.window.document.startViewTransition(async () => { - React.startTransition(() => logErrorsAndSetState(newState)); + React.startTransition(() => setStateImpl(newState)); await renderPromise; }); transition.finished.finally(() => { @@ -705,7 +690,7 @@ export function RouterProvider({ }); setTransition(transition); } - }, [pendingState, renderDfd, router.window, logErrorsAndSetState]); + }, [pendingState, renderDfd, router.window]); // When the new location finally renders and is committed to the DOM, this // effect will run to resolve the transition diff --git a/packages/react-router/lib/router/router.ts b/packages/react-router/lib/router/router.ts index 23155b7582..821bbaacbf 100644 --- a/packages/react-router/lib/router/router.ts +++ b/packages/react-router/lib/router/router.ts @@ -489,6 +489,7 @@ export interface RouterSubscriber { state: RouterState, opts: { deletedFetchers: string[]; + newErrors: RouteData | null; viewTransitionOpts?: ViewTransitionOpts; flushSync: boolean; }, @@ -1292,6 +1293,7 @@ export function createRouter(init: RouterInit): Router { [...subscribers].forEach((subscriber) => subscriber(state, { deletedFetchers: unmountedFetchers, + newErrors: newState.errors ?? null, viewTransitionOpts: opts.viewTransitionOpts, flushSync: opts.flushSync === true, }),