From e39632aad1d70a153499bfc917cce62a0e080a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Norte?= Date: Fri, 1 May 2026 02:28:28 -0700 Subject: [PATCH] Honor skipBubbling in the new EventTarget-based event dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: The feature flag `enableNativeEventTargetEventDispatching` switches the React Native renderer from the legacy plugin-based event dispatch to a new W3C `EventTarget`-based dispatch in `dispatchNativeEvent.js`. The legacy path honors the `phasedRegistrationNames.skipBubbling` flag declared in view configs by short-circuiting the bubble traversal. The new path was unconditionally setting `bubbles: true` for any event with a `customBubblingEventTypes` entry, so `topPointerEnter` / `topPointerLeave` (the only events that declare `skipBubbling: true` today) ended up bubbling to all ancestors, breaking the W3C Pointer Events contract that `pointerenter` / `pointerleave` do not bubble. Map `skipBubbling: true` to `bubbles: false` on the synthesized `LegacySyntheticEvent`. The existing `EventTarget.dispatch()` bubble loop already short-circuits when `event.bubbles` is `false` and `target !== eventTarget`, so the target's own bubble + capture handlers still fire — only ancestor bubble handlers are suppressed. Capture-phase listeners are unaffected. Changelog: [internal] Reviewed By: sammy-SC Differential Revision: D103200424 --- .../__tests__/EventTargetDispatching-itest.js | 126 ++++++++++++++++++ .../renderer/events/dispatchNativeEvent.js | 12 +- 2 files changed, 136 insertions(+), 2 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 bad91d553a56..4c230499c4a6 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 @@ -1476,5 +1476,131 @@ const {isOSS} = Fantom.getConstants(); expect(capturedDispatchConfig.registrationName).toBe('onLayout'); }); }); + + // --- skipBubbling --- + + describe('skipBubbling (pointerenter / pointerleave)', () => { + it('does not bubble onPointerEnter to ancestor views', () => { + const root = Fantom.createRoot(); + + const childRef = React.createRef>(); + + const parentSpy = jest.fn((_e: PointerEvent) => {}); + const childSpy = jest.fn((_e: PointerEvent) => {}); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + Fantom.dispatchNativeEvent( + childRef, + 'onPointerEnter', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.ContinuousStart, + }, + ); + + expect(childSpy).toHaveBeenCalledTimes(1); + expect(parentSpy).toHaveBeenCalledTimes(0); + }); + + it('does not bubble onPointerLeave to ancestor views', () => { + const root = Fantom.createRoot(); + + const childRef = React.createRef>(); + + const parentSpy = jest.fn((_e: PointerEvent) => {}); + const childSpy = jest.fn((_e: PointerEvent) => {}); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + Fantom.dispatchNativeEvent( + childRef, + 'onPointerLeave', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.ContinuousEnd, + }, + ); + + expect(childSpy).toHaveBeenCalledTimes(1); + expect(parentSpy).toHaveBeenCalledTimes(0); + }); + + it('still fires onPointerEnterCapture on ancestors during the capture phase', () => { + const root = Fantom.createRoot(); + + const childRef = React.createRef>(); + + const callOrder: Array = []; + const parentCaptureSpy = jest.fn((_e: PointerEvent) => { + callOrder.push('parentCapture'); + }); + const childSpy = jest.fn((_e: PointerEvent) => { + callOrder.push('child'); + }); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + Fantom.dispatchNativeEvent( + childRef, + 'onPointerEnter', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.ContinuousStart, + }, + ); + + expect(parentCaptureSpy).toHaveBeenCalledTimes(1); + expect(childSpy).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['parentCapture', 'child']); + }); + + it('still bubbles non-skipBubbling events (onPointerDown) to ancestor views', () => { + const root = Fantom.createRoot(); + + const childRef = React.createRef>(); + + const parentSpy = jest.fn((_e: PointerEvent) => {}); + const childSpy = jest.fn((_e: PointerEvent) => {}); + + Fantom.runTask(() => { + root.render( + + + , + ); + }); + + Fantom.dispatchNativeEvent( + childRef, + 'onPointerDown', + {x: 0, y: 0}, + { + category: Fantom.NativeEventCategory.Discrete, + }, + ); + + expect(childSpy).toHaveBeenCalledTimes(1); + expect(parentSpy).toHaveBeenCalledTimes(1); + }); + }); }, ); diff --git a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js index 41bef5591d3e..7c2f16c96852 100644 --- a/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js +++ b/packages/react-native/src/private/renderer/events/dispatchNativeEvent.js @@ -43,10 +43,18 @@ export default function dispatchNativeEvent( // Normal EventTarget dispatch const bubbleConfig = customBubblingEventTypes[type]; const directConfig = customDirectEventTypes[type]; - const bubbles = bubbleConfig != null; // Skip events that are not registered in the view config - if (bubbles || directConfig != null) { + if (bubbleConfig != null || directConfig != null) { + // Honor `skipBubbling` declared in the view config: when set, the bubble + // phase only fires on the target itself (matching the legacy renderer's + // behavior). The synthesized event reports `bubbles: false`, which causes + // the EventTarget bubble loop to short-circuit after dispatching to the + // target. Capture-phase listeners are unaffected. + const bubbles = + bubbleConfig != null && + bubbleConfig.phasedRegistrationNames.skipBubbling !== true; + const eventType = topLevelTypeToEventType(type); const options: {bubbles: boolean, cancelable: boolean} = { bubbles,