diff --git a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js
index cbe9bd985c..1b92c0ff53 100644
--- a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js
@@ -590,4 +590,36 @@ describe('ReactDOMEventListener', () => {
document.body.removeChild(container);
}
});
+
+ it('should handle non-bubbling capture events correctly', () => {
+ const container = document.createElement('div');
+ const innerRef = React.createRef();
+ const outerRef = React.createRef();
+ const onPlayCapture = jest.fn();
+ document.body.appendChild(container);
+ try {
+ ReactDOM.render(
+
,
+ container,
+ );
+ innerRef.current.dispatchEvent(
+ new Event('play', {
+ bubbles: false,
+ }),
+ );
+ expect(onPlayCapture).toHaveBeenCalledTimes(3);
+ outerRef.current.dispatchEvent(
+ new Event('play', {
+ bubbles: false,
+ }),
+ );
+ expect(onPlayCapture).toHaveBeenCalledTimes(4);
+ } finally {
+ document.body.removeChild(container);
+ }
+ });
});
diff --git a/packages/react-dom/src/events/DOMPluginEventSystem.js b/packages/react-dom/src/events/DOMPluginEventSystem.js
index 60f7e5b828..161f3931df 100644
--- a/packages/react-dom/src/events/DOMPluginEventSystem.js
+++ b/packages/react-dom/src/events/DOMPluginEventSystem.js
@@ -712,6 +712,7 @@ export function accumulateSinglePhaseListeners(
dispatchQueue: DispatchQueue,
event: ReactSyntheticEvent,
inCapturePhase: boolean,
+ accumulateTargetOnly: boolean,
): void {
const bubbled = event._reactName;
const captured = bubbled !== null ? bubbled + 'Capture' : null;
@@ -809,6 +810,12 @@ export function accumulateSinglePhaseListeners(
}
}
}
+ // If we are only accumulating events for the target, then we don't
+ // continue to propagate through the React fiber tree to find other
+ // listeners.
+ if (accumulateTargetOnly) {
+ break;
+ }
instance = instance.return;
}
if (listeners.length !== 0) {
diff --git a/packages/react-dom/src/events/plugins/SimpleEventPlugin.js b/packages/react-dom/src/events/plugins/SimpleEventPlugin.js
index 6d96d42de7..a09d2d7bc7 100644
--- a/packages/react-dom/src/events/plugins/SimpleEventPlugin.js
+++ b/packages/react-dom/src/events/plugins/SimpleEventPlugin.js
@@ -40,7 +40,7 @@ import {
import {IS_EVENT_HANDLE_NON_MANAGED_NODE} from '../EventSystemFlags';
import getEventCharCode from '../getEventCharCode';
-import {IS_CAPTURE_PHASE} from '../EventSystemFlags';
+import {IS_CAPTURE_PHASE, IS_NON_DELEGATED} from '../EventSystemFlags';
import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags';
@@ -165,12 +165,23 @@ function extractEvents(
inCapturePhase,
);
} else {
+ // When we encounter a non-delegated event in the capture phase,
+ // we shouldn't emuluate capture bubbling. This is because we'll
+ // add a native capture event listener to each element directly,
+ // not the root, and native capture listeners always fire even
+ // if the event doesn't bubble.
+ const isNonDelegatedEvent = (eventSystemFlags & IS_NON_DELEGATED) !== 0;
+ // TODO: We may also want to re-use the accumulateTargetOnly flag to
+ // special case bubbling for onScroll/media events at a later point.
+ const accumulateTargetOnly = inCapturePhase && isNonDelegatedEvent;
+
// We traverse only capture or bubble phase listeners
accumulateSinglePhaseListeners(
targetInst,
dispatchQueue,
event,
inCapturePhase,
+ accumulateTargetOnly,
);
}
return event;