Repository: facebook/react
Pull request: Add skipBubbling property to dispatch config

<!--
  Thanks for submitting a pull request!
  We appreciate you spending the time to work on these changes. Please provide enough information so that others can review your pull request. The three fields below are mandatory.

  Before submitting a pull request, please make sure the following is done:

  1. Fork [the repository](https://github.com/facebook/react) and create your branch from `main`.
  2. Run `yarn` in the repository root.
  3. If you've fixed a bug or added code that should be tested, add tests!
  4. Ensure the test suite passes (`yarn test`). Tip: `yarn test --watch TestName` is helpful in development.
  5. Run `yarn test --prod` to test in the production environment. It supports the same options as `yarn test`.
  6. If you need a debugger, run `yarn debug-test --watch TestName`, open `chrome://inspect`, and press "Inspect".
  7. Format your code with [prettier](https://github.com/prettier/prettier) (`yarn prettier`).
  8. Make sure your code lints (`yarn lint`). Tip: `yarn linc` to only check changed files.
  9. Run the [Flow](https://flowtype.org/) type checks (`yarn flow`).
  10. If you haven't already, complete the CLA.

  Learn more about contributing: https://reactjs.org/docs/how-to-contribute.html
-->

## Support skip bubbling for React Native bubbled events.

Introducing this to offer similar event dispatching for event behavior on the web. In particular, there are some events that we want to support in React Native that do not bubble. These are not synonymous with direct events on React Native as they still go through a capture phase. 

<!--
 Explain the **motivation** for making this change. What existing problem does the pull request solve?
-->

## How did you test this change?

Added a test case to demonstrate that ancestors do not receive bubbled events when `skipBubbling` is true

Changed files:
filename: packages/react-native-renderer/src/ReactNativeBridgeEventPlugin.js
patch: @@ -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,21 +104,42 @@ 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,
+    );
   }
 }
 
 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 {

filename: packages/react-native-renderer/src/ReactNativeTypes.js
patch: @@ -73,6 +73,7 @@ export type ViewConfig = $ReadOnly<{
       phasedRegistrationNames: $ReadOnly<{
         captured: string,
         bubbled: string,
+        skipBubble?: ?boolean,
       }>,
     }>,
     ...,

filename: packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js
patch: @@ -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(
+          <View
+            onSkippedBubblingEventCapture={ancestorCapture}
+            onDefaultBubblingEventCapture={ancestorCapture}
+            onBubblingEventCapture={ancestorCapture}
+            onSkippedBubblingEvent={ancestorBubble}
+            onDefaultBubblingEvent={ancestorBubble}
+            onBubblingEvent={ancestorBubble}>
+            <View
+              onSkippedBubblingEventCapture={targetCapture}
+              onDefaultBubblingEventCapture={targetCapture}
+              onBubblingEventCapture={targetCapture}
+              onSkippedBubblingEvent={targetBubble}
+              onDefaultBubblingEvent={targetBubble}
+              onBubblingEvent={targetBubble}
+            />
+          </View>,
+          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: {

filename: packages/react-native-renderer/src/legacy-events/ReactSyntheticEventType.js
patch: @@ -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,

filename: scripts/rollup/shims/react-native/ReactNativeViewConfigRegistry.js
patch: @@ -19,6 +19,7 @@ const customBubblingEventTypes: {
     phasedRegistrationNames: $ReadOnly<{|
       captured: string,
       bubbled: string,
+      skipBubbling?: ?boolean,
     |}>,
   |}>,
   ...,