Modern Event System: add plugin handling and forked paths (#18195)
Dominic Gannaway committed
Mar 3, 2020 at 14:37 UTC
8e6a08ea4f3beb2127ec3d1595cf5f83df5816b6
6 files changed
+368
-84
packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.internal.js
+10
-2
@@ -17,6 +17,7 @@ let ReactDOMComponentTree;
17
let listenToEvent;
18
let ReactDOMEventListener;
19
let ReactTestUtils;
20
+let ReactFeatureFlags;
21
22
let idCallOrder;
23
const recordID = function(id) {
@@ -60,13 +61,20 @@ describe('ReactBrowserEventEmitter', () => {
61
jest.resetModules();
62
LISTENER.mockClear();
63
64
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
65
EventPluginGetListener = require('legacy-events/getListener').default;
66
EventPluginRegistry = require('legacy-events/EventPluginRegistry');
67
React = require('react');
68
ReactDOM = require('react-dom');
69
ReactDOMComponentTree = require('../client/ReactDOMComponentTree');
68
- listenToEvent = require('../events/DOMLegacyEventPluginSystem')
69
- .legacyListenToEvent;
70
+ if (ReactFeatureFlags.enableModernEventSystem) {
71
+ listenToEvent = require('../events/DOMModernPluginEventSystem')
72
+ .listenToEvent;
73
+ } else {
74
+ listenToEvent = require('../events/DOMLegacyEventPluginSystem')
75
+ .legacyListenToEvent;
76
+ }
77
+
78
ReactDOMEventListener = require('../events/ReactDOMEventListener');
79
ReactTestUtils = require('react-dom/test-utils');
80
packages/react-dom/src/__tests__/ReactDOMEventListener-test.js
+64
-31
@@ -12,6 +12,7 @@
12
describe('ReactDOMEventListener', () => {
13
let React;
14
let ReactDOM;
15
+ let ReactFeatureFlags = require('shared/ReactFeatureFlags');
16
17
beforeEach(() => {
18
jest.resetModules();
@@ -19,29 +20,33 @@ describe('ReactDOMEventListener', () => {
20
ReactDOM = require('react-dom');
21
});
22
22
- it('should dispatch events from outside React tree', () => {
23
- const mock = jest.fn();
23
+ // We attached events to roots with the modern system,
24
+ // so this test is no longer valid.
25
+ if (!ReactFeatureFlags.enableModernEventSystem) {
26
+ it('should dispatch events from outside React tree', () => {
27
+ const mock = jest.fn();
28
25
- const container = document.createElement('div');
26
- const node = ReactDOM.render(<div onMouseEnter={mock} />, container);
27
- const otherNode = document.createElement('h1');
28
- document.body.appendChild(container);
29
- document.body.appendChild(otherNode);
29
+ const container = document.createElement('div');
30
+ const node = ReactDOM.render(<div onMouseEnter={mock} />, container);
31
+ const otherNode = document.createElement('h1');
32
+ document.body.appendChild(container);
33
+ document.body.appendChild(otherNode);
34
31
- try {
32
- otherNode.dispatchEvent(
33
- new MouseEvent('mouseout', {
34
- bubbles: true,
35
- cancelable: true,
36
- relatedTarget: node,
37
- }),
38
- );
39
- expect(mock).toBeCalled();
40
- } finally {
41
- document.body.removeChild(container);
42
- document.body.removeChild(otherNode);
43
- }
44
- });
35
+ try {
36
+ otherNode.dispatchEvent(
37
+ new MouseEvent('mouseout', {
38
+ bubbles: true,
39
+ cancelable: true,
40
+ relatedTarget: node,
41
+ }),
42
+ );
43
+ expect(mock).toBeCalled();
44
+ } finally {
45
+ document.body.removeChild(container);
46
+ document.body.removeChild(otherNode);
47
+ }
48
+ });
49
+ }
50
51
describe('Propagation', () => {
52
it('should propagate events one level down', () => {
@@ -189,9 +194,25 @@ describe('ReactDOMEventListener', () => {
194
// The first call schedules a render of '1' into the 'Child'.
195
// However, we're batching so it isn't flushed yet.
196
expect(mock.mock.calls[0][0]).toBe('Child');
192
- // The first call schedules a render of '2' into the 'Child'.
193
- // We're still batching so it isn't flushed yet either.
194
- expect(mock.mock.calls[1][0]).toBe('Child');
197
+ if (ReactFeatureFlags.enableModernEventSystem) {
198
+ // As we have two roots, it means we have two event listeners.
199
+ // This also means we enter the event batching phase twice,
200
+ // flushing the child to be 1.
201
+
202
+ // We don't have any good way of knowing if another event will
203
+ // occur because another event handler might invoke
204
+ // stopPropagation() along the way. After discussions internally
205
+ // with Sebastian, it seems that for now over-flushing should
206
+ // be fine, especially as the new event system is a breaking
207
+ // change anyway. We can maybe revisit this later as part of
208
+ // the work to refine this in the scheduler (maybe by leveraging
209
+ // isInputPending?).
210
+ expect(mock.mock.calls[1][0]).toBe('1');
211
+ } else {
212
+ // The first call schedules a render of '2' into the 'Child'.
213
+ // We're still batching so it isn't flushed yet either.
214
+ expect(mock.mock.calls[1][0]).toBe('Child');
215
+ }
216
// By the time we leave the handler, the second update is flushed.
217
expect(childNode.textContent).toBe('2');
218
} finally {
@@ -362,13 +383,25 @@ describe('ReactDOMEventListener', () => {
383
bubbles: false,
384
}),
385
);
365
- // Historically, we happened to not support onLoadStart
366
- // on <img>, and this test documents that lack of support.
367
- // If we decide to support it in the future, we should change
368
- // this line to expect 1 call. Note that fixing this would
369
- // be simple but would require attaching a handler to each
370
- // <img>. So far nobody asked us for it.
371
- expect(handleImgLoadStart).toHaveBeenCalledTimes(0);
386
+ if (ReactFeatureFlags.enableModernEventSystem) {
387
+ // As of the modern event system refactor, we now support
388
+ // this on <img>. The reason for this, is because we now
389
+ // attach all media events to the "root" or "portal" in the
390
+ // capture phase, rather than the bubble phase. This allows
391
+ // us to assign less event listeners to individual elements,
392
+ // which also nicely allows us to support more without needing
393
+ // to add more individual code paths to support various
394
+ // events that do not bubble.
395
+ expect(handleImgLoadStart).toHaveBeenCalledTimes(1);
396
+ } else {
397
+ // Historically, we happened to not support onLoadStart
398
+ // on <img>, and this test documents that lack of support.
399
+ // If we decide to support it in the future, we should change
400
+ // this line to expect 1 call. Note that fixing this would
401
+ // be simple but would require attaching a handler to each
402
+ // <img>. So far nobody asked us for it.
403
+ expect(handleImgLoadStart).toHaveBeenCalledTimes(0);
404
+ }
405
406
videoRef.current.dispatchEvent(
407
new ProgressEvent('loadstart', {
packages/react-dom/src/__tests__/ReactTreeTraversal-test.js
+84
-35
@@ -11,6 +11,7 @@
11
12
let React;
13
let ReactDOM;
14
+let ReactFeatureFlags = require('shared/ReactFeatureFlags');
15
16
const ChildComponent = ({id, eventHandler}) => (
17
<div
@@ -203,41 +204,89 @@ describe('ReactTreeTraversal', () => {
204
expect(mockFn.mock.calls).toEqual(expectedCalls);
205
});
206
206
- it('should enter from the window', () => {
207
- const enterNode = document.getElementById('P_P1_C1__DIV');
208
-
209
- const expectedCalls = [
210
- ['P', 'mouseenter'],
211
- ['P_P1', 'mouseenter'],
212
- ['P_P1_C1__DIV', 'mouseenter'],
213
- ];
214
-
215
- outerNode1.dispatchEvent(
216
- new MouseEvent('mouseout', {
217
- bubbles: true,
218
- cancelable: true,
219
- relatedTarget: enterNode,
220
- }),
221
- );
222
-
223
- expect(mockFn.mock.calls).toEqual(expectedCalls);
224
- });
225
-
226
- it('should enter from the window to the shallowest', () => {
227
- const enterNode = document.getElementById('P');
228
-
229
- const expectedCalls = [['P', 'mouseenter']];
230
-
231
- outerNode1.dispatchEvent(
232
- new MouseEvent('mouseout', {
233
- bubbles: true,
234
- cancelable: true,
235
- relatedTarget: enterNode,
236
- }),
237
- );
238
-
239
- expect(mockFn.mock.calls).toEqual(expectedCalls);
240
- });
207
+ // This will not work with the modern event system that
208
+ // attaches event listeners to roots as the event below
209
+ // is being triggered on a node that React does not listen
210
+ // to any more. Instead we should fire mouseover.
211
+ if (ReactFeatureFlags.enableModernEventSystem) {
212
+ it('should enter from the window', () => {
213
+ const enterNode = document.getElementById('P_P1_C1__DIV');
214
+
215
+ const expectedCalls = [
216
+ ['P', 'mouseenter'],
217
+ ['P_P1', 'mouseenter'],
218
+ ['P_P1_C1__DIV', 'mouseenter'],
219
+ ];
220
+
221
+ enterNode.dispatchEvent(
222
+ new MouseEvent('mouseover', {
223
+ bubbles: true,
224
+ cancelable: true,
225
+ relatedTarget: outerNode1,
226
+ }),
227
+ );
228
+
229
+ expect(mockFn.mock.calls).toEqual(expectedCalls);
230
+ });
231
+ } else {
232
+ it('should enter from the window', () => {
233
+ const enterNode = document.getElementById('P_P1_C1__DIV');
234
+
235
+ const expectedCalls = [
236
+ ['P', 'mouseenter'],
237
+ ['P_P1', 'mouseenter'],
238
+ ['P_P1_C1__DIV', 'mouseenter'],
239
+ ];
240
+
241
+ outerNode1.dispatchEvent(
242
+ new MouseEvent('mouseout', {
243
+ bubbles: true,
244
+ cancelable: true,
245
+ relatedTarget: enterNode,
246
+ }),
247
+ );
248
+
249
+ expect(mockFn.mock.calls).toEqual(expectedCalls);
250
+ });
251
+ }
252
+
253
+ // This will not work with the modern event system that
254
+ // attaches event listeners to roots as the event below
255
+ // is being triggered on a node that React does not listen
256
+ // to any more. Instead we should fire mouseover.
257
+ if (ReactFeatureFlags.enableModernEventSystem) {
258
+ it('should enter from the window to the shallowest', () => {
259
+ const enterNode = document.getElementById('P');
260
+
261
+ const expectedCalls = [['P', 'mouseenter']];
262
+
263
+ enterNode.dispatchEvent(
264
+ new MouseEvent('mouseover', {
265
+ bubbles: true,
266
+ cancelable: true,
267
+ relatedTarget: outerNode1,
268
+ }),
269
+ );
270
+
271
+ expect(mockFn.mock.calls).toEqual(expectedCalls);
272
+ });
273
+ } else {
274
+ it('should enter from the window to the shallowest', () => {
275
+ const enterNode = document.getElementById('P');
276
+
277
+ const expectedCalls = [['P', 'mouseenter']];
278
+
279
+ outerNode1.dispatchEvent(
280
+ new MouseEvent('mouseout', {
281
+ bubbles: true,
282
+ cancelable: true,
283
+ relatedTarget: enterNode,
284
+ }),
285
+ );
286
+
287
+ expect(mockFn.mock.calls).toEqual(expectedCalls);
288
+ });
289
+ }
290
291
it('should leave to the window', () => {
292
const leaveNode = document.getElementById('P_P1_C1__DIV');
packages/react-dom/src/events/DOMModernPluginEventSystem.js
+60
-1
@@ -11,10 +11,16 @@ import type {AnyNativeEvent} from 'legacy-events/PluginModuleType';
11
import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes';
12
import type {EventSystemFlags} from 'legacy-events/EventSystemFlags';
13
import type {Fiber} from 'react-reconciler/src/ReactFiber';
14
+import type {PluginModule} from 'legacy-events/PluginModuleType';
15
+import type {ReactSyntheticEvent} from 'legacy-events/ReactSyntheticEventType';
16
17
import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry';
18
+import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching';
19
+import {executeDispatchesInOrder} from 'legacy-events/EventPluginUtils';
20
+import {plugins} from 'legacy-events/EventPluginRegistry';
21
22
import {trapEventForPluginEventSystem} from './ReactDOMEventListener';
23
+import getEventTarget from './getEventTarget';
24
import {getListenerMapForElement} from './DOMEventListenerMap';
25
import {
26
TOP_FOCUS,
@@ -87,6 +93,49 @@ const capturePhaseEvents = new Set([
93
TOP_WAITING,
94
]);
95
96
+const isArray = Array.isArray;
97
+
98
+function dispatchEventsForPlugins(
99
+ topLevelType: DOMTopLevelEventType,
100
+ eventSystemFlags: EventSystemFlags,
101
+ nativeEvent: AnyNativeEvent,
102
+ targetInst: null | Fiber,
103
+ rootContainer: Element | Document,
104
+): void {
105
+ const nativeEventTarget = getEventTarget(nativeEvent);
106
+ const syntheticEvents: Array<ReactSyntheticEvent> = [];
107
+
108
+ for (let i = 0; i < plugins.length; i++) {
109
+ const possiblePlugin: PluginModule<AnyNativeEvent> = plugins[i];
110
+ if (possiblePlugin !== undefined) {
111
+ const extractedEvents = possiblePlugin.extractEvents(
112
+ topLevelType,
113
+ targetInst,
114
+ nativeEvent,
115
+ nativeEventTarget,
116
+ eventSystemFlags,
117
+ rootContainer,
118
+ );
119
+ if (isArray(extractedEvents)) {
120
+ // Flow complains about @@iterator being missing in ReactSyntheticEvent,
121
+ // so we cast to avoid the Flow error.
122
+ const arrOfExtractedEvents = ((extractedEvents: any): Array<ReactSyntheticEvent>);
123
+ syntheticEvents.push(...arrOfExtractedEvents);
124
+ } else if (extractedEvents != null) {
125
+ syntheticEvents.push(extractedEvents);
126
+ }
127
+ }
128
+ }
129
+ for (let i = 0; i < syntheticEvents.length; i++) {
130
+ const syntheticEvent = syntheticEvents[i];
131
+ executeDispatchesInOrder(syntheticEvent);
132
+ // Release the event from the pool if needed
133
+ if (!syntheticEvent.isPersistent()) {
134
+ syntheticEvent.constructor.release(syntheticEvent);
135
+ }
136
+ }
137
+}
138
+
139
export function listenToTopLevelEvent(
140
topLevelType: DOMTopLevelEventType,
141
rootContainerElement: Element,
@@ -123,5 +172,15 @@ export function dispatchEventForPluginEventSystem(
172
targetInst: null | Fiber,
173
rootContainer: Document | Element,
174
): void {
126
- // TODO
175
+ let ancestorInst = targetInst;
176
+
177
+ batchedEventUpdates(() =>
178
+ dispatchEventsForPlugins(
179
+ topLevelType,
180
+ eventSystemFlags,
181
+ nativeEvent,
182
+ ancestorInst,
183
+ rootContainer,
184
+ ),
185
+ );
186
}
packages/react-dom/src/events/EnterLeaveEventPlugin.js
+28
-15
@@ -22,6 +22,7 @@ import {
22
} from '../client/ReactDOMComponentTree';
23
import {HostComponent, HostText} from 'shared/ReactWorkTags';
24
import {getNearestMountedFiber} from 'react-reconciler/reflection';
25
+import {enableModernEventSystem} from 'shared/ReactFeatureFlags';
26
27
const eventTypes = {
28
mouseEnter: {
@@ -64,16 +65,26 @@ const EnterLeaveEventPlugin = {
65
const isOutEvent =
66
topLevelType === TOP_MOUSE_OUT || topLevelType === TOP_POINTER_OUT;
67
67
- if (
68
- isOverEvent &&
69
- (eventSystemFlags & IS_REPLAYED) === 0 &&
70
- (nativeEvent.relatedTarget || nativeEvent.fromElement)
71
- ) {
72
- // If this is an over event with a target, then we've already dispatched
73
- // the event in the out event of the other target. If this is replayed,
74
- // then it's because we couldn't dispatch against this target previously
75
- // so we have to do it now instead.
76
- return null;
68
+ if (isOverEvent && (eventSystemFlags & IS_REPLAYED) === 0) {
69
+ const related = nativeEvent.relatedTarget || nativeEvent.fromElement;
70
+ if (related) {
71
+ if (enableModernEventSystem) {
72
+ // Due to the fact we don't add listeners to the document with the
73
+ // modern event system and instead attach listeners to roots, we
74
+ // need to handle the over event case. To ensure this, we just need to
75
+ // make sure the node that we're coming from is managed by React.
76
+ const inst = getClosestInstanceFromNode(related);
77
+ if (inst !== null) {
78
+ return null;
79
+ }
80
+ } else {
81
+ // If this is an over event with a target, then we've already dispatched
82
+ // the event in the out event of the other target. If this is replayed,
83
+ // then it's because we couldn't dispatch against this target previously
84
+ // so we have to do it now instead.
85
+ return null;
86
+ }
87
+ }
88
}
89
90
if (!isOutEvent && !isOverEvent) {
@@ -163,11 +174,13 @@ const EnterLeaveEventPlugin = {
174
175
accumulateEnterLeaveDispatches(leave, enter, from, to);
176
166
- // If we are not processing the first ancestor, then we
167
- // should not process the same nativeEvent again, as we
168
- // will have already processed it in the first ancestor.
169
- if ((eventSystemFlags & IS_FIRST_ANCESTOR) === 0) {
170
- return [leave];
177
+ if (!enableModernEventSystem) {
178
+ // If we are not processing the first ancestor, then we
179
+ // should not process the same nativeEvent again, as we
180
+ // will have already processed it in the first ancestor.
181
+ if ((eventSystemFlags & IS_FIRST_ANCESTOR) === 0) {
182
+ return [leave];
183
+ }
184
}
185
186
return [leave, enter];
packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js
new
+122
@@ -0,0 +1,122 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @emails react-core
8
+ */
9
+
10
+'use strict';
11
+
12
+let React;
13
+let ReactFeatureFlags;
14
+let ReactDOM;
15
+
16
+function dispatchClickEvent(element) {
17
+ const event = document.createEvent('Event');
18
+ event.initEvent('click', true, true);
19
+ element.dispatchEvent(event);
20
+}
21
+
22
+describe('DOMModernPluginEventSystem', () => {
23
+ let container;
24
+
25
+ beforeEach(() => {
26
+ jest.resetModules();
27
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
28
+ ReactFeatureFlags.enableModernEventSystem = true;
29
+
30
+ React = require('react');
31
+ ReactDOM = require('react-dom');
32
+ container = document.createElement('div');
33
+ document.body.appendChild(container);
34
+ });
35
+
36
+ afterEach(() => {
37
+ document.body.removeChild(container);
38
+ container = null;
39
+ });
40
+
41
+ it('handle propagation of click events', () => {
42
+ const buttonRef = React.createRef();
43
+ const divRef = React.createRef();
44
+ const log = [];
45
+ const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
46
+ const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
47
+
48
+ function Test() {
49
+ return (
50
+ <button
51
+ ref={buttonRef}
52
+ onClick={onClick}
53
+ onClickCapture={onClickCapture}>
54
+ <div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
55
+ Click me!
56
+ </div>
57
+ </button>
58
+ );
59
+ }
60
+
61
+ ReactDOM.render(<Test />, container);
62
+
63
+ let buttonElement = buttonRef.current;
64
+ dispatchClickEvent(buttonElement);
65
+ expect(onClick).toHaveBeenCalledTimes(1);
66
+ expect(onClickCapture).toHaveBeenCalledTimes(1);
67
+ expect(log[0]).toEqual(['capture', buttonElement]);
68
+ expect(log[1]).toEqual(['bubble', buttonElement]);
69
+
70
+ let divElement = divRef.current;
71
+ dispatchClickEvent(divElement);
72
+ expect(onClick).toHaveBeenCalledTimes(3);
73
+ expect(onClickCapture).toHaveBeenCalledTimes(3);
74
+ expect(log[2]).toEqual(['capture', buttonElement]);
75
+ expect(log[3]).toEqual(['capture', divElement]);
76
+ expect(log[4]).toEqual(['bubble', divElement]);
77
+ expect(log[5]).toEqual(['bubble', buttonElement]);
78
+ });
79
+
80
+ it('handle propagation of focus events', () => {
81
+ const buttonRef = React.createRef();
82
+ const divRef = React.createRef();
83
+ const log = [];
84
+ const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget]));
85
+ const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
86
+
87
+ function Test() {
88
+ return (
89
+ <button
90
+ ref={buttonRef}
91
+ onFocus={onFocus}
92
+ onFocusCapture={onFocusCapture}>
93
+ <div
94
+ ref={divRef}
95
+ onFocus={onFocus}
96
+ onFocusCapture={onFocusCapture}
97
+ tabIndex={0}>
98
+ Click me!
99
+ </div>
100
+ </button>
101
+ );
102
+ }
103
+
104
+ ReactDOM.render(<Test />, container);
105
+
106
+ let buttonElement = buttonRef.current;
107
+ buttonElement.focus();
108
+ expect(onFocus).toHaveBeenCalledTimes(1);
109
+ expect(onFocusCapture).toHaveBeenCalledTimes(1);
110
+ expect(log[0]).toEqual(['capture', buttonElement]);
111
+ expect(log[1]).toEqual(['bubble', buttonElement]);
112
+
113
+ let divElement = divRef.current;
114
+ divElement.focus();
115
+ expect(onFocus).toHaveBeenCalledTimes(3);
116
+ expect(onFocusCapture).toHaveBeenCalledTimes(3);
117
+ expect(log[2]).toEqual(['capture', buttonElement]);
118
+ expect(log[3]).toEqual(['capture', divElement]);
119
+ expect(log[4]).toEqual(['bubble', divElement]);
120
+ expect(log[5]).toEqual(['bubble', buttonElement]);
121
+ });
122
+});