[Flare] Add Input event responder surface (#16148)
Dominic Gannaway committed
Jul 17, 2019 at 21:04 UTC
65b80fdd94d8be59263a032408a2a903dbb55378
9 files changed
+1163
-2
packages/react-dom/src/events/DOMEventResponderSystem.js
+2
@@ -30,6 +30,7 @@ import {
30
discreteUpdates,
31
flushDiscreteUpdatesIfNeeded,
32
} from 'events/ReactGenericBatching';
33
+import {enqueueStateRestore} from 'events/ReactControlledComponent';
34
import type {Fiber} from 'react-reconciler/src/ReactFiber';
35
import warning from 'shared/warning';
36
import {enableFlareAPI} from 'shared/ReactFeatureFlags';
@@ -382,6 +383,7 @@ const eventResponderContext: ReactDOMResponderContext = {
383
isRespondingToHook() {
384
return currentlyInHook;
385
},
386
+ enqueueStateRestore,
387
};
388
389
function collectFocusableElements(
packages/react-dom/src/shared/ReactControlledValuePropTypes.js
+5
-2
@@ -7,6 +7,7 @@
7
8
import checkPropTypes from 'prop-types/checkPropTypes';
9
import ReactSharedInternals from 'shared/ReactSharedInternals';
10
+import {enableFlareAPI} from 'shared/ReactFeatureFlags';
11
12
let ReactDebugCurrentFrame = null;
13
@@ -34,7 +35,8 @@ if (__DEV__) {
35
props.onChange ||
36
props.readOnly ||
37
props.disabled ||
37
- props[propName] == null
38
+ props[propName] == null ||
39
+ enableFlareAPI
40
) {
41
return null;
42
}
@@ -50,7 +52,8 @@ if (__DEV__) {
52
props.onChange ||
53
props.readOnly ||
54
props.disabled ||
53
- props[propName] == null
55
+ props[propName] == null ||
56
+ enableFlareAPI
57
) {
58
return null;
59
}
packages/react-events/input.js
new
+12
@@ -0,0 +1,12 @@
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
+ * @flow
8
+ */
9
+
10
+'use strict';
11
+
12
+module.exports = require('./src/dom/Input');
packages/react-events/npm/input.js
new
+7
@@ -0,0 +1,7 @@
1
+'use strict';
2
+
3
+if (process.env.NODE_ENV === 'production') {
4
+ module.exports = require('./cjs/react-events-input.production.min.js');
5
+} else {
6
+ module.exports = require('./cjs/react-events-input.development.js');
7
+}
packages/react-events/package.json
+1
@@ -19,6 +19,7 @@
19
"drag.js",
20
"scroll.js",
21
"focus-scope.js",
22
+ "input.js",
23
"build-info.json",
24
"cjs/",
25
"umd/"
packages/react-events/src/dom/Input.js
new
+219
@@ -0,0 +1,219 @@
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
+ * @flow
8
+ */
9
+
10
+import type {
11
+ ReactDOMEventResponder,
12
+ ReactDOMResponderEvent,
13
+ ReactDOMResponderContext,
14
+} from 'shared/ReactDOMTypes';
15
+
16
+import React from 'react';
17
+import {DiscreteEvent} from 'shared/ReactTypes';
18
+
19
+type InputEventType = 'change' | 'beforechange' | 'valuechange';
20
+
21
+type InputResponderProps = {
22
+ disabled: boolean,
23
+ onBeforeChange: (e: InputEvent) => void,
24
+ onChange: (e: InputEvent) => void,
25
+ onValueChange: (value: string | boolean) => void,
26
+};
27
+
28
+type InputEvent = {|
29
+ data: string,
30
+ isComposing: boolean,
31
+ inputType: string,
32
+ dataTransfer: null | string,
33
+ target: Element | Document,
34
+ type: InputEventType,
35
+ timeStamp: number,
36
+|};
37
+
38
+const targetEventTypes = ['input', 'change', 'beforeinput', 'click'];
39
+
40
+const supportedInputTypes = new Set([
41
+ 'color',
42
+ 'date',
43
+ 'datetime',
44
+ 'datetime-local',
45
+ 'email',
46
+ 'month',
47
+ 'number',
48
+ 'password',
49
+ 'range',
50
+ 'search',
51
+ 'tel',
52
+ 'text',
53
+ 'time',
54
+ 'url',
55
+ 'week',
56
+]);
57
+
58
+function createInputEvent(
59
+ event: ReactDOMResponderEvent,
60
+ context: ReactDOMResponderContext,
61
+ type: InputEventType,
62
+ target: Document | Element,
63
+): InputEvent {
64
+ const {data, dataTransfer, inputType, isComposing} = (event: any).nativeEvent;
65
+ return {
66
+ data,
67
+ dataTransfer,
68
+ inputType,
69
+ isComposing,
70
+ target,
71
+ timeStamp: context.getTimeStamp(),
72
+ type,
73
+ };
74
+}
75
+
76
+function dispatchInputEvent(
77
+ event: ReactDOMResponderEvent,
78
+ context: ReactDOMResponderContext,
79
+ type: InputEventType,
80
+ listener: (e: any) => void,
81
+ target: Element | Document,
82
+): void {
83
+ const syntheticEvent = createInputEvent(event, context, type, target);
84
+ context.dispatchEvent(syntheticEvent, listener, DiscreteEvent);
85
+}
86
+
87
+function getNodeName(elem: Element | Document): string {
88
+ return elem.nodeName && elem.nodeName.toLowerCase();
89
+}
90
+
91
+function isTextInputElement(elem: Element | Document): boolean {
92
+ const nodeName = getNodeName(elem);
93
+ const type = (elem: any).type;
94
+ return (
95
+ nodeName === 'textarea' ||
96
+ (nodeName === 'input' && (type == null || supportedInputTypes.has(type)))
97
+ );
98
+}
99
+
100
+function isCheckable(elem: Element | Document): boolean {
101
+ const nodeName = getNodeName(elem);
102
+ const type = (elem: any).type;
103
+ return nodeName === 'input' && (type === 'checkbox' || type === 'radio');
104
+}
105
+
106
+function shouldUseChangeEvent(elem: Element | Document): boolean {
107
+ const nodeName = getNodeName(elem);
108
+ return (
109
+ nodeName === 'select' ||
110
+ (nodeName === 'input' && (elem: any).type === 'file')
111
+ );
112
+}
113
+
114
+function dispatchChangeEvent(
115
+ event: ReactDOMResponderEvent,
116
+ context: ReactDOMResponderContext,
117
+ type: InputEventType,
118
+ changeListener: (e: string | boolean) => void,
119
+ target: Element | Document,
120
+): void {
121
+ const value = getValueFromNode(target);
122
+ const listener = () => {
123
+ changeListener(value);
124
+ };
125
+ dispatchInputEvent(event, context, type, listener, target);
126
+}
127
+
128
+function dispatchBothChangeEvents(
129
+ event: ReactDOMResponderEvent,
130
+ context: ReactDOMResponderContext,
131
+ props: InputResponderProps,
132
+ target: Document | Element,
133
+): void {
134
+ const onChange = props.onChange;
135
+ context.enqueueStateRestore(target);
136
+ if (onChange) {
137
+ dispatchInputEvent(event, context, 'change', onChange, target);
138
+ }
139
+ const onValueChange = props.onValueChange;
140
+ if (onValueChange) {
141
+ dispatchChangeEvent(event, context, 'valuechange', onValueChange, target);
142
+ }
143
+}
144
+
145
+function updateValueIfChanged(elem: Element | Document): boolean {
146
+ // React's internal value tracker
147
+ const valueTracker = (elem: any)._valueTracker;
148
+ if (valueTracker == null) {
149
+ return true;
150
+ }
151
+ const prevValue = valueTracker.getValue();
152
+ const nextValue = getValueFromNode(elem);
153
+
154
+ if (prevValue !== nextValue) {
155
+ valueTracker.setValue(nextValue);
156
+ return true;
157
+ }
158
+ return false;
159
+}
160
+
161
+function getValueFromNode(node: Element | Document): string {
162
+ let value = '';
163
+ if (!node) {
164
+ return value;
165
+ }
166
+
167
+ if (isCheckable(node)) {
168
+ value = (node: any).checked ? 'true' : 'false';
169
+ } else {
170
+ value = (node: any).value;
171
+ }
172
+
173
+ return value;
174
+}
175
+
176
+const InputResponder: ReactDOMEventResponder = {
177
+ displayName: 'Input',
178
+ targetEventTypes,
179
+ onEvent(
180
+ event: ReactDOMResponderEvent,
181
+ context: ReactDOMResponderContext,
182
+ props: InputResponderProps,
183
+ ): void {
184
+ const {responderTarget, type, target} = event;
185
+
186
+ if (props.disabled) {
187
+ return;
188
+ }
189
+ if (target !== responderTarget || responderTarget === null) {
190
+ return;
191
+ }
192
+ switch (type) {
193
+ default: {
194
+ if (shouldUseChangeEvent(target) && type === 'change') {
195
+ dispatchBothChangeEvents(event, context, props, responderTarget);
196
+ } else if (
197
+ isTextInputElement(target) &&
198
+ (type === 'input' || type === 'change') &&
199
+ updateValueIfChanged(target)
200
+ ) {
201
+ dispatchBothChangeEvents(event, context, props, responderTarget);
202
+ } else if (
203
+ isCheckable(target) &&
204
+ type === 'click' &&
205
+ updateValueIfChanged(target)
206
+ ) {
207
+ dispatchBothChangeEvents(event, context, props, responderTarget);
208
+ }
209
+ break;
210
+ }
211
+ }
212
+ },
213
+};
214
+
215
+export const Input = React.unstable_createEvent(InputResponder);
216
+
217
+export function useInput(props: InputResponderProps): void {
218
+ React.unstable_useEvent(Input, props);
219
+}
packages/react-events/src/dom/__tests__/Input-test.internal.js
new
+900
@@ -0,0 +1,900 @@
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
+let Input;
16
+let Press;
17
+let Scheduler;
18
+
19
+const setUntrackedChecked = Object.getOwnPropertyDescriptor(
20
+ HTMLInputElement.prototype,
21
+ 'checked',
22
+).set;
23
+
24
+const setUntrackedValue = Object.getOwnPropertyDescriptor(
25
+ HTMLInputElement.prototype,
26
+ 'value',
27
+).set;
28
+
29
+const setUntrackedTextareaValue = Object.getOwnPropertyDescriptor(
30
+ HTMLTextAreaElement.prototype,
31
+ 'value',
32
+).set;
33
+
34
+const modulesInit = () => {
35
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
36
+ ReactFeatureFlags.enableFlareAPI = true;
37
+ ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
38
+ ReactFeatureFlags.enableUserBlockingEvents = true;
39
+ React = require('react');
40
+ ReactDOM = require('react-dom');
41
+ Scheduler = require('scheduler');
42
+ Input = require('react-events/input').Input;
43
+ Press = require('react-events/press').Press;
44
+};
45
+
46
+describe('Input event responder', () => {
47
+ let container;
48
+
49
+ beforeEach(() => {
50
+ jest.resetModules();
51
+ modulesInit();
52
+
53
+ container = document.createElement('div');
54
+ document.body.appendChild(container);
55
+ });
56
+
57
+ afterEach(() => {
58
+ document.body.removeChild(container);
59
+ container = null;
60
+ });
61
+
62
+ describe('disabled', () => {
63
+ let onChange, onValueChange, ref;
64
+
65
+ beforeEach(() => {
66
+ onChange = jest.fn();
67
+ onValueChange = jest.fn();
68
+ ref = React.createRef();
69
+ const element = (
70
+ <Input
71
+ disabled={true}
72
+ onChange={onChange}
73
+ onValueChange={onValueChange}>
74
+ <input ref={ref} />
75
+ </Input>
76
+ );
77
+ ReactDOM.render(element, container);
78
+ });
79
+
80
+ it('prevents custom events being dispatched', () => {
81
+ ref.current.dispatchEvent(
82
+ new Event('change', {bubbles: true, cancelable: true}),
83
+ );
84
+ ref.current.dispatchEvent(
85
+ new Event('input', {bubbles: true, cancelable: true}),
86
+ );
87
+ expect(onChange).not.toBeCalled();
88
+ expect(onValueChange).not.toBeCalled();
89
+ });
90
+ });
91
+
92
+ // These were taken from the original ChangeEventPlugin-test.
93
+ // They've been updated and cleaned up for React Flare.
94
+ describe('onChange', () => {
95
+ // We try to avoid firing "duplicate" React change events.
96
+ // However, to tell which events are "duplicates" and should be ignored,
97
+ // we are tracking the "current" input value, and only respect events
98
+ // that occur after it changes. In most of these tests, we verify that we
99
+ // keep track of the "current" value and only fire events when it changes.
100
+ // See https://github.com/facebook/react/pull/5746.
101
+
102
+ it('should consider initial text value to be current', () => {
103
+ let onChangeCalled = 0;
104
+ let onValueChangeCalled = 0;
105
+ const ref = React.createRef();
106
+
107
+ function onChange(e) {
108
+ onChangeCalled++;
109
+ expect(e.type).toBe('change');
110
+ }
111
+
112
+ function onValueChange(e) {
113
+ onValueChangeCalled++;
114
+ }
115
+
116
+ ReactDOM.render(
117
+ <Input onChange={onChange} onValueChange={onValueChange}>
118
+ <input type="text" ref={ref} defaultValue="foo" />
119
+ </Input>,
120
+ container,
121
+ );
122
+ ref.current.dispatchEvent(
123
+ new Event('change', {bubbles: true, cancelable: true}),
124
+ );
125
+ ref.current.dispatchEvent(
126
+ new Event('input', {bubbles: true, cancelable: true}),
127
+ );
128
+ expect(onChangeCalled).toBe(0);
129
+ expect(onValueChangeCalled).toBe(0);
130
+ });
131
+
132
+ it('should consider initial checkbox checked=true to be current', () => {
133
+ let onChangeCalled = 0;
134
+ let onValueChangeCalled = 0;
135
+ const ref = React.createRef();
136
+
137
+ function onChange(e) {
138
+ onChangeCalled++;
139
+ expect(e.type).toBe('change');
140
+ }
141
+
142
+ function onValueChange(e) {
143
+ onValueChangeCalled++;
144
+ }
145
+
146
+ ReactDOM.render(
147
+ <Input onChange={onChange} onValueChange={onValueChange}>
148
+ <input type="checkbox" ref={ref} defaultChecked={true} />
149
+ </Input>,
150
+ container,
151
+ );
152
+
153
+ // Secretly, set `checked` to false, so that dispatching the `click` will
154
+ // make it `true` again. Thus, at the time of the event, React should not
155
+ // consider it a change from the initial `true` value.
156
+ setUntrackedChecked.call(ref.current, false);
157
+ ref.current.dispatchEvent(
158
+ new MouseEvent('click', {bubbles: true, cancelable: true}),
159
+ );
160
+
161
+ // There should be no React change events because the value stayed the same.
162
+ expect(onChangeCalled).toBe(0);
163
+ expect(onValueChangeCalled).toBe(0);
164
+ });
165
+
166
+ it('should consider initial checkbox checked=false to be current', () => {
167
+ let onChangeCalled = 0;
168
+ let onValueChangeCalled = 0;
169
+ const ref = React.createRef();
170
+
171
+ function onChange(e) {
172
+ onChangeCalled++;
173
+ expect(e.type).toBe('change');
174
+ }
175
+
176
+ function onValueChange(e) {
177
+ onValueChangeCalled++;
178
+ }
179
+
180
+ ReactDOM.render(
181
+ <Input onChange={onChange} onValueChange={onValueChange}>
182
+ <input type="checkbox" ref={ref} defaultChecked={false} />
183
+ </Input>,
184
+ container,
185
+ );
186
+
187
+ // Secretly, set `checked` to true, so that dispatching the `click` will
188
+ // make it `false` again. Thus, at the time of the event, React should not
189
+ // consider it a change from the initial `false` value.
190
+ setUntrackedChecked.call(ref.current, true);
191
+ ref.current.dispatchEvent(
192
+ new MouseEvent('click', {bubbles: true, cancelable: true}),
193
+ );
194
+ // There should be no React change events because the value stayed the same.
195
+ expect(onChangeCalled).toBe(0);
196
+ expect(onValueChangeCalled).toBe(0);
197
+ });
198
+
199
+ it('should fire change for checkbox input', () => {
200
+ let onChangeCalled = 0;
201
+ let onValueChangeCalled = 0;
202
+ const ref = React.createRef();
203
+
204
+ function onChange(e) {
205
+ onChangeCalled++;
206
+ expect(e.type).toBe('change');
207
+ }
208
+
209
+ function onValueChange(e) {
210
+ onValueChangeCalled++;
211
+ }
212
+
213
+ ReactDOM.render(
214
+ <Input onChange={onChange} onValueChange={onValueChange}>
215
+ <input type="checkbox" ref={ref} />
216
+ </Input>,
217
+ container,
218
+ );
219
+
220
+ ref.current.dispatchEvent(
221
+ new MouseEvent('click', {bubbles: true, cancelable: true}),
222
+ );
223
+ // Note: unlike with text input events, dispatching `click` actually
224
+ // toggles the checkbox and updates its `checked` value.
225
+ expect(ref.current.checked).toBe(true);
226
+ expect(onChangeCalled).toBe(1);
227
+ expect(onValueChangeCalled).toBe(1);
228
+
229
+ expect(ref.current.checked).toBe(true);
230
+ ref.current.dispatchEvent(
231
+ new MouseEvent('click', {bubbles: true, cancelable: true}),
232
+ );
233
+ expect(ref.current.checked).toBe(false);
234
+ expect(onChangeCalled).toBe(2);
235
+ expect(onValueChangeCalled).toBe(2);
236
+ });
237
+
238
+ it('should not fire change setting the value programmatically', () => {
239
+ let onChangeCalled = 0;
240
+ let onValueChangeCalled = 0;
241
+ const ref = React.createRef();
242
+
243
+ function onChange(e) {
244
+ onChangeCalled++;
245
+ expect(e.type).toBe('change');
246
+ }
247
+
248
+ function onValueChange(e) {
249
+ onValueChangeCalled++;
250
+ }
251
+
252
+ ReactDOM.render(
253
+ <Input onChange={onChange} onValueChange={onValueChange}>
254
+ <input type="text" defaultValue="foo" ref={ref} />
255
+ </Input>,
256
+ container,
257
+ );
258
+
259
+ // Set it programmatically.
260
+ ref.current.value = 'bar';
261
+ // Even if a DOM input event fires, React sees that the real input value now
262
+ // ('bar') is the same as the "current" one we already recorded.
263
+ ref.current.dispatchEvent(
264
+ new Event('input', {bubbles: true, cancelable: true}),
265
+ );
266
+ expect(ref.current.value).toBe('bar');
267
+ // In this case we don't expect to get a React event.
268
+ expect(onChangeCalled).toBe(0);
269
+ expect(onValueChangeCalled).toBe(0);
270
+
271
+ // However, we can simulate user typing by calling the underlying setter.
272
+ setUntrackedValue.call(ref.current, 'foo');
273
+ // Now, when the event fires, the real input value ('foo') differs from the
274
+ // "current" one we previously recorded ('bar').
275
+ ref.current.dispatchEvent(
276
+ new Event('input', {bubbles: true, cancelable: true}),
277
+ );
278
+ expect(ref.current.value).toBe('foo');
279
+ // In this case React should fire an event for it.
280
+ expect(onChangeCalled).toBe(1);
281
+ expect(onValueChangeCalled).toBe(1);
282
+
283
+ // Verify again that extra events without real changes are ignored.
284
+ ref.current.dispatchEvent(
285
+ new Event('input', {bubbles: true, cancelable: true}),
286
+ );
287
+ expect(onChangeCalled).toBe(1);
288
+ expect(onValueChangeCalled).toBe(1);
289
+ });
290
+
291
+ it('should not distinguish equal string and number values', () => {
292
+ let onChangeCalled = 0;
293
+ let onValueChangeCalled = 0;
294
+ const ref = React.createRef();
295
+
296
+ function onChange(e) {
297
+ onChangeCalled++;
298
+ expect(e.type).toBe('change');
299
+ }
300
+
301
+ function onValueChange(e) {
302
+ onValueChangeCalled++;
303
+ }
304
+
305
+ ReactDOM.render(
306
+ <Input onChange={onChange} onValueChange={onValueChange}>
307
+ <input type="text" defaultValue="42" ref={ref} />
308
+ </Input>,
309
+ container,
310
+ );
311
+
312
+ // When we set `value` as a property, React updates the "current" value
313
+ // that it tracks internally. The "current" value is later used to determine
314
+ // whether a change event is a duplicate or not.
315
+ // Even though we set value to a number, we still shouldn't get a change
316
+ // event because as a string, it's equal to the initial value ('42').
317
+ ref.current.value = 42;
318
+ ref.current.dispatchEvent(
319
+ new Event('input', {bubbles: true, cancelable: true}),
320
+ );
321
+ expect(ref.current.value).toBe('42');
322
+ expect(onChangeCalled).toBe(0);
323
+ expect(onValueChangeCalled).toBe(0);
324
+ });
325
+
326
+ // See a similar input test above for a detailed description of why.
327
+ it('should not fire change when setting checked programmatically', () => {
328
+ let onChangeCalled = 0;
329
+ let onValueChangeCalled = 0;
330
+ const ref = React.createRef();
331
+
332
+ function onChange(e) {
333
+ onChangeCalled++;
334
+ expect(e.type).toBe('change');
335
+ }
336
+
337
+ function onValueChange(e) {
338
+ onValueChangeCalled++;
339
+ }
340
+
341
+ ReactDOM.render(
342
+ <Input onChange={onChange} onValueChange={onValueChange}>
343
+ <input type="checkbox" defaultChecked={false} ref={ref} />
344
+ </Input>,
345
+ container,
346
+ );
347
+
348
+ // Set the value, updating the "current" value that React tracks to true.
349
+ ref.current.checked = true;
350
+ // Under the hood, uncheck the box so that the click will "check" it again.
351
+ setUntrackedChecked.call(ref.current, false);
352
+ ref.current.dispatchEvent(
353
+ new MouseEvent('click', {bubbles: true, cancelable: true}),
354
+ );
355
+ expect(ref.current.checked).toBe(true);
356
+ // We don't expect a React event because at the time of the click, the real
357
+ // checked value (true) was the same as the last recorded "current" value
358
+ // (also true).
359
+ expect(onChangeCalled).toBe(0);
360
+ expect(onValueChangeCalled).toBe(0);
361
+
362
+ // However, simulating a normal click should fire a React event because the
363
+ // real value (false) would have changed from the last tracked value (true).
364
+ ref.current.dispatchEvent(
365
+ new Event('click', {bubbles: true, cancelable: true}),
366
+ );
367
+ expect(onChangeCalled).toBe(1);
368
+ expect(onValueChangeCalled).toBe(1);
369
+ });
370
+
371
+ it('should only fire change for checked radio button once', () => {
372
+ let onChangeCalled = 0;
373
+ let onValueChangeCalled = 0;
374
+ const ref = React.createRef();
375
+
376
+ function onChange(e) {
377
+ onChangeCalled++;
378
+ expect(e.type).toBe('change');
379
+ }
380
+
381
+ function onValueChange(e) {
382
+ onValueChangeCalled++;
383
+ }
384
+
385
+ ReactDOM.render(
386
+ <Input onChange={onChange} onValueChange={onValueChange}>
387
+ <input type="radio" ref={ref} />
388
+ </Input>,
389
+ container,
390
+ );
391
+
392
+ setUntrackedChecked.call(ref.current, true);
393
+ ref.current.dispatchEvent(
394
+ new Event('click', {bubbles: true, cancelable: true}),
395
+ );
396
+ ref.current.dispatchEvent(
397
+ new Event('click', {bubbles: true, cancelable: true}),
398
+ );
399
+ expect(onChangeCalled).toBe(1);
400
+ expect(onValueChangeCalled).toBe(1);
401
+ });
402
+
403
+ it('should track radio button cousins in a group', () => {
404
+ let onChangeCalled1 = 0;
405
+ let onValueChangeCalled1 = 0;
406
+ let onChangeCalled2 = 0;
407
+ let onValueChangeCalled2 = 0;
408
+ const ref = React.createRef();
409
+
410
+ function onChange1(e) {
411
+ onChangeCalled1++;
412
+ expect(e.type).toBe('change');
413
+ }
414
+
415
+ function onValueChange1(e) {
416
+ onValueChangeCalled1++;
417
+ }
418
+
419
+ function onChange2(e) {
420
+ onChangeCalled2++;
421
+ expect(e.type).toBe('change');
422
+ }
423
+
424
+ function onValueChange2(e) {
425
+ onValueChangeCalled2++;
426
+ }
427
+
428
+ ReactDOM.render(
429
+ <div ref={ref}>
430
+ <Input onChange={onChange1} onValueChange={onValueChange1}>
431
+ <input type="radio" name="group" />
432
+ </Input>
433
+ <Input onChange={onChange2} onValueChange={onValueChange2}>
434
+ <input type="radio" name="group" />
435
+ </Input>
436
+ </div>,
437
+ container,
438
+ );
439
+ const option1 = ref.current.childNodes[0];
440
+ const option2 = ref.current.childNodes[1];
441
+
442
+ // Select first option.
443
+ option1.dispatchEvent(
444
+ new Event('click', {bubbles: true, cancelable: true}),
445
+ );
446
+ expect(onChangeCalled1).toBe(1);
447
+ expect(onValueChangeCalled1).toBe(1);
448
+ expect(onChangeCalled2).toBe(0);
449
+ expect(onValueChangeCalled2).toBe(0);
450
+
451
+ // Select second option.
452
+ option2.dispatchEvent(
453
+ new Event('click', {bubbles: true, cancelable: true}),
454
+ );
455
+ expect(onChangeCalled1).toBe(1);
456
+ expect(onValueChangeCalled1).toBe(1);
457
+ expect(onChangeCalled2).toBe(1);
458
+ expect(onValueChangeCalled2).toBe(1);
459
+
460
+ // Select the first option.
461
+ // It should receive the React change event again.
462
+ option1.dispatchEvent(
463
+ new Event('click', {bubbles: true, cancelable: true}),
464
+ );
465
+ expect(onChangeCalled1).toBe(2);
466
+ expect(onValueChangeCalled1).toBe(2);
467
+ expect(onChangeCalled2).toBe(1);
468
+ expect(onValueChangeCalled2).toBe(1);
469
+ });
470
+
471
+ it('should deduplicate input value change events', () => {
472
+ let onChangeCalled = 0;
473
+ let onValueChangeCalled = 0;
474
+ const ref = React.createRef();
475
+
476
+ function onChange(e) {
477
+ onChangeCalled++;
478
+ expect(e.type).toBe('change');
479
+ }
480
+
481
+ function onValueChange(e) {
482
+ onValueChangeCalled++;
483
+ }
484
+
485
+ ['text', 'number', 'range'].forEach(type => {
486
+ onChangeCalled = 0;
487
+ onValueChangeCalled = 0;
488
+ ReactDOM.render(
489
+ <Input onChange={onChange} onValueChange={onValueChange}>
490
+ <input type={type} name="group" ref={ref} />
491
+ </Input>,
492
+ container,
493
+ );
494
+ // Should be ignored (no change):
495
+ ref.current.dispatchEvent(
496
+ new Event('change', {bubbles: true, cancelable: true}),
497
+ );
498
+ setUntrackedValue.call(ref.current, '42');
499
+ ref.current.dispatchEvent(
500
+ new Event('change', {bubbles: true, cancelable: true}),
501
+ );
502
+ // Should be ignored (no change):
503
+ ref.current.dispatchEvent(
504
+ new Event('change', {bubbles: true, cancelable: true}),
505
+ );
506
+ expect(onChangeCalled).toBe(1);
507
+ expect(onValueChangeCalled).toBe(1);
508
+ ReactDOM.unmountComponentAtNode(container);
509
+
510
+ onChangeCalled = 0;
511
+ onValueChangeCalled = 0;
512
+ ReactDOM.render(
513
+ <Input onChange={onChange} onValueChange={onValueChange}>
514
+ <input type={type} ref={ref} />
515
+ </Input>,
516
+ container,
517
+ );
518
+ // Should be ignored (no change):
519
+ ref.current.dispatchEvent(
520
+ new Event('input', {bubbles: true, cancelable: true}),
521
+ );
522
+ setUntrackedValue.call(ref.current, '42');
523
+ ref.current.dispatchEvent(
524
+ new Event('input', {bubbles: true, cancelable: true}),
525
+ );
526
+ // Should be ignored (no change):
527
+ ref.current.dispatchEvent(
528
+ new Event('input', {bubbles: true, cancelable: true}),
529
+ );
530
+ expect(onChangeCalled).toBe(1);
531
+ expect(onValueChangeCalled).toBe(1);
532
+ ReactDOM.unmountComponentAtNode(container);
533
+
534
+ onChangeCalled = 0;
535
+ onValueChangeCalled = 0;
536
+ ReactDOM.render(
537
+ <Input onChange={onChange} onValueChange={onValueChange}>
538
+ <input type={type} ref={ref} />
539
+ </Input>,
540
+ container,
541
+ );
542
+ // Should be ignored (no change):
543
+ ref.current.dispatchEvent(
544
+ new Event('change', {bubbles: true, cancelable: true}),
545
+ );
546
+ setUntrackedValue.call(ref.current, '42');
547
+ ref.current.dispatchEvent(
548
+ new Event('input', {bubbles: true, cancelable: true}),
549
+ );
550
+ // Should be ignored (no change):
551
+ ref.current.dispatchEvent(
552
+ new Event('change', {bubbles: true, cancelable: true}),
553
+ );
554
+ expect(onChangeCalled).toBe(1);
555
+ expect(onValueChangeCalled).toBe(1);
556
+ ReactDOM.unmountComponentAtNode(container);
557
+ });
558
+ });
559
+
560
+ it('should listen for both change and input events when supported', () => {
561
+ let onChangeCalled = 0;
562
+ let onValueChangeCalled = 0;
563
+ const ref = React.createRef();
564
+
565
+ function onChange(e) {
566
+ onChangeCalled++;
567
+ expect(e.type).toBe('change');
568
+ }
569
+
570
+ function onValueChange(e) {
571
+ onValueChangeCalled++;
572
+ }
573
+
574
+ ReactDOM.render(
575
+ <Input onChange={onChange} onValueChange={onValueChange}>
576
+ <input type="range" ref={ref} />
577
+ </Input>,
578
+ container,
579
+ );
580
+
581
+ setUntrackedValue.call(ref.current, 10);
582
+ ref.current.dispatchEvent(
583
+ new Event('input', {bubbles: true, cancelable: true}),
584
+ );
585
+
586
+ setUntrackedValue.call(ref.current, 20);
587
+ ref.current.dispatchEvent(
588
+ new Event('change', {bubbles: true, cancelable: true}),
589
+ );
590
+
591
+ expect(onChangeCalled).toBe(2);
592
+ expect(onValueChangeCalled).toBe(2);
593
+ });
594
+
595
+ it('should only fire events when the value changes for range inputs', () => {
596
+ let onChangeCalled = 0;
597
+ let onValueChangeCalled = 0;
598
+ const ref = React.createRef();
599
+
600
+ function onChange(e) {
601
+ onChangeCalled++;
602
+ expect(e.type).toBe('change');
603
+ }
604
+
605
+ function onValueChange(e) {
606
+ onValueChangeCalled++;
607
+ }
608
+
609
+ ReactDOM.render(
610
+ <Input onChange={onChange} onValueChange={onValueChange}>
611
+ <input type="range" ref={ref} />
612
+ </Input>,
613
+ container,
614
+ );
615
+ setUntrackedValue.call(ref.current, '40');
616
+ ref.current.dispatchEvent(
617
+ new Event('input', {bubbles: true, cancelable: true}),
618
+ );
619
+ ref.current.dispatchEvent(
620
+ new Event('change', {bubbles: true, cancelable: true}),
621
+ );
622
+
623
+ setUntrackedValue.call(ref.current, 'foo');
624
+ ref.current.dispatchEvent(
625
+ new Event('input', {bubbles: true, cancelable: true}),
626
+ );
627
+ ref.current.dispatchEvent(
628
+ new Event('change', {bubbles: true, cancelable: true}),
629
+ );
630
+
631
+ expect(onChangeCalled).toBe(2);
632
+ expect(onValueChangeCalled).toBe(2);
633
+ });
634
+
635
+ it('does not crash for nodes with custom value property', () => {
636
+ let originalCreateElement;
637
+ // https://github.com/facebook/react/issues/10196
638
+ try {
639
+ originalCreateElement = document.createElement;
640
+ document.createElement = function() {
641
+ const node = originalCreateElement.apply(this, arguments);
642
+ Object.defineProperty(node, 'value', {
643
+ get() {},
644
+ set() {},
645
+ });
646
+ return node;
647
+ };
648
+ const ref = React.createRef();
649
+ const div = document.createElement('div');
650
+ // Mount
651
+ ReactDOM.render(
652
+ <Input>
653
+ <input type="text" ref={ref} />
654
+ </Input>,
655
+ div,
656
+ );
657
+ // Update
658
+ ReactDOM.render(
659
+ <Input>
660
+ <input type="text" ref={ref} />
661
+ </Input>,
662
+ div,
663
+ );
664
+ // Change
665
+ ref.current.dispatchEvent(
666
+ new Event('change', {bubbles: true, cancelable: true}),
667
+ );
668
+ // Unmount
669
+ ReactDOM.unmountComponentAtNode(div);
670
+ } finally {
671
+ document.createElement = originalCreateElement;
672
+ }
673
+ });
674
+
675
+ describe('concurrent mode', () => {
676
+ it('text input', () => {
677
+ const root = ReactDOM.unstable_createRoot(container);
678
+ let input;
679
+
680
+ let ops = [];
681
+
682
+ class ControlledInput extends React.Component {
683
+ state = {value: 'initial'};
684
+ onChange = event => this.setState({value: event.target.value});
685
+ render() {
686
+ ops.push(`render: ${this.state.value}`);
687
+ const controlledValue =
688
+ this.state.value === 'changed' ? 'changed [!]' : this.state.value;
689
+ return (
690
+ <Input onChange={this.onChange}>
691
+ <input
692
+ type="text"
693
+ ref={el => (input = el)}
694
+ value={controlledValue}
695
+ />
696
+ </Input>
697
+ );
698
+ }
699
+ }
700
+
701
+ // Initial mount. Test that this is async.
702
+ root.render(<ControlledInput />);
703
+ // Should not have flushed yet.
704
+ expect(ops).toEqual([]);
705
+ expect(input).toBe(undefined);
706
+ // Flush callbacks.
707
+ Scheduler.unstable_flushAll();
708
+ expect(ops).toEqual(['render: initial']);
709
+ expect(input.value).toBe('initial');
710
+
711
+ ops = [];
712
+
713
+ // Trigger a change event.
714
+ setUntrackedValue.call(input, 'changed');
715
+ input.dispatchEvent(
716
+ new Event('input', {bubbles: true, cancelable: true}),
717
+ );
718
+ // Change should synchronously flush
719
+ expect(ops).toEqual(['render: changed']);
720
+ // Value should be the controlled value, not the original one
721
+ expect(input.value).toBe('changed [!]');
722
+ });
723
+
724
+ it('checkbox input', () => {
725
+ const root = ReactDOM.unstable_createRoot(container);
726
+ let input;
727
+
728
+ let ops = [];
729
+
730
+ class ControlledInput extends React.Component {
731
+ state = {checked: false};
732
+ onChange = event => {
733
+ this.setState({checked: event.target.checked});
734
+ };
735
+ render() {
736
+ ops.push(`render: ${this.state.checked}`);
737
+ const controlledValue = this.props.reverse
738
+ ? !this.state.checked
739
+ : this.state.checked;
740
+ return (
741
+ <Input onChange={this.onChange}>
742
+ <input
743
+ type="checkbox"
744
+ ref={el => (input = el)}
745
+ checked={controlledValue}
746
+ />
747
+ </Input>
748
+ );
749
+ }
750
+ }
751
+
752
+ // Initial mount. Test that this is async.
753
+ root.render(<ControlledInput reverse={false} />);
754
+ // Should not have flushed yet.
755
+ expect(ops).toEqual([]);
756
+ expect(input).toBe(undefined);
757
+ // Flush callbacks.
758
+ Scheduler.unstable_flushAll();
759
+ expect(ops).toEqual(['render: false']);
760
+ expect(input.checked).toBe(false);
761
+
762
+ ops = [];
763
+
764
+ // Trigger a change event.
765
+ input.dispatchEvent(
766
+ new MouseEvent('click', {bubbles: true, cancelable: true}),
767
+ );
768
+ // Change should synchronously flush
769
+ expect(ops).toEqual(['render: true']);
770
+ expect(input.checked).toBe(true);
771
+
772
+ // Now let's make sure we're using the controlled value.
773
+ root.render(<ControlledInput reverse={true} />);
774
+ Scheduler.unstable_flushAll();
775
+
776
+ ops = [];
777
+
778
+ // Trigger another change event.
779
+ input.dispatchEvent(
780
+ new MouseEvent('click', {bubbles: true, cancelable: true}),
781
+ );
782
+ // Change should synchronously flush
783
+ expect(ops).toEqual(['render: true']);
784
+ expect(input.checked).toBe(false);
785
+ });
786
+
787
+ it('textarea', () => {
788
+ const root = ReactDOM.unstable_createRoot(container);
789
+ let textarea;
790
+
791
+ let ops = [];
792
+
793
+ class ControlledTextarea extends React.Component {
794
+ state = {value: 'initial'};
795
+ onChange = event => this.setState({value: event.target.value});
796
+ render() {
797
+ ops.push(`render: ${this.state.value}`);
798
+ const controlledValue =
799
+ this.state.value === 'changed' ? 'changed [!]' : this.state.value;
800
+ return (
801
+ <Input onChange={this.onChange}>
802
+ <textarea
803
+ type="text"
804
+ ref={el => (textarea = el)}
805
+ value={controlledValue}
806
+ />
807
+ </Input>
808
+ );
809
+ }
810
+ }
811
+
812
+ // Initial mount. Test that this is async.
813
+ root.render(<ControlledTextarea />);
814
+ // Should not have flushed yet.
815
+ expect(ops).toEqual([]);
816
+ expect(textarea).toBe(undefined);
817
+ // Flush callbacks.
818
+ Scheduler.unstable_flushAll();
819
+ expect(ops).toEqual(['render: initial']);
820
+ expect(textarea.value).toBe('initial');
821
+
822
+ ops = [];
823
+
824
+ // Trigger a change event.
825
+ setUntrackedTextareaValue.call(textarea, 'changed');
826
+ textarea.dispatchEvent(
827
+ new Event('input', {bubbles: true, cancelable: true}),
828
+ );
829
+ // Change should synchronously flush
830
+ expect(ops).toEqual(['render: changed']);
831
+ // Value should be the controlled value, not the original one
832
+ expect(textarea.value).toBe('changed [!]');
833
+ });
834
+
835
+ it('is async for non-input events', () => {
836
+ const root = ReactDOM.unstable_createRoot(container);
837
+ let input;
838
+
839
+ let ops = [];
840
+
841
+ class ControlledInput extends React.Component {
842
+ state = {value: 'initial'};
843
+ onChange = event => this.setState({value: event.target.value});
844
+ reset = () => {
845
+ this.setState({value: ''});
846
+ };
847
+ render() {
848
+ ops.push(`render: ${this.state.value}`);
849
+ const controlledValue =
850
+ this.state.value === 'changed' ? 'changed [!]' : this.state.value;
851
+ return (
852
+ <Press onPress={this.reset}>
853
+ <Input onChange={this.onChange}>
854
+ <input
855
+ type="text"
856
+ ref={el => (input = el)}
857
+ value={controlledValue}
858
+ />
859
+ </Input>
860
+ </Press>
861
+ );
862
+ }
863
+ }
864
+
865
+ // Initial mount. Test that this is async.
866
+ root.render(<ControlledInput />);
867
+ // Should not have flushed yet.
868
+ expect(ops).toEqual([]);
869
+ expect(input).toBe(undefined);
870
+ // Flush callbacks.
871
+ Scheduler.unstable_flushAll();
872
+ expect(ops).toEqual(['render: initial']);
873
+ expect(input.value).toBe('initial');
874
+
875
+ ops = [];
876
+
877
+ // Trigger a click event
878
+ input.dispatchEvent(
879
+ new MouseEvent('mousedown', {bubbles: true, cancelable: true}),
880
+ );
881
+ input.dispatchEvent(
882
+ new MouseEvent('mouseup', {bubbles: true, cancelable: true}),
883
+ );
884
+ // Nothing should have changed
885
+ expect(ops).toEqual([]);
886
+ expect(input.value).toBe('initial');
887
+
888
+ // Flush callbacks.
889
+ Scheduler.unstable_flushAll();
890
+ // Now the click update has flushed.
891
+ expect(ops).toEqual(['render: ']);
892
+ expect(input.value).toBe('');
893
+ });
894
+ });
895
+ });
896
+
897
+ it('expect displayName to show up for event component', () => {
898
+ expect(Input.responder.displayName).toBe('Input');
899
+ });
900
+});
packages/shared/ReactDOMTypes.js
+2
@@ -74,4 +74,6 @@ export type ReactDOMResponderContext = {
74
) => boolean,
75
continueLocalPropagation(): void,
76
isRespondingToHook(): boolean,
77
+ // Used for controller components
78
+ enqueueStateRestore(Element | Document): void,
79
};
scripts/rollup/bundles.js
+15
@@ -509,6 +509,21 @@ const bundles = [
509
externals: ['react'],
510
},
511
512
+ {
513
+ bundleTypes: [
514
+ UMD_DEV,
515
+ UMD_PROD,
516
+ NODE_DEV,
517
+ NODE_PROD,
518
+ FB_WWW_DEV,
519
+ FB_WWW_PROD,
520
+ ],
521
+ moduleType: NON_FIBER_RENDERER,
522
+ entry: 'react-events/input',
523
+ global: 'ReactEventsInput',
524
+ externals: ['react'],
525
+ },
526
+
527
{
528
bundleTypes: [
529
UMD_DEV,