Skip to content

Commit 187513f

Browse files
rubennortemeta-codesync[bot]
authored andcommitted
Rethrow event listener errors synchronously for native event dispatch (#57207)
Summary: Pull Request resolved: #57207 Under the EventTarget-based event dispatch path, an error thrown by an event handler (e.g. `onPress`, `onScroll`) was deferred to a new task via `setTimeout(0)` in `reportListenerError`. This diverged from the legacy plugin path, which collected the first listener error and rethrew it synchronously at the end of the dispatch (React's `runEventsInBatch` + `rethrowCaughtError`). As a result, handler errors escaped the synchronous dispatch flow — they were no longer catchable by React error boundaries or the native event call, and instead surfaced as deferred, uncaught global errors. This restores the legacy contract for native event dispatch: - `dispatchTrustedEvent` gains an opt-in `rethrowListenerErrors` argument. `dispatch()` threads a per-dispatch error holder through `invoke`/`invokeListeners`; when the flag is set it records the first listener error and rethrows it synchronously after the event has been fully cleaned up. - The renderer's native event dispatch (`dispatchNativeEvent`) opts in, so listener errors propagate synchronously again. The responder lifecycle `rethrowCaughtError()` is moved into a `finally` so a pending responder error can never leak into a later dispatch if the normal dispatch throws. - The public `dispatchEvent` API and other `EventTarget` consumers (e.g. `XMLHttpRequest`) are unchanged: they keep the DOM contract of reporting listener errors to the global error handler without throwing. - Re-enabled the previously skipped event-dispatch error-handling integration tests so they run in both dispatch modes. Changelog: [Internal] Reviewed By: javache Differential Revision: D108622141
1 parent 066c0d8 commit 187513f

4 files changed

Lines changed: 148 additions & 76 deletions

File tree

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

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1327,15 +1327,7 @@ const {isOSS} = Fantom.getConstants();
13271327
expect(order).toEqual(['parent-capture']);
13281328
});
13291329

