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); +}