@samitouri / QOS-React-2 / commits / 65b80fdd94

[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,