main
js 875 lines 26.4 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and 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 ReactDOM;
14 let ReactDOMClient;
15 let Scheduler;
16 let act;
17 let waitForAll;
18 let waitForDiscrete;
19 let assertLog;
20
21 const setUntrackedChecked = Object.getOwnPropertyDescriptor(
22 HTMLInputElement.prototype,
23 'checked',
24 ).set;
25
26 const setUntrackedValue = Object.getOwnPropertyDescriptor(
27 HTMLInputElement.prototype,
28 'value',
29 ).set;
30
31 const setUntrackedTextareaValue = Object.getOwnPropertyDescriptor(
32 HTMLTextAreaElement.prototype,
33 'value',
34 ).set;
35
36 describe('ChangeEventPlugin', () => {
37 let container;
38
39 beforeEach(() => {
40 jest.resetModules();
41 // TODO pull this into helper method, reduce repetition.
42 // mock the browser APIs which are used in schedule:
43 // - calling 'window.postMessage' should actually fire postmessage handlers
44 const originalAddEventListener = global.addEventListener;
45 let postMessageCallback;
46 global.addEventListener = function (eventName, callback, useCapture) {
47 if (eventName === 'message') {
48 postMessageCallback = callback;
49 } else {
50 originalAddEventListener(eventName, callback, useCapture);
51 }
52 };
53 global.postMessage = function (messageKey, targetOrigin) {
54 const postMessageEvent = {source: window, data: messageKey};
55 if (postMessageCallback) {
56 postMessageCallback(postMessageEvent);
57 }
58 };
59 React = require('react');
60 ReactDOM = require('react-dom');
61 ReactDOMClient = require('react-dom/client');
62 act = require('internal-test-utils').act;
63 Scheduler = require('scheduler');
64
65 const InternalTestUtils = require('internal-test-utils');
66 waitForAll = InternalTestUtils.waitForAll;
67 waitForDiscrete = InternalTestUtils.waitForDiscrete;
68 assertLog = InternalTestUtils.assertLog;
69
70 container = document.createElement('div');
71 document.body.appendChild(container);
72 });
73
74 afterEach(() => {
75 document.body.removeChild(container);
76 container = null;
77 });
78
79 // We try to avoid firing "duplicate" React change events.
80 // However, to tell which events are "duplicates" and should be ignored,
81 // we are tracking the "current" input value, and only respect events
82 // that occur after it changes. In most of these tests, we verify that we
83 // keep track of the "current" value and only fire events when it changes.
84 // See https://github.com/facebook/react/pull/5746.
85
86 it('should consider initial text value to be current', async () => {
87 let called = 0;
88
89 function cb(e) {
90 called++;
91 expect(e.type).toBe('change');
92 }
93
94 const root = ReactDOMClient.createRoot(container);
95 await act(() => {
96 root.render(<input type="text" onChange={cb} defaultValue="foo" />);
97 });
98
99 const node = container.firstChild;
100 node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
101 node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
102
103 // There should be no React change events because the value stayed the same.
104 expect(called).toBe(0);
105 });
106
107 it('should consider initial text value to be current (capture)', async () => {
108 let called = 0;
109
110 function cb(e) {
111 called++;
112 expect(e.type).toBe('change');
113 }
114
115 const root = ReactDOMClient.createRoot(container);
116 await act(() => {
117 root.render(
118 <input type="text" onChangeCapture={cb} defaultValue="foo" />,
119 );
120 });
121
122 const node = container.firstChild;
123 node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
124 node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
125
126 // There should be no React change events because the value stayed the same.
127 expect(called).toBe(0);
128 });
129
130 it('should not invoke a change event for textarea same value', async () => {
131 let called = 0;
132
133 function cb(e) {
134 called++;
135 expect(e.type).toBe('change');
136 }
137
138 const root = ReactDOMClient.createRoot(container);
139 await act(() => {
140 root.render(<textarea onChange={cb} defaultValue="initial" />);
141 });
142
143 const node = container.firstChild;
144 node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
145 node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
146 // There should be no React change events because the value stayed the same.
147 expect(called).toBe(0);
148 });
149
150 it('should not invoke a change event for textarea same value (capture)', async () => {
151 let called = 0;
152
153 function cb(e) {
154 called++;
155 expect(e.type).toBe('change');
156 }
157
158 const root = ReactDOMClient.createRoot(container);
159 await act(() => {
160 root.render(<textarea onChangeCapture={cb} defaultValue="initial" />);
161 });
162
163 const node = container.firstChild;
164 node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
165 node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
166 // There should be no React change events because the value stayed the same.
167 expect(called).toBe(0);
168 });
169
170 it('should consider initial checkbox checked=true to be current', async () => {
171 let called = 0;
172
173 function cb(e) {
174 called++;
175 expect(e.type).toBe('change');
176 }
177
178 const root = ReactDOMClient.createRoot(container);
179 await act(() => {
180 root.render(
181 <input type="checkbox" onChange={cb} defaultChecked={true} />,
182 );
183 });
184
185 const node = container.firstChild;
186
187 // Secretly, set `checked` to false, so that dispatching the `click` will
188 // make it `true` again. Thus, at the time of the event, React should not
189 // consider it a change from the initial `true` value.
190 setUntrackedChecked.call(node, false);
191 node.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(called).toBe(0);
196 });
197
198 it('should consider initial checkbox checked=false to be current', async () => {
199 let called = 0;
200
201 function cb(e) {
202 called++;
203 expect(e.type).toBe('change');
204 }
205
206 const root = ReactDOMClient.createRoot(container);
207 await act(() => {
208 root.render(
209 <input type="checkbox" onChange={cb} defaultChecked={false} />,
210 );
211 });
212
213 const node = container.firstChild;
214
215 // Secretly, set `checked` to true, so that dispatching the `click` will
216 // make it `false` again. Thus, at the time of the event, React should not
217 // consider it a change from the initial `false` value.
218 setUntrackedChecked.call(node, true);
219 node.dispatchEvent(
220 new MouseEvent('click', {bubbles: true, cancelable: true}),
221 );
222 // There should be no React change events because the value stayed the same.
223 expect(called).toBe(0);
224 });
225
226 it('should fire change for checkbox input', async () => {
227 let called = 0;
228
229 function cb(e) {
230 called++;
231 expect(e.type).toBe('change');
232 }
233
234 const root = ReactDOMClient.createRoot(container);
235 await act(() => {
236 root.render(<input type="checkbox" onChange={cb} />);
237 });
238
239 const node = container.firstChild;
240
241 expect(node.checked).toBe(false);
242 node.dispatchEvent(
243 new MouseEvent('click', {bubbles: true, cancelable: true}),
244 );
245 // Note: unlike with text input events, dispatching `click` actually
246 // toggles the checkbox and updates its `checked` value.
247 expect(node.checked).toBe(true);
248 expect(called).toBe(1);
249
250 expect(node.checked).toBe(true);
251 node.dispatchEvent(
252 new MouseEvent('click', {bubbles: true, cancelable: true}),
253 );
254 expect(node.checked).toBe(false);
255 expect(called).toBe(2);
256 });
257
258 it('should not fire change setting the value programmatically', async () => {
259 let called = 0;
260
261 function cb(e) {
262 called++;
263 expect(e.type).toBe('change');
264 }
265
266 const root = ReactDOMClient.createRoot(container);
267 await act(() => {
268 root.render(<input type="text" defaultValue="foo" onChange={cb} />);
269 });
270
271 const input = container.firstChild;
272
273 // Set it programmatically.
274 input.value = 'bar';
275 // Even if a DOM input event fires, React sees that the real input value now
276 // ('bar') is the same as the "current" one we already recorded.
277 input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
278 expect(input.value).toBe('bar');
279 // In this case we don't expect to get a React event.
280 expect(called).toBe(0);
281
282 // However, we can simulate user typing by calling the underlying setter.
283 setUntrackedValue.call(input, 'foo');
284 // Now, when the event fires, the real input value ('foo') differs from the
285 // "current" one we previously recorded ('bar').
286 input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
287 expect(input.value).toBe('foo');
288 // In this case React should fire an event for it.
289 expect(called).toBe(1);
290
291 // Verify again that extra events without real changes are ignored.
292 input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
293 expect(called).toBe(1);
294 });
295
296 it('should not distinguish equal string and number values', async () => {
297 let called = 0;
298
299 function cb(e) {
300 called++;
301 expect(e.type).toBe('change');
302 }
303
304 const root = ReactDOMClient.createRoot(container);
305 await act(() => {
306 root.render(<input type="text" defaultValue="42" onChange={cb} />);
307 });
308
309 const input = container.firstChild;
310
311 // When we set `value` as a property, React updates the "current" value
312 // that it tracks internally. The "current" value is later used to determine
313 // whether a change event is a duplicate or not.
314 // Even though we set value to a number, we still shouldn't get a change
315 // event because as a string, it's equal to the initial value ('42').
316 input.value = 42;
317 input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
318 expect(input.value).toBe('42');
319 expect(called).toBe(0);
320 });
321
322 // See a similar input test above for a detailed description of why.
323 it('should not fire change when setting checked programmatically', async () => {
324 let called = 0;
325
326 function cb(e) {
327 called++;
328 expect(e.type).toBe('change');
329 }
330
331 const root = ReactDOMClient.createRoot(container);
332 await act(() => {
333 root.render(
334 <input type="checkbox" onChange={cb} defaultChecked={false} />,
335 );
336 });
337
338 const input = container.firstChild;
339
340 // Set the value, updating the "current" value that React tracks to true.
341 input.checked = true;
342 // Under the hood, uncheck the box so that the click will "check" it again.
343 setUntrackedChecked.call(input, false);
344 input.click();
345 expect(input.checked).toBe(true);
346 // We don't expect a React event because at the time of the click, the real
347 // checked value (true) was the same as the last recorded "current" value
348 // (also true).
349 expect(called).toBe(0);
350
351 // However, simulating a normal click should fire a React event because the
352 // real value (false) would have changed from the last tracked value (true).
353 input.click();
354 expect(called).toBe(1);
355 });
356
357 it('should unmount', async () => {
358 const root = ReactDOMClient.createRoot(container);
359 await act(() => {
360 root.render(<input />);
361 });
362
363 const input = container.firstChild;
364
365 await act(() => {
366 root.unmount();
367 });
368 });
369
370 it('should only fire change for checked radio button once', async () => {
371 let called = 0;
372
373 function cb(e) {
374 called++;
375 expect(e.type).toBe('change');
376 }
377
378 const root = ReactDOMClient.createRoot(container);
379 await act(() => {
380 root.render(<input type="radio" onChange={cb} />);
381 });
382
383 const input = container.firstChild;
384
385 setUntrackedChecked.call(input, true);
386 input.dispatchEvent(new Event('click', {bubbles: true, cancelable: true}));
387 input.dispatchEvent(new Event('click', {bubbles: true, cancelable: true}));
388 expect(called).toBe(1);
389 });
390
391 it('should track radio button cousins in a group', async () => {
392 let called1 = 0;
393 let called2 = 0;
394
395 function cb1(e) {
396 called1++;
397 expect(e.type).toBe('change');
398 }
399
400 function cb2(e) {
401 called2++;
402 expect(e.type).toBe('change');
403 }
404
405 const root = ReactDOMClient.createRoot(container);
406 await act(() => {
407 root.render(
408 <div>
409 <input type="radio" name="group" onChange={cb1} />
410 <input type="radio" name="group" onChange={cb2} />
411 </div>,
412 );
413 });
414
415 const div = container.firstChild;
416 const option1 = div.childNodes[0];
417 const option2 = div.childNodes[1];
418
419 // Select first option.
420 option1.click();
421 expect(called1).toBe(1);
422 expect(called2).toBe(0);
423
424 // Select second option.
425 option2.click();
426 expect(called1).toBe(1);
427 expect(called2).toBe(1);
428
429 // Select the first option.
430 // It should receive the React change event again.
431 option1.click();
432 expect(called1).toBe(2);
433 expect(called2).toBe(1);
434 });
435
436 it('should deduplicate input value change events', async () => {
437 let called = 0;
438
439 function cb(e) {
440 called++;
441 expect(e.type).toBe('change');
442 }
443
444 const inputTypes = ['text', 'number', 'range'];
445 while (inputTypes.length) {
446 const type = inputTypes.pop();
447 called = 0;
448 let root = ReactDOMClient.createRoot(container);
449 let ref = {current: null};
450 await act(() => {
451 root.render(<input ref={ref} type={type} onChange={cb} />);
452 });
453 let input = ref.current;
454 await act(() => {
455 // Should be ignored (no change):
456 input.dispatchEvent(
457 new Event('change', {bubbles: true, cancelable: true}),
458 );
459 setUntrackedValue.call(input, '42');
460 input.dispatchEvent(
461 new Event('change', {bubbles: true, cancelable: true}),
462 );
463 // Should be ignored (no change):
464 input.dispatchEvent(
465 new Event('change', {bubbles: true, cancelable: true}),
466 );
467 });
468 expect(called).toBe(1);
469 root.unmount();
470
471 called = 0;
472 root = ReactDOMClient.createRoot(container);
473 ref = {current: null};
474 await act(() => {
475 root.render(<input ref={ref} type={type} onChange={cb} />);
476 });
477 input = ref.current;
478 await act(() => {
479 // Should be ignored (no change):
480 input.dispatchEvent(
481 new Event('input', {bubbles: true, cancelable: true}),
482 );
483 setUntrackedValue.call(input, '42');
484 input.dispatchEvent(
485 new Event('input', {bubbles: true, cancelable: true}),
486 );
487 // Should be ignored (no change):
488 input.dispatchEvent(
489 new Event('input', {bubbles: true, cancelable: true}),
490 );
491 });
492 expect(called).toBe(1);
493 root.unmount();
494
495 called = 0;
496 root = ReactDOMClient.createRoot(container);
497 ref = {current: null};
498 await act(() => {
499 root.render(<input ref={ref} type={type} onChange={cb} />);
500 });
501 input = ref.current;
502 await act(() => {
503 // Should be ignored (no change):
504 input.dispatchEvent(
505 new Event('change', {bubbles: true, cancelable: true}),
506 );
507 setUntrackedValue.call(input, '42');
508 input.dispatchEvent(
509 new Event('input', {bubbles: true, cancelable: true}),
510 );
511 // Should be ignored (no change):
512 input.dispatchEvent(
513 new Event('change', {bubbles: true, cancelable: true}),
514 );
515 });
516 expect(called).toBe(1);
517 root.unmount();
518 }
519 });
520
521 it('should listen for both change and input events when supported', async () => {
522 let called = 0;
523
524 function cb(e) {
525 called++;
526 expect(e.type).toBe('change');
527 }
528
529 const root = ReactDOMClient.createRoot(container);
530 await act(() => {
531 root.render(<input type="range" onChange={cb} />);
532 });
533
534 const input = container.firstChild;
535
536 setUntrackedValue.call(input, 10);
537 input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
538
539 setUntrackedValue.call(input, 20);
540 input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
541
542 expect(called).toBe(2);
543 });
544
545 it('should only fire events when the value changes for range inputs', async () => {
546 let called = 0;
547
548 function cb(e) {
549 called++;
550 expect(e.type).toBe('change');
551 }
552
553 const root = ReactDOMClient.createRoot(container);
554 await act(() => {
555 root.render(<input type="range" onChange={cb} />);
556 });
557
558 const input = container.firstChild;
559 setUntrackedValue.call(input, '40');
560 input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
561 input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
562
563 setUntrackedValue.call(input, 'foo');
564 input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
565 input.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
566
567 expect(called).toBe(2);
568 });
569
570 it('does not crash for nodes with custom value property', async () => {
571 let originalCreateElement;
572 // https://github.com/facebook/react/issues/10196
573 try {
574 originalCreateElement = document.createElement;
575 document.createElement = function () {
576 const node = originalCreateElement.apply(this, arguments);
577 Object.defineProperty(node, 'value', {
578 get() {},
579 set() {},
580 });
581 return node;
582 };
583 const div = document.createElement('div');
584 const root = ReactDOMClient.createRoot(div);
585 // Mount
586 await act(() => {
587 root.render(<input type="text" />);
588 });
589 const node = div.firstChild;
590 // Update
591 await act(() => {
592 root.render(<input type="text" />);
593 });
594
595 // Change
596 node.dispatchEvent(
597 new Event('change', {bubbles: true, cancelable: true}),
598 );
599 // Unmount
600 await act(() => {
601 root.unmount();
602 });
603 } finally {
604 document.createElement = originalCreateElement;
605 }
606 });
607
608 describe('concurrent mode', () => {
609 it('text input', async () => {
610 const root = ReactDOMClient.createRoot(container);
611 let input;
612
613 class ControlledInput extends React.Component {
614 state = {value: 'initial'};
615 onChange = event => this.setState({value: event.target.value});
616 render() {
617 Scheduler.log(`render: ${this.state.value}`);
618 const controlledValue =
619 this.state.value === 'changed' ? 'changed [!]' : this.state.value;
620 return (
621 <input
622 ref={el => (input = el)}
623 type="text"
624 value={controlledValue}
625 onChange={this.onChange}
626 />
627 );
628 }
629 }
630
631 // Initial mount. Test that this is async.
632 root.render(<ControlledInput />);
633 // Should not have flushed yet.
634 assertLog([]);
635 expect(input).toBe(undefined);
636 // Flush callbacks.
637 await waitForAll(['render: initial']);
638 expect(input.value).toBe('initial');
639
640 // Trigger a change event.
641 setUntrackedValue.call(input, 'changed');
642 input.dispatchEvent(
643 new Event('input', {bubbles: true, cancelable: true}),
644 );
645 // Change should synchronously flush
646 assertLog(['render: changed']);
647 // Value should be the controlled value, not the original one
648 expect(input.value).toBe('changed [!]');
649 });
650
651 it('checkbox input', async () => {
652 const root = ReactDOMClient.createRoot(container);
653 let input;
654
655 class ControlledInput extends React.Component {
656 state = {checked: false};
657 onChange = event => {
658 this.setState({checked: event.target.checked});
659 };
660 render() {
661 Scheduler.log(`render: ${this.state.checked}`);
662 const controlledValue = this.props.reverse
663 ? !this.state.checked
664 : this.state.checked;
665 return (
666 <input
667 ref={el => (input = el)}
668 type="checkbox"
669 checked={controlledValue}
670 onChange={this.onChange}
671 />
672 );
673 }
674 }
675
676 // Initial mount. Test that this is async.
677 root.render(<ControlledInput reverse={false} />);
678 // Should not have flushed yet.
679 assertLog([]);
680 expect(input).toBe(undefined);
681 // Flush callbacks.
682 await waitForAll(['render: false']);
683 expect(input.checked).toBe(false);
684
685 // Trigger a change event.
686 input.dispatchEvent(
687 new MouseEvent('click', {bubbles: true, cancelable: true}),
688 );
689 // Change should synchronously flush
690 assertLog(['render: true']);
691 expect(input.checked).toBe(true);
692
693 // Now let's make sure we're using the controlled value.
694 root.render(<ControlledInput reverse={true} />);
695 await waitForAll(['render: true']);
696
697 // Trigger another change event.
698 input.dispatchEvent(
699 new MouseEvent('click', {bubbles: true, cancelable: true}),
700 );
701 // Change should synchronously flush
702 assertLog(['render: true']);
703 expect(input.checked).toBe(false);
704 });
705
706 it('textarea', async () => {
707 const root = ReactDOMClient.createRoot(container);
708 let textarea;
709
710 class ControlledTextarea extends React.Component {
711 state = {value: 'initial'};
712 onChange = event => this.setState({value: event.target.value});
713 render() {
714 Scheduler.log(`render: ${this.state.value}`);
715 const controlledValue =
716 this.state.value === 'changed' ? 'changed [!]' : this.state.value;
717 return (
718 <textarea
719 ref={el => (textarea = el)}
720 type="text"
721 value={controlledValue}
722 onChange={this.onChange}
723 />
724 );
725 }
726 }
727
728 // Initial mount. Test that this is async.
729 root.render(<ControlledTextarea />);
730 // Should not have flushed yet.
731 assertLog([]);
732 expect(textarea).toBe(undefined);
733 // Flush callbacks.
734 await waitForAll(['render: initial']);
735 expect(textarea.value).toBe('initial');
736
737 // Trigger a change event.
738 setUntrackedTextareaValue.call(textarea, 'changed');
739 textarea.dispatchEvent(
740 new Event('input', {bubbles: true, cancelable: true}),
741 );
742 // Change should synchronously flush
743 assertLog(['render: changed']);
744 // Value should be the controlled value, not the original one
745 expect(textarea.value).toBe('changed [!]');
746 });
747
748 it('parent of input', async () => {
749 const root = ReactDOMClient.createRoot(container);
750 let input;
751
752 class ControlledInput extends React.Component {
753 state = {value: 'initial'};
754 onChange = event => this.setState({value: event.target.value});
755 render() {
756 Scheduler.log(`render: ${this.state.value}`);
757 const controlledValue =
758 this.state.value === 'changed' ? 'changed [!]' : this.state.value;
759 return (
760 <div onChange={this.onChange}>
761 <input
762 ref={el => (input = el)}
763 type="text"
764 value={controlledValue}
765 onChange={() => {
766 // Does nothing. Parent handler is responsible for updating.
767 }}
768 />
769 </div>
770 );
771 }
772 }
773
774 // Initial mount. Test that this is async.
775 root.render(<ControlledInput />);
776 // Should not have flushed yet.
777 assertLog([]);
778 expect(input).toBe(undefined);
779 // Flush callbacks.
780 await waitForAll(['render: initial']);
781 expect(input.value).toBe('initial');
782
783 // Trigger a change event.
784 setUntrackedValue.call(input, 'changed');
785 input.dispatchEvent(
786 new Event('input', {bubbles: true, cancelable: true}),
787 );
788 // Change should synchronously flush
789 assertLog(['render: changed']);
790 // Value should be the controlled value, not the original one
791 expect(input.value).toBe('changed [!]');
792 });
793
794 it('is sync for non-input events', async () => {
795 const root = ReactDOMClient.createRoot(container);
796 let input;
797
798 class ControlledInput extends React.Component {
799 state = {value: 'initial'};
800 onChange = event => this.setState({value: event.target.value});
801 reset = () => {
802 this.setState({value: ''});
803 };
804 render() {
805 Scheduler.log(`render: ${this.state.value}`);
806 const controlledValue =
807 this.state.value === 'changed' ? 'changed [!]' : this.state.value;
808 return (
809 <input
810 ref={el => (input = el)}
811 type="text"
812 value={controlledValue}
813 onChange={this.onChange}
814 onClick={this.reset}
815 />
816 );
817 }
818 }
819
820 // Initial mount. Test that this is async.
821 root.render(<ControlledInput />);
822 // Should not have flushed yet.
823 assertLog([]);
824 expect(input).toBe(undefined);
825 // Flush callbacks.
826 await waitForAll(['render: initial']);
827 expect(input.value).toBe('initial');
828
829 // Trigger a click event
830 input.dispatchEvent(
831 new Event('click', {bubbles: true, cancelable: true}),
832 );
833
834 // Flush microtask queue.
835 await waitForDiscrete(['render: ']);
836 expect(input.value).toBe('');
837 });
838
839 it('mouse enter/leave should be user-blocking but not discrete', async () => {
840 const {useState} = React;
841
842 const root = ReactDOMClient.createRoot(container);
843
844 const target = React.createRef(null);
845 function Foo() {
846 const [isHover, setHover] = useState(false);
847 return (
848 <div
849 ref={target}
850 onMouseEnter={() => setHover(true)}
851 onMouseLeave={() => setHover(false)}>
852 {isHover ? 'hovered' : 'not hovered'}
853 </div>
854 );
855 }
856
857 await act(() => {
858 root.render(<Foo />);
859 });
860 expect(container.textContent).toEqual('not hovered');
861
862 await act(() => {
863 const mouseOverEvent = document.createEvent('MouseEvents');
864 mouseOverEvent.initEvent('mouseover', true, true);
865 target.current.dispatchEvent(mouseOverEvent);
866
867 // Flush discrete updates
868 ReactDOM.flushSync();
869 // Since mouse enter/leave is not discrete, should not have updated yet
870 expect(container.textContent).toEqual('not hovered');
871 });
872 expect(container.textContent).toEqual('hovered');
873 });
874 });
875 });