Skip to content

Commit e39632a

Browse files
rubennortefacebook-github-bot
authored andcommitted
Honor skipBubbling in the new EventTarget-based event dispatch
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
1 parent 5ae948e commit e39632a

2 files changed

Lines changed: 136 additions & 2 deletions

File tree

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1476,5 +1476,131 @@ const {isOSS} = Fantom.getConstants();
14761476
expect(capturedDispatchConfig.registrationName).toBe('onLayout');
14771477
});
14781478
});
1479+
1480+
// --- skipBubbling ---
1481+
1482+
describe('skipBubbling (pointerenter / pointerleave)', () => {
1483+
it('does not bubble onPointerEnter to ancestor views', () => {
1484+
const root = Fantom.createRoot();
1485+
1486+
const childRef = React.createRef<React.ElementRef<typeof View>>();
1487+
1488+
const parentSpy = jest.fn((_e: PointerEvent) => {});
1489+
const childSpy = jest.fn((_e: PointerEvent) => {});
1490+
1491+
Fantom.runTask(() => {
1492+
root.render(
1493+
<View onPointerEnter={parentSpy}>
1494+
<View ref={childRef} onPointerEnter={childSpy} />
1495+
</View>,
1496+
);
1497+
});
1498+
1499+
Fantom.dispatchNativeEvent(
1500+
childRef,
1501+
'onPointerEnter',
1502+
{x: 0, y: 0},
1503+
{
1504+
category: Fantom.NativeEventCategory.ContinuousStart,
1505+
},
1506+
);
1507+
1508+
expect(childSpy).toHaveBeenCalledTimes(1);
1509+
expect(parentSpy).toHaveBeenCalledTimes(0);
1510+
});
1511+
1512+
it('does not bubble onPointerLeave to ancestor views', () => {
1513+
const root = Fantom.createRoot();
1514+
1515+
const childRef = React.createRef<React.ElementRef<typeof View>>();
1516+
1517+
const parentSpy = jest.fn((_e: PointerEvent) => {});
1518+
const childSpy = jest.fn((_e: PointerEvent) => {});
1519+
1520+
Fantom.runTask(() => {
1521+
root.render(
1522+
<View onPointerLeave={parentSpy}>
1523+
<View ref={childRef} onPointerLeave={childSpy} />
1524+
</View>,
1525+
);
1526+
});
1527+
1528+
Fantom.dispatchNativeEvent(
1529+
childRef,
1530+
'onPointerLeave',
1531+
{x: 0, y: 0},
1532+
{
1533+
category: Fantom.NativeEventCategory.ContinuousEnd,
1534+
},
1535+
);
1536+
1537+
expect(childSpy).toHaveBeenCalledTimes(1);
1538+
expect(parentSpy).toHaveBeenCalledTimes(0);
1539+
});
1540+
1541+
it('still fires onPointerEnterCapture on ancestors during the capture phase', () => {
1542+
const root = Fantom.createRoot();
1543+
1544+
const childRef = React.createRef<React.ElementRef<typeof View>>();
1545+
1546+
const callOrder: Array<string> = [];
1547+
const parentCaptureSpy = jest.fn((_e: PointerEvent) => {
1548+
callOrder.push('parentCapture');
1549+
});
1550+
const childSpy = jest.fn((_e: PointerEvent) => {
1551+
callOrder.push('child');
1552+
});
1553+
1554+
Fantom.runTask(() => {
1555+
root.render(
1556+
<View onPointerEnterCapture={parentCaptureSpy}>
1557+
<View ref={childRef} onPointerEnter={childSpy} />
1558+
</View>,
1559+
);
1560+
});
1561+
1562+
Fantom.dispatchNativeEvent(
1563+
childRef,
1564+
'onPointerEnter',
1565+
{x: 0, y: 0},
1566+
{
1567+
category: Fantom.NativeEventCategory.ContinuousStart,
1568+
},
1569+
);
1570+
1571+
expect(parentCaptureSpy).toHaveBeenCalledTimes(1);
1572+
expect(childSpy).toHaveBeenCalledTimes(1);
1573+
expect(callOrder).toEqual(['parentCapture', 'child']);
1574+
});
1575+
1576+
it('still bubbles non-skipBubbling events (onPointerDown) to ancestor views', () => {
1577+
const root = Fantom.createRoot();
1578+
1579+
const childRef = React.createRef<React.ElementRef<typeof View>>();
1580+
1581+
const parentSpy = jest.fn((_e: PointerEvent) => {});
1582+
const childSpy = jest.fn((_e: PointerEvent) => {});
1583+
1584+
Fantom.runTask(() => {
1585+
root.render(
1586+
<View onPointerDown={parentSpy}>
1587+
<View ref={childRef} onPointerDown={childSpy} />
1588+
</View>,
1589+
);
1590+
});
1591+
1592+
Fantom.dispatchNativeEvent(
1593+
childRef,
1594+
'onPointerDown',
1595+
{x: 0, y: 0},
1596+
{
1597+
category: Fantom.NativeEventCategory.Discrete,
1598+
},
1599+
);
1600+
1601+
expect(childSpy).toHaveBeenCalledTimes(1);
1602+
expect(parentSpy).toHaveBeenCalledTimes(1);
1603+
});
1604+
});
14791605
},
14801606
);

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,18 @@ export default function dispatchNativeEvent(
4343
// Normal EventTarget dispatch
4444
const bubbleConfig = customBubblingEventTypes[type];
4545
const directConfig = customDirectEventTypes[type];
46-
const bubbles = bubbleConfig != null;
4746

4847
// Skip events that are not registered in the view config
49-
if (bubbles || directConfig != null) {
48+
if (bubbleConfig != null || directConfig != null) {
49+
// Honor `skipBubbling` declared in the view config: when set, the bubble
50+
// phase only fires on the target itself (matching the legacy renderer's
51+
// behavior). The synthesized event reports `bubbles: false`, which causes
52+
// the EventTarget bubble loop to short-circuit after dispatching to the
53+
// target. Capture-phase listeners are unaffected.
54+
const bubbles =
55+
bubbleConfig != null &&
56+
bubbleConfig.phasedRegistrationNames.skipBubbling !== true;
57+
5058
const eventType = topLevelTypeToEventType(type);
5159
const options: {bubbles: boolean, cancelable: boolean} = {
5260
bubbles,

0 commit comments

Comments
 (0)