spy()} />, container);
+
+ const el = container.querySelector('div');
+
+ el.dispatchEvent(new Event('scroll'));
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call old listeners on a second update with a new handler', () => {
+ const a = jest.fn();
+ const b = jest.fn();
+
+ ReactDOM.render(
, container);
+ ReactDOM.render(
, container);
+
+ const el = container.querySelector('div');
+
+ el.dispatchEvent(new Event('scroll'));
+
+ // The first handler should have been torn down
+ expect(a).toHaveBeenCalledTimes(0);
+ // The second handler is now attached
+ expect(b).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js
index 4dc613805c9..b83ce5d5e1f 100644
--- a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js
@@ -2603,60 +2603,4 @@ describe('ReactDOMComponent', () => {
expect(node.getAttribute('onx')).toBe('bar');
});
});
-
- it('receives events in specific order', () => {
- let eventOrder = [];
- let track = tag => () => eventOrder.push(tag);
- let outerRef = React.createRef();
- let innerRef = React.createRef();
-
- function OuterReactApp() {
- return (
-
- );
- }
-
- function InnerReactApp() {
- return (
-
- );
- }
-
- const container = document.createElement('div');
- document.body.appendChild(container);
-
- try {
- ReactDOM.render(
, container);
- ReactDOM.render(
, outerRef.current);
-
- document.addEventListener('click', track('document bubble'));
- document.addEventListener('click', track('document capture'), true);
-
- innerRef.current.click();
-
- // The order we receive here is not ideal since it is expected that the
- // capture listener fire before all bubble listeners. Other React apps
- // might depend on this.
- //
- // @see https://github.com/facebook/react/pull/12919#issuecomment-395224674
- expect(eventOrder).toEqual([
- 'document capture',
- 'inner capture',
- 'inner bubble',
- 'outer capture',
- 'outer bubble',
- 'document bubble',
- ]);
- } finally {
- document.body.removeChild(container);
- }
- });
});
diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js
index d62df23466e..9ab90776bfb 100644
--- a/packages/react-dom/src/client/ReactDOMComponent.js
+++ b/packages/react-dom/src/client/ReactDOMComponent.js
@@ -222,14 +222,14 @@ if (__DEV__) {
};
}
-function ensureListeningTo(rootContainerElement, registrationName) {
+function ensureListeningTo(rootContainerElement, registrationName, domElement) {
const isDocumentOrFragment =
rootContainerElement.nodeType === DOCUMENT_NODE ||
rootContainerElement.nodeType === DOCUMENT_FRAGMENT_NODE;
const doc = isDocumentOrFragment
? rootContainerElement
: rootContainerElement.ownerDocument;
- listenTo(registrationName, doc);
+ listenTo(registrationName, doc, domElement);
}
function getOwnerDocumentFromRootContainer(
@@ -308,7 +308,7 @@ function setInitialDOMProperties(
if (__DEV__ && typeof nextProp !== 'function') {
warnForInvalidEventListener(propKey, nextProp);
}
- ensureListeningTo(rootContainerElement, propKey);
+ ensureListeningTo(rootContainerElement, propKey, domElement);
}
} else if (nextProp != null) {
DOMPropertyOperations.setValueForProperty(
@@ -508,7 +508,7 @@ export function setInitialProperties(
trapBubbledEvent(TOP_INVALID, domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
- ensureListeningTo(rootContainerElement, 'onChange');
+ ensureListeningTo(rootContainerElement, 'onChange', domElement);
break;
case 'option':
ReactDOMOption.validateProps(domElement, rawProps);
@@ -520,7 +520,7 @@ export function setInitialProperties(
trapBubbledEvent(TOP_INVALID, domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
- ensureListeningTo(rootContainerElement, 'onChange');
+ ensureListeningTo(rootContainerElement, 'onChange', domElement);
break;
case 'textarea':
ReactDOMTextarea.initWrapperState(domElement, rawProps);
@@ -528,7 +528,7 @@ export function setInitialProperties(
trapBubbledEvent(TOP_INVALID, domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
- ensureListeningTo(rootContainerElement, 'onChange');
+ ensureListeningTo(rootContainerElement, 'onChange', domElement);
break;
default:
props = rawProps;
@@ -749,7 +749,7 @@ export function diffProperties(
if (__DEV__ && typeof nextProp !== 'function') {
warnForInvalidEventListener(propKey, nextProp);
}
- ensureListeningTo(rootContainerElement, propKey);
+ ensureListeningTo(rootContainerElement, propKey, domElement);
}
if (!updatePayload && lastProp !== nextProp) {
// This is a special case. If any listener updates we need to ensure
@@ -892,7 +892,7 @@ export function diffHydratedProperties(
trapBubbledEvent(TOP_INVALID, domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
- ensureListeningTo(rootContainerElement, 'onChange');
+ ensureListeningTo(rootContainerElement, 'onChange', domElement);
break;
case 'option':
ReactDOMOption.validateProps(domElement, rawProps);
@@ -902,14 +902,14 @@ export function diffHydratedProperties(
trapBubbledEvent(TOP_INVALID, domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
- ensureListeningTo(rootContainerElement, 'onChange');
+ ensureListeningTo(rootContainerElement, 'onChange', domElement);
break;
case 'textarea':
ReactDOMTextarea.initWrapperState(domElement, rawProps);
trapBubbledEvent(TOP_INVALID, domElement);
// For controlled components we always need to ensure we're listening
// to onChange. Even if there is no listener.
- ensureListeningTo(rootContainerElement, 'onChange');
+ ensureListeningTo(rootContainerElement, 'onChange', domElement);
break;
}
@@ -976,7 +976,7 @@ export function diffHydratedProperties(
if (__DEV__ && typeof nextProp !== 'function') {
warnForInvalidEventListener(propKey, nextProp);
}
- ensureListeningTo(rootContainerElement, propKey);
+ ensureListeningTo(rootContainerElement, propKey, domElement);
}
} else if (
__DEV__ &&
diff --git a/packages/react-dom/src/events/ReactBrowserEventEmitter.js b/packages/react-dom/src/events/ReactBrowserEventEmitter.js
index a0f38c5fb1f..eeb280549e5 100644
--- a/packages/react-dom/src/events/ReactBrowserEventEmitter.js
+++ b/packages/react-dom/src/events/ReactBrowserEventEmitter.js
@@ -17,6 +17,11 @@ import {
TOP_RESET,
TOP_SCROLL,
TOP_SUBMIT,
+ TOP_WHEEL,
+ TOP_TOUCH_START,
+ TOP_TOUCH_END,
+ TOP_TOUCH_MOVE,
+ TOP_TOUCH_CANCEL,
getRawEventName,
mediaEventTypes,
} from './DOMTopLevelEventTypes';
@@ -92,14 +97,14 @@ let reactTopListenersCounter = 0;
*/
const topListenersIDKey = '_reactListenersID' + ('' + Math.random()).slice(2);
-function getListeningForDocument(mountAt: any) {
- // In IE8, `mountAt` is a host object and doesn't have `hasOwnProperty`
+function getListenerTrackingFor(node: any) {
+ // In IE8, `node` is a host object and doesn't have `hasOwnProperty`
// directly.
- if (!Object.prototype.hasOwnProperty.call(mountAt, topListenersIDKey)) {
- mountAt[topListenersIDKey] = reactTopListenersCounter++;
- alreadyListeningTo[mountAt[topListenersIDKey]] = {};
+ if (!Object.prototype.hasOwnProperty.call(node, topListenersIDKey)) {
+ node[topListenersIDKey] = reactTopListenersCounter++;
+ alreadyListeningTo[node[topListenersIDKey]] = {};
}
- return alreadyListeningTo[mountAt[topListenersIDKey]];
+ return alreadyListeningTo[node[topListenersIDKey]];
}
/**
@@ -126,49 +131,71 @@ function getListeningForDocument(mountAt: any) {
export function listenTo(
registrationName: string,
mountAt: Document | Element,
+ element: Element,
) {
- const isListening = getListeningForDocument(mountAt);
+ const mountAtListeners = getListenerTrackingFor(mountAt);
const dependencies = registrationNameDependencies[registrationName];
+ let elementListeners;
for (let i = 0; i < dependencies.length; i++) {
const dependency = dependencies[i];
- if (!(isListening.hasOwnProperty(dependency) && isListening[dependency])) {
- switch (dependency) {
- case TOP_SCROLL:
- trapCapturedEvent(TOP_SCROLL, mountAt);
- break;
- case TOP_FOCUS:
- case TOP_BLUR:
+
+ switch (dependency) {
+ case TOP_SCROLL:
+ case TOP_WHEEL:
+ elementListeners = getListenerTrackingFor(element);
+ if (!elementListeners.hasOwnProperty(dependency)) {
+ trapCapturedEvent(dependency, element);
+ elementListeners[dependency] = true;
+ }
+ break;
+ case TOP_TOUCH_START:
+ case TOP_TOUCH_END:
+ case TOP_TOUCH_MOVE:
+ case TOP_TOUCH_CANCEL:
+ elementListeners = getListenerTrackingFor(element);
+ if (!elementListeners.hasOwnProperty(dependency)) {
+ trapBubbledEvent(dependency, element);
+ elementListeners[dependency] = true;
+ }
+ break;
+ case TOP_FOCUS:
+ case TOP_BLUR:
+ if (!mountAtListeners.hasOwnProperty(dependency)) {
trapCapturedEvent(TOP_FOCUS, mountAt);
trapCapturedEvent(TOP_BLUR, mountAt);
// We set the flag for a single dependency later in this function,
// but this ensures we mark both as attached rather than just one.
- isListening[TOP_BLUR] = true;
- isListening[TOP_FOCUS] = true;
- break;
- case TOP_CANCEL:
- case TOP_CLOSE:
+ mountAtListeners[TOP_BLUR] = true;
+ mountAtListeners[TOP_FOCUS] = true;
+ }
+ break;
+ case TOP_CANCEL:
+ case TOP_CLOSE:
+ if (!mountAtListeners.hasOwnProperty(dependency)) {
if (isEventSupported(getRawEventName(dependency))) {
trapCapturedEvent(dependency, mountAt);
}
- break;
- case TOP_INVALID:
- case TOP_SUBMIT:
- case TOP_RESET:
- // We listen to them on the target DOM elements.
- // Some of them bubble so we don't want them to fire twice.
- break;
- default:
+ }
+ break;
+ case TOP_INVALID:
+ case TOP_SUBMIT:
+ case TOP_RESET:
+ // We listen to them on the target DOM elements.
+ // Some of them bubble so we don't want them to fire twice.
+ break;
+ default:
+ if (!mountAtListeners.hasOwnProperty(dependency)) {
// By default, listen on the top level to all non-media events.
// Media events don't bubble so adding the listener wouldn't do anything.
const isMediaEvent = mediaEventTypes.indexOf(dependency) !== -1;
if (!isMediaEvent) {
trapBubbledEvent(dependency, mountAt);
}
- break;
- }
- isListening[dependency] = true;
+ }
}
+
+ mountAtListeners[dependency] = true;
}
}
@@ -176,11 +203,11 @@ export function isListeningToAllDependencies(
registrationName: string,
mountAt: Document | Element,
) {
- const isListening = getListeningForDocument(mountAt);
+ const isListeningListeners = getListenerTrackingFor(mountAt);
const dependencies = registrationNameDependencies[registrationName];
for (let i = 0; i < dependencies.length; i++) {
const dependency = dependencies[i];
- if (!(isListening.hasOwnProperty(dependency) && isListening[dependency])) {
+ if (!isListeningListeners.hasOwnProperty(dependency)) {
return false;
}
}
diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js
index 0a464cbd559..abdb90ceb2c 100644
--- a/packages/react-dom/src/events/ReactDOMEventListener.js
+++ b/packages/react-dom/src/events/ReactDOMEventListener.js
@@ -21,6 +21,10 @@ import getEventTarget from './getEventTarget';
import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree';
import SimpleEventPlugin from './SimpleEventPlugin';
import {getRawEventName} from './DOMTopLevelEventTypes';
+import {
+ hasEventDispatched,
+ trackEventDispatch,
+} from './ReactEventDispatchTracker';
const {isInteractiveTopLevelEventType} = SimpleEventPlugin;
@@ -189,12 +193,15 @@ export function dispatchEvent(
topLevelType: DOMTopLevelEventType,
nativeEvent: AnyNativeEvent,
) {
- if (!_enabled) {
+ if (!_enabled || hasEventDispatched(nativeEvent)) {
return;
}
+ trackEventDispatch(nativeEvent);
+
const nativeEventTarget = getEventTarget(nativeEvent);
let targetInst = getClosestInstanceFromNode(nativeEventTarget);
+
if (
targetInst !== null &&
typeof targetInst.tag === 'number' &&
diff --git a/packages/react-dom/src/events/ReactEventDispatchTracker.js b/packages/react-dom/src/events/ReactEventDispatchTracker.js
new file mode 100644
index 00000000000..87f103f9b3b
--- /dev/null
+++ b/packages/react-dom/src/events/ReactEventDispatchTracker.js
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2013-present, Facebook, Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import type {AnyNativeEvent} from 'events/PluginModuleType';
+
+const lastTarget = '__$react_event_tracking:' + Math.random();
+
+type TrackableEvent = AnyNativeEvent & {
+ [lastTarget: string]: Node,
+};
+
+export function trackEventDispatch(event: TrackableEvent) {
+ event[lastTarget] = event.currentTarget;
+}
+
+export function hasEventDispatched(event: TrackableEvent): boolean {
+ if (!event.hasOwnProperty(lastTarget)) {
+ return false;
+ }
+
+ return event[lastTarget] !== event.currentTarget;
+}