main
js 662 lines 18.5 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 // polyfill missing JSDOM support
13 class ToggleEvent extends Event {
14 constructor(type, eventInit) {
15 super(type, eventInit);
16 this.newState = eventInit.newState;
17 this.oldState = eventInit.oldState;
18 this.source = eventInit.source;
19 }
20 }
21
22 describe('SimpleEventPlugin', function () {
23 let React;
24 let ReactDOMClient;
25 let Scheduler;
26 let act;
27
28 let onClick;
29 let container;
30 let assertLog;
31 let waitForAll;
32
33 async function expectClickThru(element) {
34 await act(() => {
35 element.click();
36 });
37 expect(onClick).toHaveBeenCalledTimes(1);
38 }
39
40 function expectNoClickThru(element) {
41 element.click();
42 expect(onClick).toHaveBeenCalledTimes(0);
43 }
44
45 async function mounted(element) {
46 container = document.createElement('div');
47 document.body.appendChild(container);
48 const root = ReactDOMClient.createRoot(container);
49 await act(() => {
50 root.render(element);
51 });
52 element = container.firstChild;
53 return element;
54 }
55
56 beforeEach(function () {
57 jest.resetModules();
58 React = require('react');
59 ReactDOMClient = require('react-dom/client');
60 Scheduler = require('scheduler');
61
62 const InternalTestUtils = require('internal-test-utils');
63 assertLog = InternalTestUtils.assertLog;
64 waitForAll = InternalTestUtils.waitForAll;
65 act = InternalTestUtils.act;
66
67 onClick = jest.fn();
68 });
69
70 afterEach(() => {
71 if (container && document.body.contains(container)) {
72 document.body.removeChild(container);
73 container = null;
74 }
75 });
76
77 it('A non-interactive tags click when disabled', async function () {
78 const element = <div onClick={onClick} />;
79 await expectClickThru(await mounted(element));
80 });
81
82 it('A non-interactive tags clicks bubble when disabled', async function () {
83 const element = await mounted(
84 <div onClick={onClick}>
85 <div />
86 </div>,
87 );
88 const child = element.firstChild;
89 child.click();
90 expect(onClick).toHaveBeenCalledTimes(1);
91 });
92
93 it('does not register a click when clicking a child of a disabled element', async function () {
94 const element = await mounted(
95 <button onClick={onClick} disabled={true}>
96 <span />
97 </button>,
98 );
99 const child = element.querySelector('span');
100
101 child.click();
102 expect(onClick).toHaveBeenCalledTimes(0);
103 });
104
105 it('triggers click events for children of disabled elements', async function () {
106 const element = await mounted(
107 <button disabled={true}>
108 <span onClick={onClick} />
109 </button>,
110 );
111 const child = element.querySelector('span');
112
113 child.click();
114 expect(onClick).toHaveBeenCalledTimes(1);
115 });
116
117 it('triggers parent captured click events when target is a child of a disabled elements', async function () {
118 const element = await mounted(
119 <div onClickCapture={onClick}>
120 <button disabled={true}>
121 <span />
122 </button>
123 </div>,
124 );
125 const child = element.querySelector('span');
126
127 child.click();
128 expect(onClick).toHaveBeenCalledTimes(1);
129 });
130
131 it('triggers captured click events for children of disabled elements', async function () {
132 const element = await mounted(
133 <button disabled={true}>
134 <span onClickCapture={onClick} />
135 </button>,
136 );
137 const child = element.querySelector('span');
138
139 child.click();
140 expect(onClick).toHaveBeenCalledTimes(1);
141 });
142
143 describe.each(['button', 'input', 'select', 'textarea'])(
144 '%s',
145 function (tagName) {
146 it('should forward clicks when it starts out not disabled', async () => {
147 const element = React.createElement(tagName, {
148 onClick: onClick,
149 });
150
151 await expectClickThru(await mounted(element));
152 });
153
154 it('should not forward clicks when it starts out disabled', async () => {
155 const element = React.createElement(tagName, {
156 onClick: onClick,
157 disabled: true,
158 });
159
160 await expectNoClickThru(await mounted(element));
161 });
162
163 it('should forward clicks when it becomes not disabled', async () => {
164 container = document.createElement('div');
165 document.body.appendChild(container);
166 const root = ReactDOMClient.createRoot(container);
167 await act(() => {
168 root.render(
169 React.createElement(tagName, {onClick: onClick, disabled: true}),
170 );
171 });
172 await act(() => {
173 root.render(React.createElement(tagName, {onClick: onClick}));
174 });
175 const element = container.firstChild;
176 await expectClickThru(element);
177 });
178
179 it('should not forward clicks when it becomes disabled', async () => {
180 container = document.createElement('div');
181 document.body.appendChild(container);
182 const root = ReactDOMClient.createRoot(container);
183 await act(() => {
184 root.render(React.createElement(tagName, {onClick: onClick}));
185 });
186 await act(() => {
187 root.render(
188 React.createElement(tagName, {onClick: onClick, disabled: true}),
189 );
190 });
191 const element = container.firstChild;
192 expectNoClickThru(element);
193 });
194
195 it('should work correctly if the listener is changed', async () => {
196 container = document.createElement('div');
197 document.body.appendChild(container);
198 const root = ReactDOMClient.createRoot(container);
199 await act(() => {
200 root.render(
201 React.createElement(tagName, {onClick: onClick, disabled: true}),
202 );
203 });
204 await act(() => {
205 root.render(
206 React.createElement(tagName, {onClick: onClick, disabled: false}),
207 );
208 });
209 const element = container.firstChild;
210 await expectClickThru(element);
211 });
212 },
213 );
214
215 it('batches updates that occur as a result of a nested event dispatch', async () => {
216 container = document.createElement('div');
217 document.body.appendChild(container);
218
219 let button;
220 class Button extends React.Component {
221 state = {count: 0};
222 increment = () =>
223 this.setState(state => ({
224 count: state.count + 1,
225 }));
226 componentDidUpdate() {
227 Scheduler.log(`didUpdate - Count: ${this.state.count}`);
228 }
229 render() {
230 return (
231 <button
232 ref={el => (button = el)}
233 onFocus={this.increment}
234 onClick={() => {
235 // The focus call synchronously dispatches a nested event. All of
236 // the updates in this handler should be batched together.
237 this.increment();
238 button.focus();
239 this.increment();
240 }}>
241 Count: {this.state.count}
242 </button>
243 );
244 }
245 }
246
247 function click() {
248 button.dispatchEvent(
249 new MouseEvent('click', {bubbles: true, cancelable: true}),
250 );
251 }
252
253 const root = ReactDOMClient.createRoot(container);
254 await act(() => {
255 root.render(<Button />);
256 });
257
258 expect(button.textContent).toEqual('Count: 0');
259 assertLog([]);
260
261 await act(() => {
262 click();
263 });
264 // There should be exactly one update.
265 assertLog(['didUpdate - Count: 3']);
266 expect(button.textContent).toEqual('Count: 3');
267 });
268
269 describe('interactive events, in concurrent mode', () => {
270 beforeEach(() => {
271 jest.resetModules();
272
273 React = require('react');
274 ReactDOMClient = require('react-dom/client');
275 Scheduler = require('scheduler');
276
277 const InternalTestUtils = require('internal-test-utils');
278 assertLog = InternalTestUtils.assertLog;
279 waitForAll = InternalTestUtils.waitForAll;
280
281 act = require('internal-test-utils').act;
282 });
283
284 it('flushes pending interactive work before exiting event handler', async () => {
285 container = document.createElement('div');
286 const root = ReactDOMClient.createRoot(container);
287 document.body.appendChild(container);
288
289 let button;
290 class Button extends React.Component {
291 state = {disabled: false};
292 onClick = () => {
293 // Perform some side-effect
294 Scheduler.log('Side-effect');
295 // Disable the button
296 this.setState({disabled: true});
297 };
298 render() {
299 Scheduler.log(
300 `render button: ${this.state.disabled ? 'disabled' : 'enabled'}`,
301 );
302 return (
303 <button
304 ref={el => (button = el)}
305 // Handler is removed after the first click
306 onClick={this.state.disabled ? null : this.onClick}
307 />
308 );
309 }
310 }
311
312 // Initial mount
313 root.render(<Button />);
314 // Should not have flushed yet because it's async
315 assertLog([]);
316 expect(button).toBe(undefined);
317 // Flush async work
318 await waitForAll(['render button: enabled']);
319
320 function click() {
321 const event = new MouseEvent('click', {
322 bubbles: true,
323 cancelable: true,
324 });
325 Object.defineProperty(event, 'timeStamp', {
326 value: 0,
327 });
328 button.dispatchEvent(event);
329 }
330
331 // Click the button to trigger the side-effect
332 await act(() => click());
333 assertLog([
334 // The handler fired
335 'Side-effect',
336 // The component re-rendered synchronously, even in concurrent mode.
337 'render button: disabled',
338 ]);
339
340 // Click the button again
341 click();
342 assertLog([
343 // The event handler was removed from the button, so there's no effect.
344 ]);
345
346 // The handler should not fire again no matter how many times we
347 // click the handler.
348 click();
349 click();
350 click();
351 click();
352 click();
353 await waitForAll([]);
354 });
355
356 // NOTE: This test was written for the old behavior of discrete updates,
357 // where they would be async, but flushed early if another discrete update
358 // was dispatched.
359 it('end result of many interactive updates is deterministic', async () => {
360 container = document.createElement('div');
361 const root = ReactDOMClient.createRoot(container);
362 document.body.appendChild(container);
363
364 let button;
365 class Button extends React.Component {
366 state = {count: 0};
367 render() {
368 return (
369 <button
370 ref={el => (button = el)}
371 onClick={() =>
372 // Intentionally not using the updater form here
373 this.setState({count: this.state.count + 1})
374 }>
375 Count: {this.state.count}
376 </button>
377 );
378 }
379 }
380
381 // Initial mount
382 root.render(<Button />);
383 // Should not have flushed yet because it's async
384 expect(button).toBe(undefined);
385 // Flush async work
386 await waitForAll([]);
387 expect(button.textContent).toEqual('Count: 0');
388
389 function click() {
390 const event = new MouseEvent('click', {
391 bubbles: true,
392 cancelable: true,
393 });
394 Object.defineProperty(event, 'timeStamp', {
395 value: 0,
396 });
397 button.dispatchEvent(event);
398 }
399
400 // Click the button a single time
401 await act(() => click());
402 // The counter should update synchronously, even in concurrent mode.
403 expect(button.textContent).toEqual('Count: 1');
404
405 // Click the button many more times
406 await act(() => click());
407 await act(() => click());
408 await act(() => click());
409 await act(() => click());
410 await act(() => click());
411 await act(() => click());
412
413 // Flush the remaining work
414 await waitForAll([]);
415 // The counter should equal the total number of clicks
416 expect(button.textContent).toEqual('Count: 7');
417 });
418 });
419
420 describe('iOS bubbling click fix', function () {
421 // See http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html
422
423 it('does not add a local click to interactive elements', async function () {
424 container = document.createElement('div');
425
426 const root = ReactDOMClient.createRoot(container);
427 await act(() => {
428 root.render(<button onClick={onClick} />);
429 });
430
431 const node = container.firstChild;
432
433 node.dispatchEvent(new MouseEvent('click'));
434
435 expect(onClick).toHaveBeenCalledTimes(0);
436 });
437
438 it('adds a local click listener to non-interactive elements', async function () {
439 container = document.createElement('div');
440
441 const root = ReactDOMClient.createRoot(container);
442 await act(() => {
443 root.render(<div onClick={onClick} />);
444 });
445
446 const node = container.firstChild;
447
448 await act(() => {
449 node.dispatchEvent(new MouseEvent('click'));
450 });
451
452 expect(onClick).toHaveBeenCalledTimes(0);
453 });
454
455 it('registers passive handlers for events affected by the intervention', async () => {
456 container = document.createElement('div');
457
458 const passiveEvents = [];
459 const nativeAddEventListener = container.addEventListener;
460 container.addEventListener = function (type, fn, options) {
461 if (options !== null && typeof options === 'object') {
462 if (options.passive) {
463 passiveEvents.push(type);
464 }
465 }
466 return nativeAddEventListener.apply(this, arguments);
467 };
468
469 const root = ReactDOMClient.createRoot(container);
470 await act(() => {
471 root.render(<div />);
472 });
473
474 expect(passiveEvents).toEqual([
475 'touchstart',
476 'touchstart',
477 'touchmove',
478 'touchmove',
479 'wheel',
480 'wheel',
481 ]);
482 });
483
484 it('dispatches synthetic toggle events when the Popover API is used', async () => {
485 container = document.createElement('div');
486
487 const onToggle = jest.fn();
488 const root = ReactDOMClient.createRoot(container);
489 await act(() => {
490 root.render(
491 <>
492 <button popoverTarget="popover">Toggle popover</button>
493 <div id="popover" popover="" onToggle={onToggle}>
494 popover content
495 </div>
496 </>,
497 );
498 });
499
500 const target = container.querySelector('#popover');
501 target.dispatchEvent(
502 new ToggleEvent('toggle', {
503 bubbles: false,
504 cancelable: true,
505 oldState: 'closed',
506 newState: 'open',
507 }),
508 );
509
510 expect(onToggle).toHaveBeenCalledTimes(1);
511 let event = onToggle.mock.calls[0][0];
512 expect(event).toEqual(
513 expect.objectContaining({
514 oldState: 'closed',
515 newState: 'open',
516 }),
517 );
518
519 target.dispatchEvent(
520 new ToggleEvent('toggle', {
521 bubbles: false,
522 cancelable: true,
523 oldState: 'open',
524 newState: 'closed',
525 }),
526 );
527
528 expect(onToggle).toHaveBeenCalledTimes(2);
529 event = onToggle.mock.calls[1][0];
530 expect(event).toEqual(
531 expect.objectContaining({
532 oldState: 'open',
533 newState: 'closed',
534 }),
535 );
536 });
537
538 it('dispatches synthetic toggle events when <details> is used', async () => {
539 // This test just replays browser behavior.
540 // The real test would be if browsers dispatch ToggleEvent on <details>.
541 // This case only exists because MDN claims <details> doesn't receive ToggleEvent.
542 // However, Chrome dispatches ToggleEvent on <details> and the spec confirms that behavior: https://html.spec.whatwg.org/multipage/indices.html#event-toggle
543
544 container = document.createElement('div');
545
546 const onToggle = jest.fn();
547 const root = ReactDOMClient.createRoot(container);
548 await act(() => {
549 root.render(
550 <details id="details" onToggle={onToggle}>
551 <summary>Summary</summary>
552 Details
553 </details>,
554 );
555 });
556
557 const target = container.querySelector('#details');
558 target.dispatchEvent(
559 new ToggleEvent('toggle', {
560 bubbles: false,
561 cancelable: true,
562 oldState: 'closed',
563 newState: 'open',
564 }),
565 );
566
567 expect(onToggle).toHaveBeenCalledTimes(1);
568 let event = onToggle.mock.calls[0][0];
569 expect(event).toEqual(
570 expect.objectContaining({
571 oldState: 'closed',
572 newState: 'open',
573 }),
574 );
575
576 target.dispatchEvent(
577 new ToggleEvent('toggle', {
578 bubbles: false,
579 cancelable: true,
580 oldState: 'open',
581 newState: 'closed',
582 }),
583 );
584
585 expect(onToggle).toHaveBeenCalledTimes(2);
586 event = onToggle.mock.calls[1][0];
587 expect(event).toEqual(
588 expect.objectContaining({
589 oldState: 'open',
590 newState: 'closed',
591 }),
592 );
593 });
594 });
595
596 it('includes the submitter in submit events', async function () {
597 container = document.createElement('div');
598
599 const onSubmit = jest.fn(event => {
600 event.preventDefault();
601 });
602 const root = ReactDOMClient.createRoot(container);
603 await act(() => {
604 root.render(
605 <form onSubmit={onSubmit}>
606 <button type="submit" id="submitter">
607 Submit
608 </button>
609 </form>,
610 );
611 });
612
613 const submitter = container.querySelector('#submitter');
614 const submitEvent = new SubmitEvent('submit', {
615 bubbles: true,
616 cancelable: true,
617 submitter: submitter,
618 });
619 await act(() => {
620 submitter.dispatchEvent(submitEvent);
621 });
622
623 expect(onSubmit).toHaveBeenCalledTimes(1);
624 const event = onSubmit.mock.calls[0][0];
625 expect(event.submitter).toBe(submitter);
626 });
627
628 it('includes the source in toggle events', async function () {
629 container = document.createElement('div');
630
631 const onToggle = jest.fn();
632 const root = ReactDOMClient.createRoot(container);
633 await act(() => {
634 root.render(
635 <>
636 <button popoverTarget="popover">Toggle popover</button>
637 <div id="popover" popover="" onToggle={onToggle}>
638 popover content
639 </div>
640 </>,
641 );
642 });
643
644 const source = container.querySelector('button');
645 const target = container.querySelector('#popover');
646 await act(() => {
647 target.dispatchEvent(
648 new ToggleEvent('toggle', {
649 bubbles: false,
650 cancelable: true,
651 oldState: 'closed',
652 newState: 'open',
653 source: source,
654 }),
655 );
656 });
657
658 expect(onToggle).toHaveBeenCalledTimes(1);
659 const event = onToggle.mock.calls[0][0];
660 expect(event.source).toBe(source);
661 });
662 });