Re-throw event listener errors in a new task (#56760)

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
This commit is contained in:
Rubén Norte
2026-05-11 05:22:27 -07:00
committed by meta-codesync[bot]
parent 37909420f3
commit 645f846c73
2 changed files with 19 additions and 31 deletions
@@ -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<React.ElementRef<typeof View>>();
@@ -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);
@@ -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);
}