1330-
// When enableNativeEventTargetEventDispatching is true, EventTarget.js
1331-
// defers handler errors via setTimeout(0) in reportListenerError. This
1332-
// leaves a pending callback that Fantom's validateEmptyMessageQueue
1333-
// catches, and the error leaks into subsequent tests. Skip in that
1334-
// configuration until the error propagation mechanism is made
1335-
// synchronous (matching the legacy rethrowCaughtError pattern).
1336-
(ReactNativeFeatureFlags.enableNativeEventTargetEventDispatching()
1337-
? describe.skip
1338-
: describe)('error handling', () => {
1330+
describe('error handling', () => {
13391331
it('error in event handler does not break dispatch to subsequent listeners', () => {
13401332
const root = Fantom.createRoot();
13411333
const childRef = React.createRef<React.ElementRef<typeof View>>();

packages/react-native/src/private/renderer/events/dispatchNativeEvent.js

Lines changed: 64 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -44,64 +44,75 @@ export default function dispatchNativeEvent(
4444
// Process responder events before normal event dispatch.
4545
processResponderEvent(type, target, payload);
4646

47-
// Normal EventTarget dispatch
48-
const bubbleConfig = customBubblingEventTypes[type];
49-
const directConfig = customDirectEventTypes[type];
47+
try {
48+
// Normal EventTarget dispatch
49+
const bubbleConfig = customBubblingEventTypes[type];
50+
const directConfig = customDirectEventTypes[type];
5051

51-
// Skip events that are not registered in the view config
52-
if (bubbleConfig != null || directConfig != null) {
53-
// Honor `skipBubbling` declared in the view config: when set, the bubble
54-
// phase only fires on the target itself (matching the legacy renderer's
55-
// behavior). The synthesized event reports `bubbles: false`, which causes
56-
// the EventTarget bubble loop to short-circuit after dispatching to the
57-
// target. Capture-phase listeners are unaffected.
58-
const bubbles =
59-
bubbleConfig != null &&
60-
bubbleConfig.phasedRegistrationNames.skipBubbling !== true;
52+
// Skip events that are not registered in the view config
53+
if (bubbleConfig != null || directConfig != null) {
54+
// Honor `skipBubbling` declared in the view config: when set, the bubble
55+
// phase only fires on the target itself (matching the legacy renderer's
56+
// behavior). The synthesized event reports `bubbles: false`, which causes
57+
// the EventTarget bubble loop to short-circuit after dispatching to the
58+
// target. Capture-phase listeners are unaffected.
59+
const bubbles =
60+
bubbleConfig != null &&
61+
bubbleConfig.phasedRegistrationNames.skipBubbling !== true;
6162

62-
const eventType = topLevelTypeToEventType(type);
63-
const options: {bubbles: boolean, cancelable: boolean} = {
64-
bubbles,
65-
cancelable: true,
66-
};
63+
const eventType = topLevelTypeToEventType(type);
64+
const options: {bubbles: boolean, cancelable: boolean} = {
65+
bubbles,
66+
cancelable: true,
67+
};
6768

68-
// Preserve the native event timestamp for backwards compatibility.
69-
const nativeTimestamp = payload.timeStamp ?? payload.timestamp;
70-
if (typeof nativeTimestamp === 'number') {
71-
setEventInitTimeStamp(options, nativeTimestamp);
72-
}
73-
74-
const syntheticEvent = new LegacySyntheticEvent(
75-
eventType,
76-
options,
77-
payload,
78-
bubbleConfig ?? directConfig,
79-
);
69+
// Preserve the native event timestamp for backwards compatibility.
70+
const nativeTimestamp = payload.timeStamp ?? payload.timestamp;
71+
if (typeof nativeTimestamp === 'number') {
72+
setEventInitTimeStamp(options, nativeTimestamp);
73+
}
8074

81-
// Pre-resolve the React prop names ("onFoo" / "onFooCapture") once per
82-
// dispatch and stash them on the event so per-ancestor
83-
// `EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY` lookups can read them
84-
// directly, avoiding the per-call `getEventTypePropName` hash lookup.
85-
if (bubbleConfig != null) {
86-
const phasedRegistrationNames = bubbleConfig.phasedRegistrationNames;
87-
setBubbledPropName(
88-
syntheticEvent,
89-
phasedRegistrationNames.bubbled ?? null,
75+
const syntheticEvent = new LegacySyntheticEvent(
76+
eventType,
77+
options,
78+
payload,
79+
bubbleConfig ?? directConfig,
9080
);
91-
setCapturedPropName(
92-
syntheticEvent,
93-
phasedRegistrationNames.captured ?? null,
94-
);
95-
} else if (directConfig != null) {
96-
setBubbledPropName(syntheticEvent, directConfig.registrationName ?? null);
97-
setCapturedPropName(syntheticEvent, null);
98-
}
9981

100-
dispatchTrustedEvent(target, syntheticEvent);
101-
}
82+
// Pre-resolve the React prop names ("onFoo" / "onFooCapture") once per
83+
// dispatch and stash them on the event so per-ancestor
84+
// `EVENT_TARGET_GET_DECLARATIVE_LISTENER_KEY` lookups can read them
85+
// directly, avoiding the per-call `getEventTypePropName` hash lookup.
86+
if (bubbleConfig != null) {
87+
const phasedRegistrationNames = bubbleConfig.phasedRegistrationNames;
88+
setBubbledPropName(
89+
syntheticEvent,
90+
phasedRegistrationNames.bubbled ?? null,
91+
);
92+
setCapturedPropName(
93+
syntheticEvent,
94+
phasedRegistrationNames.captured ?? null,
95+
);
96+
} else if (directConfig != null) {
97+
setBubbledPropName(
98+
syntheticEvent,
99+
directConfig.registrationName ?? null,
100+
);
101+
setCapturedPropName(syntheticEvent, null);
102+
}
102103

103-
// Rethrow the first error caught during responder lifecycle dispatch,
104-
// after all dispatching is complete. This matches the old system's
105-
// runEventsInBatch → rethrowCaughtError pattern.
106-
rethrowCaughtError();
104+
// Pass `rethrowListenerErrors: true` so the first listener error is
105+
// rethrown synchronously (matching the legacy plugin path) rather than
106+
// deferred to a new task, keeping it catchable by React error boundaries
107+
// and the native event call.
108+
dispatchTrustedEvent(target, syntheticEvent, true);
109+
}
110+
} finally {
111+
// Rethrow the first error caught during responder lifecycle dispatch,
112+
// after all dispatching is complete. This matches the old system's
113+
// runEventsInBatch → rethrowCaughtError pattern. Running it in a `finally`
114+
// ensures a pending responder error is never left to leak into a later
115+
// dispatch even if the normal dispatch above threw synchronously.
116+
rethrowCaughtError();
117+
}
107118
}

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

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export default class EventTarget {
207207

208208
setIsTrusted(event, false);
209209

210-
dispatch(this, event);
210+
dispatch(this, event, false);
211211

212212
return !event.defaultPrevented;
213213
}
@@ -249,8 +249,11 @@ export default class EventTarget {
249249
* canceled (i.e. `event.defaultPrevented`), otherwise `true`.
250250
*/
251251
// $FlowExpectedError[unsupported-syntax]
252-
[INTERNAL_DISPATCH_METHOD_KEY](event: Event): boolean {
253-
dispatch(this, event);
252+
[INTERNAL_DISPATCH_METHOD_KEY](
253+
event: Event,
254+
rethrowListenerErrors?: boolean,
255+
): boolean {
256+
dispatch(this, event, rethrowListenerErrors === true);
254257
return !event.defaultPrevented;
255258
}
256259
}
@@ -280,13 +283,26 @@ function getDefaultPassiveValue(
280283
* Implements the "event dispatch" concept
281284
* (see https://dom.spec.whatwg.org/#concept-event-dispatch).
282285
*/
283-
function dispatch(eventTarget: EventTarget, event: Event): void {
286+
function dispatch(
287+
eventTarget: EventTarget,
288+
event: Event,
289+
rethrowErrors: boolean,
290+
): void {
284291
setEventDispatchFlag(event, true);
285292

286293
const eventPath = getEventPath(eventTarget, event);
287294
setComposedPath(event, eventPath);
288295
setTarget(event, eventTarget);
289296

297+
// When `rethrowErrors` is set (trusted dispatch of native UI events), collect
298+
// the first listener error and rethrow it synchronously once the dispatch
299+
// completes, matching the legacy plugin system's `rethrowCaughtError`
300+
// behavior. Otherwise (the public `dispatchEvent` API, XHR, etc.) listener
301+
// errors are reported to the global error handler per the DOM spec.
302+
const errorState: ListenerErrorState | null = rethrowErrors
303+
? {hasError: false, error: undefined}
304+
: null;
305+
290306
for (let i = eventPath.length - 1; i >= 0; i--) {
291307
if (getStopPropagationFlag(event)) {
292308
break;
@@ -297,7 +313,7 @@ function dispatch(eventTarget: EventTarget, event: Event): void {
297313
event,
298314
target === eventTarget ? Event.AT_TARGET : Event.CAPTURING_PHASE,
299315
);
300-
invoke(target, event, Event.CAPTURING_PHASE);
316+
invoke(target, event, Event.CAPTURING_PHASE, errorState);
301317
}
302318

303319
for (const target of eventPath) {
@@ -315,7 +331,7 @@ function dispatch(eventTarget: EventTarget, event: Event): void {
315331
event,
316332
target === eventTarget ? Event.AT_TARGET : Event.BUBBLING_PHASE,
317333
);
318-
invoke(target, event, Event.BUBBLING_PHASE);
334+
invoke(target, event, Event.BUBBLING_PHASE, errorState);
319335
}
320336

321337
setEventPhase(event, Event.NONE);
@@ -325,6 +341,12 @@ function dispatch(eventTarget: EventTarget, event: Event): void {
325341
setEventDispatchFlag(event, false);
326342
setStopImmediatePropagationFlag(event, false);
327343
setStopPropagationFlag(event, false);
344+
345+
// Trusted dispatch: surface the first listener error synchronously, after the
346+
// event has been fully cleaned up.
347+
if (errorState != null && errorState.hasError) {
348+
throw errorState.error;
349+
}
328350
}
329351

330352
/**
@@ -356,6 +378,7 @@ function invoke(
356378
eventTarget: EventTarget,
357379
event: Event,
358380
eventPhase: EventPhase,
381+
errorState: ListenerErrorState | null,
359382
) {
360383
const isCapture = eventPhase === Event.CAPTURING_PHASE;
361384

@@ -385,7 +408,7 @@ function invoke(
385408
try {
386409
propListener.call(eventTarget, event);
387410
} catch (error) {
388-
reportListenerError(error);
411+
handleListenerError(error, errorState);
389412
}
390413
global.event = currentEvent;
391414
return;
@@ -404,7 +427,7 @@ function invoke(
404427
for (const registration of maybeListeners.values()) {
405428
listeners.push(registration);
406429
}
407-
invokeListeners(eventTarget, event, listeners, isCapture);
430+
invokeListeners(eventTarget, event, listeners, isCapture, errorState);
408431
return;
409432
}
410433

@@ -419,6 +442,7 @@ function invoke(
419442
event,
420443
Array.from(maybeListeners.values()),
421444
isCapture,
445+
errorState,
422446
);
423447
}
424448

@@ -427,6 +451,7 @@ function invokeListeners(
427451
event: Event,
428452
listeners: Array<EventListenerRegistration>,
429453
isCapture: boolean,
454+
errorState: ListenerErrorState | null,
430455
): void {
431456
for (const listener of listeners) {
432457
if (listener.removed) {
@@ -454,7 +479,7 @@ function invokeListeners(
454479
callback.handleEvent(event);
455480
}
456481
} catch (error) {
457-
reportListenerError(error);
482+
handleListenerError(error, errorState);
458483
}
459484

460485
if (listener.passive) {
@@ -509,12 +534,44 @@ function setEventDispatchFlag(event: Event, value: boolean): void {
509534
event[EVENT_DISPATCH_FLAG] = value;
510535
}
511536

537+
type ListenerErrorState = {hasError: boolean, error: unknown};
538+
539+
/**
540+
* Handle an error thrown by an event listener without aborting the rest of the
541+
* dispatch.
542+
*
543+
* For trusted dispatch of native UI events (`errorState` is non-null), the
544+
* first error is recorded so `dispatch` can rethrow it synchronously once the
545+
* dispatch completes, matching the legacy plugin path (React's
546+
* runEventsInBatch + `rethrowCaughtError`). This keeps listener errors
547+
* catchable by React error boundaries and the native event call, instead of
548+
* escaping as deferred uncaught exceptions.
549+
*
550+
* Otherwise (`errorState` is null: the public `dispatchEvent` API, XHR, etc.)
551+
* the DOM spec requires reporting the exception to the global error handler
552+
* without throwing, so it is deferred via `reportListenerError`.
553+
*/
554+
function handleListenerError(
555+
error: unknown,
556+
errorState: ListenerErrorState | null,
557+
): void {
558+
if (errorState != null) {
559+
if (!errorState.hasError) {
560+
errorState.hasError = true;
561+
errorState.error = error;
562+
}
563+
return;
564+
}
565+
566+
reportListenerError(error);
567+
}
568+
512569
/**
513570
* Surface a listener error to the global error handler without aborting the
514-
* rest of the dispatch. Throws in a new task so the error becomes an
515-
* uncaught exception (matching the legacy plugin path's behavior of
516-
* propagating listener errors via React's runEventsInBatch +
517-
* `rethrowCaughtError`, rather than swallowing them as a `console.error`).
571+
* rest of the dispatch. Throws in a new task so the error becomes an uncaught
572+
* exception. Used for dispatches that follow the DOM `dispatchEvent` contract
573+
* (the public API, XHR, etc.), where errors are reported rather than thrown
574+
* synchronously.
518575
*
519576
* `setTimeout(0)` schedules a new macrotask; the throw inside it has no
520577
* catcher above, so it bubbles up to the host's unhandled-error reporter.

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,25 @@ export function getEventTargetParent(target: EventTarget): EventTarget | null {
7575
*
7676
* This should only be used by the runtime to dispatch native events to
7777
* JavaScript.
78+
*
79+
* When `rethrowListenerErrors` is `true`, the first error thrown by a listener
80+
* is rethrown synchronously once dispatch completes (matching the legacy
81+
* plugin system's `rethrowCaughtError` behavior). This is used by the renderer
82+
* for native UI events so listener errors stay catchable by React error
83+
* boundaries and the native event call. When omitted/`false`, listener errors
84+
* are reported to the global error handler per the DOM spec (used by XHR and
85+
* other web API event targets).
7886
*/
7987
export function dispatchTrustedEvent(
8088
eventTarget: EventTarget,
8189
event: Event,
90+
rethrowListenerErrors?: boolean,
8291
): boolean {
8392
setIsTrusted(event, true);
8493

8594
// $FlowExpectedError[prop-missing]
86-
return eventTarget[INTERNAL_DISPATCH_METHOD_KEY](event);
95+
return eventTarget[INTERNAL_DISPATCH_METHOD_KEY](
96+
event,
97+
rethrowListenerErrors,
98+
);
8799
}

0 commit comments

Comments
 (0)