diff --git a/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js b/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js index 2d74776e4200c..f3ce8a49c28f6 100644 --- a/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js +++ b/packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js @@ -89,7 +89,12 @@ function getParent(inst) { /** * Simulates the traversal of a two-phase, capture/bubble event dispatch. */ -export function traverseTwoPhase(inst: Object, fn: Function, arg: Function) { +export function traverseTwoPhase( + inst: Object, + fn: Function, + arg: Function, + skipBubbling: boolean, +) { const path = []; while (inst) { path.push(inst); @@ -99,14 +104,24 @@ export function traverseTwoPhase(inst: Object, fn: Function, arg: Function) { for (i = path.length; i-- > 0; ) { fn(path[i], 'captured', arg); } - for (i = 0; i < path.length; i++) { - fn(path[i], 'bubbled', arg); + if (skipBubbling) { + // Dispatch on target only + fn(path[0], 'bubbled', arg); + } else { + for (i = 0; i < path.length; i++) { + fn(path[i], 'bubbled', arg); + } } } function accumulateTwoPhaseDispatchesSingle(event) { if (event && event.dispatchConfig.phasedRegistrationNames) { - traverseTwoPhase(event._targetInst, accumulateDirectionalDispatches, event); + traverseTwoPhase( + event._targetInst, + accumulateDirectionalDispatches, + event, + false, + ); } } @@ -114,6 +129,17 @@ function accumulateTwoPhaseDispatches(events) { forEachAccumulated(events, accumulateTwoPhaseDispatchesSingle); } +function accumulateCapturePhaseDispatches(event) { + if (event && event.dispatchConfig.phasedRegistrationNames) { + traverseTwoPhase( + event._targetInst, + accumulateDirectionalDispatches, + event, + true, + ); + } +} + /** * Accumulates without regard to direction, does not look for phased * registration names. Same as `accumulateDirectDispatchesSingle` but without @@ -178,7 +204,15 @@ const ReactNativeBridgeEventPlugin = { nativeEventTarget, ); if (bubbleDispatchConfig) { - accumulateTwoPhaseDispatches(event); + const skipBubbling = + event != null && + event.dispatchConfig.phasedRegistrationNames != null && + event.dispatchConfig.phasedRegistrationNames.skipBubbling; + if (skipBubbling) { + accumulateCapturePhaseDispatches(event); + } else { + accumulateTwoPhaseDispatches(event); + } } else if (directDispatchConfig) { accumulateDirectDispatches(event); } else { diff --git a/packages/react-native-renderer/src/ReactNativeTypes.js b/packages/react-native-renderer/src/ReactNativeTypes.js index 55150e83517e6..ea6e5d2b41d9a 100644 --- a/packages/react-native-renderer/src/ReactNativeTypes.js +++ b/packages/react-native-renderer/src/ReactNativeTypes.js @@ -73,6 +73,7 @@ export type ViewConfig = $ReadOnly<{ phasedRegistrationNames: $ReadOnly<{ captured: string, bubbled: string, + skipBubble?: ?boolean, }>, }>, ..., diff --git a/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js index 9f2851382af43..0b38424e6df84 100644 --- a/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js @@ -633,6 +633,106 @@ describe('ReactFabric', () => { expect(touchStart2).toBeCalled(); }); + describe('skipBubbling', () => { + it('should skip bubbling to ancestor if specified', () => { + const View = createReactNativeComponentClass('RCTView', () => ({ + validAttributes: {}, + uiViewClassName: 'RCTView', + bubblingEventTypes: { + topDefaultBubblingEvent: { + phasedRegistrationNames: { + captured: 'onDefaultBubblingEventCapture', + bubbled: 'onDefaultBubblingEvent', + }, + }, + topBubblingEvent: { + phasedRegistrationNames: { + captured: 'onBubblingEventCapture', + bubbled: 'onBubblingEvent', + skipBubbling: false, + }, + }, + topSkipBubblingEvent: { + phasedRegistrationNames: { + captured: 'onSkippedBubblingEventCapture', + bubbled: 'onSkippedBubblingEvent', + skipBubbling: true, + }, + }, + }, + })); + const ancestorBubble = jest.fn(); + const ancestorCapture = jest.fn(); + const targetBubble = jest.fn(); + const targetCapture = jest.fn(); + + const event = {}; + + act(() => { + ReactFabric.render( + + + , + 11, + ); + }); + + expect(nativeFabricUIManager.createNode.mock.calls.length).toBe(2); + expect(nativeFabricUIManager.registerEventHandler.mock.calls.length).toBe( + 1, + ); + const [ + , + , + , + , + childInstance, + ] = nativeFabricUIManager.createNode.mock.calls[0]; + const [ + dispatchEvent, + ] = nativeFabricUIManager.registerEventHandler.mock.calls[0]; + + dispatchEvent(childInstance, 'topDefaultBubblingEvent', event); + expect(targetBubble).toHaveBeenCalledTimes(1); + expect(targetCapture).toHaveBeenCalledTimes(1); + expect(ancestorCapture).toHaveBeenCalledTimes(1); + expect(ancestorBubble).toHaveBeenCalledTimes(1); + ancestorBubble.mockReset(); + ancestorCapture.mockReset(); + targetBubble.mockReset(); + targetCapture.mockReset(); + + dispatchEvent(childInstance, 'topBubblingEvent', event); + expect(targetBubble).toHaveBeenCalledTimes(1); + expect(targetCapture).toHaveBeenCalledTimes(1); + expect(ancestorCapture).toHaveBeenCalledTimes(1); + expect(ancestorBubble).toHaveBeenCalledTimes(1); + ancestorBubble.mockReset(); + ancestorCapture.mockReset(); + targetBubble.mockReset(); + targetCapture.mockReset(); + + dispatchEvent(childInstance, 'topSkipBubblingEvent', event); + expect(targetBubble).toHaveBeenCalledTimes(1); + expect(targetCapture).toHaveBeenCalledTimes(1); + expect(ancestorCapture).toHaveBeenCalledTimes(1); + expect(ancestorBubble).not.toBeCalled(); + }); + }); + it('dispatches event with target as instance', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: { diff --git a/packages/react-native-renderer/src/legacy-events/ReactSyntheticEventType.js b/packages/react-native-renderer/src/legacy-events/ReactSyntheticEventType.js index 57d019e2a398d..436507b84ac19 100644 --- a/packages/react-native-renderer/src/legacy-events/ReactSyntheticEventType.js +++ b/packages/react-native-renderer/src/legacy-events/ReactSyntheticEventType.js @@ -16,6 +16,7 @@ export type DispatchConfig = {| phasedRegistrationNames: {| bubbled: null | string, captured: null | string, + skipBubbling?: ?boolean, |}, registrationName?: string, |}; @@ -24,6 +25,7 @@ export type CustomDispatchConfig = {| phasedRegistrationNames: {| bubbled: null, captured: null, + skipBubbling?: ?boolean, |}, registrationName?: string, customEvent: true, diff --git a/scripts/rollup/shims/react-native/ReactNativeViewConfigRegistry.js b/scripts/rollup/shims/react-native/ReactNativeViewConfigRegistry.js index 194a91e31ae62..de5211f09be91 100644 --- a/scripts/rollup/shims/react-native/ReactNativeViewConfigRegistry.js +++ b/scripts/rollup/shims/react-native/ReactNativeViewConfigRegistry.js @@ -19,6 +19,7 @@ const customBubblingEventTypes: { phasedRegistrationNames: $ReadOnly<{| captured: string, bubbled: string, + skipBubbling?: ?boolean, |}>, |}>, ...,