Skip to content

Commit 645f846

Browse files
rubennortemeta-codesync[bot]
authored andcommitted
Re-throw event listener errors in a new task (#56760)
Summary: Pull Request resolved: #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
1 parent 3790942 commit 645f846

2 files changed

Lines changed: 19 additions & 31 deletions

File tree

packages/react-native/src/private/renderer/core/__tests__/EventTargetDispatching-itest.js

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,21 +1322,6 @@ const {isOSS} = Fantom.getConstants();
13221322
});
13231323

13241324
describe('error handling', () => {
1325-
let originalConsoleError: typeof console.error;
1326-
let mockConsoleError: JestMockFn<$FlowFixMe, $FlowFixMe>;
1327-
1328-
beforeEach(() => {
1329-
originalConsoleError = console.error;
1330-
mockConsoleError = jest.fn();
1331-
// $FlowFixMe[cannot-write]
1332-
console.error = mockConsoleError;
1333-
});
1334-
1335-
afterEach(() => {
1336-
// $FlowFixMe[cannot-write]
1337-
console.error = originalConsoleError;
1338-
});
1339-
13401325
it('error in event handler does not break dispatch to subsequent listeners', () => {
13411326
const root = Fantom.createRoot();
13421327
const childRef = React.createRef<React.ElementRef<typeof View>>();
@@ -1365,18 +1350,7 @@ const {isOSS} = Fantom.getConstants();
13651350
},
13661351
);
13671352

1368-
if (ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()) {
1369-
// EventTarget-style dispatch catches per-listener errors and
1370-
// reports them via `console.error` (see `EventTarget.js`), so the
1371-
// dispatch itself does not throw.
1372-
dispatch();
1373-
expect(mockConsoleError).toHaveBeenCalled();
1374-
} else {
1375-
// Legacy dispatch surfaces the first per-handler error via
1376-
// Fantom's global handler, which re-throws synchronously after
1377-
// dispatch completes.
1378-
expect(dispatch).toThrow('handler error');
1379-
}
1353+
expect(dispatch).toThrow('handler error');
13801354

13811355
// The parent bubble handler should still fire despite child's error
13821356
expect(parentHandler).toHaveBeenCalledTimes(1);

packages/react-native/src/private/webapis/dom/events/EventTarget.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -382,8 +382,7 @@ function invoke(
382382
try {
383383
propListener.call(eventTarget, event);
384384
} catch (error) {
385-
// TODO: replace with `reportError` when it's available.
386-
console.error(error);
385+
reportListenerError(error);
387386
}
388387
global.event = currentEvent;
389388
return;
@@ -452,8 +451,7 @@ function invokeListeners(
452451
callback.handleEvent(event);
453452
}
454453
} catch (error) {
455-
// TODO: replace with `reportError` when it's available.
456-
console.error(error);
454+
reportListenerError(error);
457455
}
458456

459457
if (listener.passive) {
@@ -507,3 +505,19 @@ function setEventDispatchFlag(event: Event, value: boolean): void {
507505
// $FlowExpectedError[prop-missing]
508506
event[EVENT_DISPATCH_FLAG] = value;
509507
}
508+
509+
/**
510+
* Surface a listener error to the global error handler without aborting the
511+
* rest of the dispatch. Throws in a new task so the error becomes an
512+
* uncaught exception (matching the legacy plugin path's behavior of
513+
* propagating listener errors via React's runEventsInBatch +
514+
* `rethrowCaughtError`, rather than swallowing them as a `console.error`).
515+
*
516+
* `setTimeout(0)` schedules a new macrotask; the throw inside it has no
517+
* catcher above, so it bubbles up to the host's unhandled-error reporter.
518+
*/
519+
function reportListenerError(error: unknown): void {
520+
setTimeout(() => {
521+
throw error;
522+
}, 0);
523+
}

0 commit comments

Comments
 (0)