Modern event system: fix selectionchange bug (#18680)
Dominic Gannaway committed
Apr 20, 2020 at 19:54 UTC
4f59a149b8dd99e0e3f2b515e15b069c276c085b
6 files changed
+105
-39
packages/react-dom/src/events/DOMEventListenerMap.js
+18
-4
@@ -43,14 +43,28 @@ export function isListeningToAllDependencies(
43
registrationName: string,
44
mountAt: Document | Element,
45
): boolean {
46
- const listenerMap = getListenerMapForElement(mountAt);
46
const dependencies = registrationNameDependencies[registrationName];
47
+ return isListeningToEvents(dependencies, mountAt);
48
+}
49
49
- for (let i = 0; i < dependencies.length; i++) {
50
- const dependency = dependencies[i];
51
- if (!listenerMap.has(dependency)) {
50
+export function isListeningToEvents(
51
+ events: Array<string>,
52
+ mountAt: Document | Element,
53
+): boolean {
54
+ const listenerMap = getListenerMapForElement(mountAt);
55
+ for (let i = 0; i < events.length; i++) {
56
+ const event = events[i];
57
+ if (!listenerMap.has(event)) {
58
return false;
59
}
60
}
61
return true;
62
}
63
+
64
+export function isListeningToEvent(
65
+ registrationName: string,
66
+ mountAt: Document | Element,
67
+): boolean {
68
+ const listenerMap = getListenerMapForElement(mountAt);
69
+ return listenerMap.has(registrationName);
70
+}
packages/react-dom/src/events/DOMModernPluginEventSystem.js
+24
-17
@@ -82,6 +82,7 @@ import {
82
TOP_CLICK,
83
TOP_BEFORE_BLUR,
84
TOP_AFTER_BLUR,
85
+ TOP_SELECTION_CHANGE,
86
} from './DOMTopLevelEventTypes';
87
import {
88
getClosestInstanceFromNode,
@@ -250,23 +251,21 @@ function dispatchEventsForPlugins(
251
252
for (let i = 0; i < plugins.length; i++) {
253
const possiblePlugin: PluginModule<AnyNativeEvent> = plugins[i];
253
- if (possiblePlugin !== undefined) {
254
- const extractedEvents = possiblePlugin.extractEvents(
255
- topLevelType,
256
- targetInst,
257
- nativeEvent,
258
- nativeEventTarget,
259
- eventSystemFlags,
260
- targetContainer,
261
- );
262
- if (isArray(extractedEvents)) {
263
- // Flow complains about @@iterator being missing in ReactSyntheticEvent,
264
- // so we cast to avoid the Flow error.
265
- const arrOfExtractedEvents = ((extractedEvents: any): Array<ReactSyntheticEvent>);
266
- syntheticEvents.push(...arrOfExtractedEvents);
267
- } else if (extractedEvents != null) {
268
- syntheticEvents.push(extractedEvents);
269
- }
254
+ const extractedEvents = possiblePlugin.extractEvents(
255
+ topLevelType,
256
+ targetInst,
257
+ nativeEvent,
258
+ nativeEventTarget,
259
+ eventSystemFlags,
260
+ targetContainer,
261
+ );
262
+ if (isArray(extractedEvents)) {
263
+ // Flow complains about @@iterator being missing in ReactSyntheticEvent,
264
+ // so we cast to avoid the Flow error.
265
+ const arrOfExtractedEvents = ((extractedEvents: any): Array<ReactSyntheticEvent>);
266
+ syntheticEvents.push(...arrOfExtractedEvents);
267
+ } else if (extractedEvents != null) {
268
+ syntheticEvents.push(extractedEvents);
269
}
270
}
271
dispatchEventsInBatch(syntheticEvents);
@@ -314,6 +313,14 @@ export function listenToTopLevelEvent(
313
capture === undefined
314
? topLevelType
315
: `${typeStr}_${capture ? 'capture' : 'bubble'}`;
316
+
317
+ // TOP_SELECTION_CHANGE needs to be attached to the document
318
+ // otherwise it won't capture incoming events that are only
319
+ // triggered on the document directly.
320
+ if (topLevelType === TOP_SELECTION_CHANGE) {
321
+ targetContainer = (targetContainer: any).ownerDocument || targetContainer;
322
+ listenerMap = getListenerMapForElement(targetContainer);
323
+ }
324
const listenerEntry = listenerMap.get(listenerMapKey);
325
const shouldUpgrade = shouldUpgradeListener(listenerEntry, passive);
326
if (listenerEntry === undefined || shouldUpgrade) {
packages/react-dom/src/events/plugins/ModernSelectEventPlugin.js
+23
-16
@@ -25,29 +25,30 @@ import getActiveElement from '../../client/getActiveElement';
25
import {getNodeFromInstance} from '../../client/ReactDOMComponentTree';
26
import {hasSelectionCapabilities} from '../../client/ReactInputSelection';
27
import {DOCUMENT_NODE} from '../../shared/HTMLNodeType';
28
-import {isListeningToAllDependencies} from '../DOMEventListenerMap';
28
+import {isListeningToEvent, isListeningToEvents} from '../DOMEventListenerMap';
29
import {accumulateTwoPhaseListeners} from '../DOMModernPluginEventSystem';
30
31
const skipSelectionChangeEvent =
32
canUseDOM && 'documentMode' in document && document.documentMode <= 11;
33
34
+const rootTargetDependencies = [
35
+ TOP_BLUR,
36
+ TOP_CONTEXT_MENU,
37
+ TOP_DRAG_END,
38
+ TOP_FOCUS,
39
+ TOP_KEY_DOWN,
40
+ TOP_KEY_UP,
41
+ TOP_MOUSE_DOWN,
42
+ TOP_MOUSE_UP,
43
+];
44
+
45
const eventTypes = {
46
select: {
47
phasedRegistrationNames: {
48
bubbled: 'onSelect',
49
captured: 'onSelectCapture',
50
},
40
- dependencies: [
41
- TOP_BLUR,
42
- TOP_CONTEXT_MENU,
43
- TOP_DRAG_END,
44
- TOP_FOCUS,
45
- TOP_KEY_DOWN,
46
- TOP_KEY_UP,
47
- TOP_MOUSE_DOWN,
48
- TOP_MOUSE_UP,
49
- TOP_SELECTION_CHANGE,
50
- ],
51
+ dependencies: [...rootTargetDependencies, TOP_SELECTION_CHANGE],
52
},
53
};
54
@@ -168,13 +169,19 @@ const SelectEventPlugin = {
169
eventSystemFlags,
170
container,
171
) {
171
- const containerOrDoc =
172
- container || getEventTargetDocument(nativeEventTarget);
172
+ const doc = getEventTargetDocument(nativeEventTarget);
173
// Track whether all listeners exists for this plugin. If none exist, we do
174
// not extract events. See #3639.
175
if (
176
- !containerOrDoc ||
177
- !isListeningToAllDependencies('onSelect', containerOrDoc)
176
+ // We only listen to TOP_SELECTION_CHANGE on the document, never the
177
+ // root.
178
+ !isListeningToEvent(TOP_SELECTION_CHANGE, doc) ||
179
+ // If we are handling TOP_SELECTION_CHANGE, then we don't need to
180
+ // check for the other dependencies, as TOP_SELECTION_CHANGE is only
181
+ // event attached from the onChange plugin and we don't expose an
182
+ // onSelectionChange event from React.
183
+ (topLevelType !== TOP_SELECTION_CHANGE &&
184
+ !isListeningToEvents(rootTargetDependencies, container))
185
) {
186
return null;
187
}
packages/react-dom/src/events/plugins/__tests__/LegacySelectEventPlugin-test.js
+19
@@ -142,4 +142,23 @@ describe('SelectEventPlugin', () => {
142
node.dispatchEvent(nativeEvent);
143
expect(select).toHaveBeenCalledTimes(1);
144
});
145
+
146
+ it('should handle selectionchange events', function() {
147
+ const onSelect = jest.fn();
148
+ const node = ReactDOM.render(
149
+ <input type="text" onSelect={onSelect} />,
150
+ container,
151
+ );
152
+ node.focus();
153
+
154
+ // Make sure the event was not called before we emit the selection change event
155
+ expect(onSelect).toHaveBeenCalledTimes(0);
156
+
157
+ // This is dispatched e.g. when using CMD+a on macOS
158
+ document.dispatchEvent(
159
+ new Event('selectionchange', {bubbles: false, cancelable: false}),
160
+ );
161
+
162
+ expect(onSelect).toHaveBeenCalledTimes(1);
163
+ });
164
});
packages/react-dom/src/events/plugins/__tests__/ModernBeforeInputEventPlugin-test.internal.js
+2
-2
@@ -21,6 +21,8 @@ describe('BeforeInputEventPlugin', () => {
21
if (envSimulator) {
22
envSimulator();
23
}
24
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
25
+ ReactFeatureFlags.enableModernEventSystem = true;
26
return require('react-dom');
27
}
28
@@ -78,8 +80,6 @@ describe('BeforeInputEventPlugin', () => {
80
}
81
82
beforeEach(() => {
81
- ReactFeatureFlags = require('shared/ReactFeatureFlags');
82
- ReactFeatureFlags.enableModernEventSystem = true;
83
React = require('react');
84
container = document.createElement('div');
85
document.body.appendChild(container);
packages/react-dom/src/events/plugins/__tests__/ModernSelectEventPlugin-test.internal.js
+19
@@ -145,4 +145,23 @@ describe('SelectEventPlugin', () => {
145
node.dispatchEvent(nativeEvent);
146
expect(select).toHaveBeenCalledTimes(1);
147
});
148
+
149
+ it('should handle selectionchange events', function() {
150
+ const onSelect = jest.fn();
151
+ const node = ReactDOM.render(
152
+ <input type="text" onSelect={onSelect} />,
153
+ container,
154
+ );
155
+ node.focus();
156
+
157
+ // Make sure the event was not called before we emit the selection change event
158
+ expect(onSelect).toHaveBeenCalledTimes(0);
159
+
160
+ // This is dispatched e.g. when using CMD+a on macOS
161
+ document.dispatchEvent(
162
+ new Event('selectionchange', {bubbles: false, cancelable: false}),
163
+ );
164
+
165
+ expect(onSelect).toHaveBeenCalledTimes(1);
166
+ });
167
});