From 645f846c73aa20fa34fa5aaac94a00a18d0f43b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Mon, 11 May 2026 05:22:27 -0700 Subject: [PATCH] Re-throw event listener errors in a new task (#56760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56760 Aligns error handling in the new EventTarget-based event dispatch path with the legacy plugin path, which surfaces handler errors to the host's global error handler (rather than swallowing them as `console.error`). Previously, when a React event handler threw, `EventTarget.invoke` caught the error and called `console.error(error)` — the error never reached the host's error reporter, so it was effectively silent in production builds that intercept `console.error`. This diff replaces the `console.error` calls in `EventTarget.invoke` with a small `reportListenerError` helper that schedules the error to be re-thrown in a new task via `setTimeout(0)`. The throw has no catcher above it, so the host's unhandled-error reporter sees it — matching the legacy plugin path's `runEventsInBatch` + `rethrowCaughtError` behavior of propagating the first listener error after the dispatch batch completes. The dispatch loop itself continues normally so subsequent listeners (e.g. parent bubble handlers) still fire. Updates the corresponding test in `EventTargetDispatching-itest.js` to drop the `console.error` mock setup that was specific to the old behavior. Both code paths now satisfy the same assertion (`expect(dispatch).toThrow('handler error')`) — the legacy path throws synchronously after the React batch; the new path's async re-throw surfaces inside Fantom's internal work-loop pump during `Fantom.dispatchNativeEvent`. Changelog: [Internal] Reviewed By: javache Differential Revision: D104650049 fbshipit-source-id: 793072f82c9abf11f4fad23b3c1f044f0e5d2936 --- .../__tests__/EventTargetDispatching-itest.js | 28 +------------------ .../private/webapis/dom/events/EventTarget.js | 22 ++++++++++++--- 2 files changed, 19 insertions(+), 31 deletions(-) diff --git a/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js b/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js index 4c230499c4a..db59ab3fa1e 100644 --- a/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js +++ b/packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js @@ -1322,21 +1322,6 @@ const {isOSS} = Fantom.getConstants(); }); describe('error handling', () => { - let originalConsoleError: typeof console.error; - let mockConsoleError: JestMockFn<$FlowFixMe, $FlowFixMe>; - - beforeEach(() => { - originalConsoleError = console.error; - mockConsoleError = jest.fn(); - // $FlowFixMe[cannot-write] - console.error = mockConsoleError; - }); - - afterEach(() => { - // $FlowFixMe[cannot-write] - console.error = originalConsoleError; - }); - it('error in event handler does not break dispatch to subsequent listeners', () => { const root = Fantom.createRoot(); const childRef = React.createRef>(); @@ -1365,18 +1350,7 @@ const {isOSS} = Fantom.getConstants(); }, ); - if (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()) { - // EventTarget-style dispatch catches per-listener errors and - // reports them via `console.error` (see `EventTarget.js`), so the - // dispatch itself does not throw. - dispatch(); - expect(mockConsoleError).toHaveBeenCalled(); - } else { - // Legacy dispatch surfaces the first per-handler error via - // Fantom's global handler, which re-throws synchronously after - // dispatch completes. - expect(dispatch).toThrow('handler error'); - } + expect(dispatch).toThrow('handler error'); // The parent bubble handler should still fire despite child's error expect(parentHandler).toHaveBeenCalledTimes(1); diff --git a/packages/react-native/src/private/webapis/dom/events/EventTarget.js b/packages/react-native/src/private/webapis/dom/events/EventTarget.js index 75071bf8e73..f6dd2bcc607 100644 --- a/packages/react-native/src/private/webapis/dom/events/EventTarget.js +++ b/packages/react-native/src/private/webapis/dom/events/EventTarget.js @@ -382,8 +382,7 @@ function invoke( try { propListener.call(eventTarget, event); } catch (error) { - // TODO: replace with `reportError` when it's available. - console.error(error); + reportListenerError(error); } global.event = currentEvent; return; @@ -452,8 +451,7 @@ function invokeListeners( callback.handleEvent(event); } } catch (error) { - // TODO: replace with `reportError` when it's available. - console.error(error); + reportListenerError(error); } if (listener.passive) { @@ -507,3 +505,19 @@ function setEventDispatchFlag(event: Event, value: boolean): void { // $FlowExpectedError[prop-missing] event[EVENT_DISPATCH_FLAG] = value; } + +/** + * Surface a listener error to the global error handler without aborting the + * rest of the dispatch. Throws in a new task so the error becomes an + * uncaught exception (matching the legacy plugin path's behavior of + * propagating listener errors via React's runEventsInBatch + + * `rethrowCaughtError`, rather than swallowing them as a `console.error`). + * + * `setTimeout(0)` schedules a new macrotask; the throw inside it has no + * catcher above, so it bubbles up to the host's unhandled-error reporter. + */ +function reportListenerError(error: unknown): void { + setTimeout(() => { + throw error; + }, 0); +}