main
js 1,483 lines 57.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 // Set by `yarn test-fire`.
13 const {disableInputAttributeSyncing} = require('shared/ReactFeatureFlags');
14
15 describe('DOMPropertyOperations', () => {
16 let React;
17 let ReactDOMClient;
18 let act;
19 let assertConsoleErrorDev;
20
21 beforeEach(() => {
22 jest.resetModules();
23 React = require('react');
24 ReactDOMClient = require('react-dom/client');
25 ({act, assertConsoleErrorDev} = require('internal-test-utils'));
26 });
27
28 // Sets a value in a way that React doesn't see,
29 // so that a subsequent "change" event will trigger the event handler.
30 const setUntrackedValue = Object.getOwnPropertyDescriptor(
31 HTMLInputElement.prototype,
32 'value',
33 ).set;
34 const setUntrackedChecked = Object.getOwnPropertyDescriptor(
35 HTMLInputElement.prototype,
36 'checked',
37 ).set;
38
39 describe('setValueForProperty', () => {
40 it('should set values as properties by default', async () => {
41 const container = document.createElement('div');
42 const root = ReactDOMClient.createRoot(container);
43 await act(() => {
44 root.render(<div title="Tip!" />);
45 });
46 expect(container.firstChild.title).toBe('Tip!');
47 });
48
49 it('should set values as attributes if necessary', async () => {
50 const container = document.createElement('div');
51 const root = ReactDOMClient.createRoot(container);
52 await act(() => {
53 root.render(<div enterKeyHint="go" />);
54 });
55 expect(container.firstChild.getAttribute('enterkeyhint')).toBe('go');
56 expect(container.firstChild.enterKeyHint).toBeUndefined();
57 });
58
59 it('should set values as namespace attributes if necessary', async () => {
60 const container = document.createElementNS(
61 'http://www.w3.org/2000/svg',
62 'svg',
63 );
64 const root = ReactDOMClient.createRoot(container);
65 await act(() => {
66 root.render(<image xlinkHref="about:blank" />);
67 });
68 expect(
69 container.firstChild.getAttributeNS(
70 'http://www.w3.org/1999/xlink',
71 'href',
72 ),
73 ).toBe('about:blank');
74 });
75
76 it('should set values as boolean properties', async () => {
77 const container = document.createElement('div');
78 const root = ReactDOMClient.createRoot(container);
79 await act(() => {
80 root.render(<div disabled="disabled" />);
81 });
82 expect(container.firstChild.getAttribute('disabled')).toBe('');
83 await act(() => {
84 root.render(<div disabled={true} />);
85 });
86 expect(container.firstChild.getAttribute('disabled')).toBe('');
87 await act(() => {
88 root.render(<div disabled={false} />);
89 });
90 expect(container.firstChild.getAttribute('disabled')).toBe(null);
91 await act(() => {
92 root.render(<div disabled={true} />);
93 });
94 await act(() => {
95 root.render(<div disabled={null} />);
96 });
97 expect(container.firstChild.getAttribute('disabled')).toBe(null);
98 await act(() => {
99 root.render(<div disabled={true} />);
100 });
101 await act(() => {
102 root.render(<div disabled={undefined} />);
103 });
104 expect(container.firstChild.getAttribute('disabled')).toBe(null);
105 });
106
107 it('should convert attribute values to string first', async () => {
108 // Browsers default to this behavior, but some test environments do not.
109 // This ensures that we have consistent behavior.
110 const obj = {
111 toString: function () {
112 return 'css-class';
113 },
114 };
115
116 const container = document.createElement('div');
117 const root = ReactDOMClient.createRoot(container);
118 await act(() => {
119 root.render(<div className={obj} />);
120 });
121 expect(container.firstChild.getAttribute('class')).toBe('css-class');
122 });
123
124 it('should not remove empty attributes for special input properties', async () => {
125 const container = document.createElement('div');
126 const root = ReactDOMClient.createRoot(container);
127 await act(() => {
128 root.render(<input value="" onChange={() => {}} />);
129 });
130 if (disableInputAttributeSyncing) {
131 expect(container.firstChild.hasAttribute('value')).toBe(false);
132 } else {
133 expect(container.firstChild.getAttribute('value')).toBe('');
134 }
135 expect(container.firstChild.value).toBe('');
136 });
137
138 it('should not remove empty attributes for special option properties', async () => {
139 const container = document.createElement('div');
140 const root = ReactDOMClient.createRoot(container);
141 await act(() => {
142 root.render(
143 <select>
144 <option value="">empty</option>
145 <option>filled</option>
146 </select>,
147 );
148 });
149 // Regression test for https://github.com/facebook/react/issues/6219
150 expect(container.firstChild.firstChild.value).toBe('');
151 expect(container.firstChild.lastChild.value).toBe('filled');
152 });
153
154 it('should remove for falsey boolean properties', async () => {
155 const container = document.createElement('div');
156 const root = ReactDOMClient.createRoot(container);
157 await act(() => {
158 root.render(<div allowFullScreen={false} />);
159 });
160 expect(container.firstChild.hasAttribute('allowFullScreen')).toBe(false);
161 });
162
163 it('should set credentialless boolean attribute on iframes', async () => {
164 const container = document.createElement('div');
165 const root = ReactDOMClient.createRoot(container);
166 await act(() => {
167 root.render(<iframe credentialless={true} />);
168 });
169 expect(container.firstChild.getAttribute('credentialless')).toBe('');
170 await act(() => {
171 root.render(<iframe credentialless={false} />);
172 });
173 expect(container.firstChild.hasAttribute('credentialless')).toBe(false);
174 });
175
176 it('should set credentialless attribute when passed a string and warn', async () => {
177 const container = document.createElement('div');
178 const root = ReactDOMClient.createRoot(container);
179 await act(() => {
180 root.render(<iframe credentialless="true" />);
181 });
182 assertConsoleErrorDev([
183 'Received the string `true` for the boolean attribute `credentialless`. ' +
184 'Although this works, it will not work as expected if you pass the string "false". ' +
185 'Did you mean credentialless={true}?\n' +
186 ' in iframe (at **)',
187 ]);
188 expect(container.firstChild.getAttribute('credentialless')).toBe('');
189 });
190
191 it('should remove when setting custom attr to null', async () => {
192 const container = document.createElement('div');
193 const root = ReactDOMClient.createRoot(container);
194 await act(() => {
195 root.render(<div data-foo="bar" />);
196 });
197 expect(container.firstChild.hasAttribute('data-foo')).toBe(true);
198 await act(() => {
199 root.render(<div data-foo={null} />);
200 });
201 expect(container.firstChild.hasAttribute('data-foo')).toBe(false);
202 });
203
204 it('should set className to empty string instead of null', async () => {
205 const container = document.createElement('div');
206 const root = ReactDOMClient.createRoot(container);
207 await act(() => {
208 root.render(<div className="selected" />);
209 });
210 expect(container.firstChild.className).toBe('selected');
211 await act(() => {
212 root.render(<div className={null} />);
213 });
214 // className should be '', not 'null' or null (which becomes 'null' in
215 // some browsers)
216 expect(container.firstChild.className).toBe('');
217 expect(container.firstChild.getAttribute('class')).toBe(null);
218 });
219
220 it('should remove property properly for boolean properties', async () => {
221 const container = document.createElement('div');
222 const root = ReactDOMClient.createRoot(container);
223 await act(() => {
224 root.render(<div hidden={true} />);
225 });
226 expect(container.firstChild.hasAttribute('hidden')).toBe(true);
227 await act(() => {
228 root.render(<div hidden={false} />);
229 });
230 expect(container.firstChild.hasAttribute('hidden')).toBe(false);
231 });
232
233 it('should always assign the value attribute for non-inputs', async () => {
234 const container = document.createElement('div');
235 const root = ReactDOMClient.createRoot(container);
236 await act(() => {
237 root.render(<progress />);
238 });
239 spyOnDevAndProd(container.firstChild, 'setAttribute');
240 await act(() => {
241 root.render(<progress value={30} />);
242 });
243 await act(() => {
244 root.render(<progress value="30" />);
245 });
246 expect(container.firstChild.setAttribute).toHaveBeenCalledTimes(2);
247 });
248
249 it('should return the progress to intermediate state on null value', async () => {
250 const container = document.createElement('div');
251 const root = ReactDOMClient.createRoot(container);
252 await act(() => {
253 root.render(<progress value={30} />);
254 });
255 await act(() => {
256 root.render(<progress value={null} />);
257 });
258 // Ensure we move progress back to an indeterminate state.
259 // Regression test for https://github.com/facebook/react/issues/6119
260 expect(container.firstChild.hasAttribute('value')).toBe(false);
261 });
262
263 it('custom element custom events lowercase', async () => {
264 const oncustomevent = jest.fn();
265 function Test() {
266 return <my-custom-element oncustomevent={oncustomevent} />;
267 }
268 const container = document.createElement('div');
269 const root = ReactDOMClient.createRoot(container);
270 await act(() => {
271 root.render(<Test />);
272 });
273 container
274 .querySelector('my-custom-element')
275 .dispatchEvent(new Event('customevent'));
276 expect(oncustomevent).toHaveBeenCalledTimes(1);
277 });
278
279 it('custom element custom events uppercase', async () => {
280 const oncustomevent = jest.fn();
281 function Test() {
282 return <my-custom-element onCustomevent={oncustomevent} />;
283 }
284 const container = document.createElement('div');
285 const root = ReactDOMClient.createRoot(container);
286 await act(() => {
287 root.render(<Test />);
288 });
289 container
290 .querySelector('my-custom-element')
291 .dispatchEvent(new Event('Customevent'));
292 expect(oncustomevent).toHaveBeenCalledTimes(1);
293 });
294
295 it('custom element custom event with dash in name', async () => {
296 const oncustomevent = jest.fn();
297 function Test() {
298 return <my-custom-element oncustom-event={oncustomevent} />;
299 }
300 const container = document.createElement('div');
301 const root = ReactDOMClient.createRoot(container);
302 await act(() => {
303 root.render(<Test />);
304 });
305 container
306 .querySelector('my-custom-element')
307 .dispatchEvent(new Event('custom-event'));
308 expect(oncustomevent).toHaveBeenCalledTimes(1);
309 });
310
311 it('custom element remove event handler', async () => {
312 const oncustomevent = jest.fn();
313 function Test(props) {
314 return <my-custom-element oncustomevent={props.handler} />;
315 }
316
317 const container = document.createElement('div');
318 const root = ReactDOMClient.createRoot(container);
319 await act(() => {
320 root.render(<Test handler={oncustomevent} />);
321 });
322 const customElement = container.querySelector('my-custom-element');
323 customElement.dispatchEvent(new Event('customevent'));
324 expect(oncustomevent).toHaveBeenCalledTimes(1);
325
326 await act(() => {
327 root.render(<Test handler={false} />);
328 });
329 // Make sure that the second render didn't create a new element. We want
330 // to make sure removeEventListener actually gets called on the same element.
331 expect(customElement).toBe(customElement);
332 customElement.dispatchEvent(new Event('customevent'));
333 expect(oncustomevent).toHaveBeenCalledTimes(1);
334
335 await act(() => {
336 root.render(<Test handler={oncustomevent} />);
337 });
338 customElement.dispatchEvent(new Event('customevent'));
339 expect(oncustomevent).toHaveBeenCalledTimes(2);
340
341 const oncustomevent2 = jest.fn();
342 await act(() => {
343 root.render(<Test handler={oncustomevent2} />);
344 });
345 customElement.dispatchEvent(new Event('customevent'));
346 expect(oncustomevent).toHaveBeenCalledTimes(2);
347 expect(oncustomevent2).toHaveBeenCalledTimes(1);
348 });
349
350 it('custom elements shouldnt have non-functions for on* attributes treated as event listeners', async () => {
351 const container = document.createElement('div');
352 const root = ReactDOMClient.createRoot(container);
353 await act(() => {
354 root.render(
355 <my-custom-element
356 onstring={'hello'}
357 onobj={{hello: 'world'}}
358 onarray={['one', 'two']}
359 ontrue={true}
360 onfalse={false}
361 />,
362 );
363 });
364 const customElement = container.querySelector('my-custom-element');
365 expect(customElement.getAttribute('onstring')).toBe('hello');
366 expect(customElement.getAttribute('onobj')).toBe('[object Object]');
367 expect(customElement.getAttribute('onarray')).toBe('one,two');
368 expect(customElement.getAttribute('ontrue')).toBe('');
369 expect(customElement.getAttribute('onfalse')).toBe(null);
370
371 // Dispatch the corresponding event names to make sure that nothing crashes.
372 customElement.dispatchEvent(new Event('string'));
373 customElement.dispatchEvent(new Event('obj'));
374 customElement.dispatchEvent(new Event('array'));
375 customElement.dispatchEvent(new Event('true'));
376 customElement.dispatchEvent(new Event('false'));
377 });
378
379 it('custom elements should still have onClick treated like regular elements', async () => {
380 let syntheticClickEvent = null;
381 const syntheticEventHandler = jest.fn(
382 event => (syntheticClickEvent = event),
383 );
384 let nativeClickEvent = null;
385 const nativeEventHandler = jest.fn(event => (nativeClickEvent = event));
386 function Test() {
387 return <my-custom-element onClick={syntheticEventHandler} />;
388 }
389
390 const container = document.createElement('div');
391 document.body.appendChild(container);
392 const root = ReactDOMClient.createRoot(container);
393 await act(() => {
394 root.render(<Test />);
395 });
396
397 const customElement = container.querySelector('my-custom-element');
398 customElement.onclick = nativeEventHandler;
399 container.querySelector('my-custom-element').click();
400
401 expect(nativeEventHandler).toHaveBeenCalledTimes(1);
402 expect(syntheticEventHandler).toHaveBeenCalledTimes(1);
403 expect(syntheticClickEvent.nativeEvent).toBe(nativeClickEvent);
404 });
405
406 it('custom elements should have working onChange event listeners', async () => {
407 let reactChangeEvent = null;
408 const eventHandler = jest.fn(event => (reactChangeEvent = event));
409 const container = document.createElement('div');
410 document.body.appendChild(container);
411 const root = ReactDOMClient.createRoot(container);
412 await act(() => {
413 root.render(<my-custom-element onChange={eventHandler} />);
414 });
415 const customElement = container.querySelector('my-custom-element');
416 let expectedHandlerCallCount = 0;
417
418 const changeEvent = new Event('change', {bubbles: true});
419 customElement.dispatchEvent(changeEvent);
420 expectedHandlerCallCount++;
421 expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
422 expect(reactChangeEvent.nativeEvent).toBe(changeEvent);
423
424 // Also make sure that removing and re-adding the event listener works
425 await act(() => {
426 root.render(<my-custom-element />);
427 });
428 customElement.dispatchEvent(new Event('change', {bubbles: true}));
429 expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
430 await act(() => {
431 root.render(<my-custom-element onChange={eventHandler} />);
432 });
433 customElement.dispatchEvent(new Event('change', {bubbles: true}));
434 expectedHandlerCallCount++;
435 expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
436 });
437
438 it('custom elements should have working onInput event listeners', async () => {
439 let reactInputEvent = null;
440 const eventHandler = jest.fn(event => (reactInputEvent = event));
441 const container = document.createElement('div');
442 document.body.appendChild(container);
443 const root = ReactDOMClient.createRoot(container);
444 await act(() => {
445 root.render(<my-custom-element onInput={eventHandler} />);
446 });
447 const customElement = container.querySelector('my-custom-element');
448 let expectedHandlerCallCount = 0;
449
450 const inputEvent = new Event('input', {bubbles: true});
451 customElement.dispatchEvent(inputEvent);
452 expectedHandlerCallCount++;
453 expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
454 expect(reactInputEvent.nativeEvent).toBe(inputEvent);
455
456 // Also make sure that removing and re-adding the event listener works
457 await act(() => {
458 root.render(<my-custom-element />);
459 });
460 customElement.dispatchEvent(new Event('input', {bubbles: true}));
461 expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
462 await act(() => {
463 root.render(<my-custom-element onInput={eventHandler} />);
464 });
465 customElement.dispatchEvent(new Event('input', {bubbles: true}));
466 expectedHandlerCallCount++;
467 expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
468 });
469
470 it('custom elements should have separate onInput and onChange handling', async () => {
471 const container = document.createElement('div');
472 document.body.appendChild(container);
473 const root = ReactDOMClient.createRoot(container);
474 const inputEventHandler = jest.fn();
475 const changeEventHandler = jest.fn();
476 await act(() => {
477 root.render(
478 <my-custom-element
479 onInput={inputEventHandler}
480 onChange={changeEventHandler}
481 />,
482 );
483 });
484 const customElement = container.querySelector('my-custom-element');
485
486 customElement.dispatchEvent(new Event('input', {bubbles: true}));
487 expect(inputEventHandler).toHaveBeenCalledTimes(1);
488 expect(changeEventHandler).toHaveBeenCalledTimes(0);
489
490 customElement.dispatchEvent(new Event('change', {bubbles: true}));
491 expect(inputEventHandler).toHaveBeenCalledTimes(1);
492 expect(changeEventHandler).toHaveBeenCalledTimes(1);
493 });
494
495 it('custom elements should be able to remove and re-add custom event listeners', async () => {
496 const container = document.createElement('div');
497 document.body.appendChild(container);
498 const root = ReactDOMClient.createRoot(container);
499 const eventHandler = jest.fn();
500 await act(() => {
501 root.render(<my-custom-element oncustomevent={eventHandler} />);
502 });
503
504 const customElement = container.querySelector('my-custom-element');
505 customElement.dispatchEvent(new Event('customevent'));
506 expect(eventHandler).toHaveBeenCalledTimes(1);
507
508 await act(() => {
509 root.render(<my-custom-element />);
510 });
511 customElement.dispatchEvent(new Event('customevent'));
512 expect(eventHandler).toHaveBeenCalledTimes(1);
513
514 await act(() => {
515 root.render(<my-custom-element oncustomevent={eventHandler} />);
516 });
517 customElement.dispatchEvent(new Event('customevent'));
518 expect(eventHandler).toHaveBeenCalledTimes(2);
519 });
520
521 it('<input is=...> should have the same onChange/onInput/onClick behavior as <input>', async () => {
522 const container = document.createElement('div');
523 document.body.appendChild(container);
524 const root = ReactDOMClient.createRoot(container);
525 const regularOnInputHandler = jest.fn();
526 const regularOnChangeHandler = jest.fn();
527 const regularOnClickHandler = jest.fn();
528 const customOnInputHandler = jest.fn();
529 const customOnChangeHandler = jest.fn();
530 const customOnClickHandler = jest.fn();
531 function clearMocks() {
532 regularOnInputHandler.mockClear();
533 regularOnChangeHandler.mockClear();
534 regularOnClickHandler.mockClear();
535 customOnInputHandler.mockClear();
536 customOnChangeHandler.mockClear();
537 customOnClickHandler.mockClear();
538 }
539 await act(() => {
540 root.render(
541 <div>
542 <input
543 onInput={regularOnInputHandler}
544 onChange={regularOnChangeHandler}
545 onClick={regularOnClickHandler}
546 />
547 <input
548 is="my-custom-element"
549 onInput={customOnInputHandler}
550 onChange={customOnChangeHandler}
551 onClick={customOnClickHandler}
552 />
553 </div>,
554 );
555 });
556
557 const regularInput = container.querySelector(
558 'input:not([is=my-custom-element])',
559 );
560 const customInput = container.querySelector(
561 'input[is=my-custom-element]',
562 );
563 expect(regularInput).not.toBe(customInput);
564
565 // Typing should trigger onInput and onChange for both kinds of inputs.
566 clearMocks();
567 setUntrackedValue.call(regularInput, 'hello');
568 regularInput.dispatchEvent(new Event('input', {bubbles: true}));
569 expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
570 expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
571 expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
572 setUntrackedValue.call(customInput, 'hello');
573 customInput.dispatchEvent(new Event('input', {bubbles: true}));
574 expect(customOnInputHandler).toHaveBeenCalledTimes(1);
575 expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
576 expect(customOnClickHandler).toHaveBeenCalledTimes(0);
577
578 // The native change event itself does not produce extra React events.
579 clearMocks();
580 regularInput.dispatchEvent(new Event('change', {bubbles: true}));
581 expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
582 expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
583 expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
584 customInput.dispatchEvent(new Event('change', {bubbles: true}));
585 expect(customOnInputHandler).toHaveBeenCalledTimes(0);
586 expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
587 expect(customOnClickHandler).toHaveBeenCalledTimes(0);
588
589 // The click event is handled by both inputs.
590 clearMocks();
591 regularInput.dispatchEvent(new Event('click', {bubbles: true}));
592 expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
593 expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
594 expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
595 customInput.dispatchEvent(new Event('click', {bubbles: true}));
596 expect(customOnInputHandler).toHaveBeenCalledTimes(0);
597 expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
598 expect(customOnClickHandler).toHaveBeenCalledTimes(1);
599
600 // Typing again should trigger onInput and onChange for both kinds of inputs.
601 clearMocks();
602 setUntrackedValue.call(regularInput, 'goodbye');
603 regularInput.dispatchEvent(new Event('input', {bubbles: true}));
604 expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
605 expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
606 expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
607 setUntrackedValue.call(customInput, 'goodbye');
608 customInput.dispatchEvent(new Event('input', {bubbles: true}));
609 expect(customOnInputHandler).toHaveBeenCalledTimes(1);
610 expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
611 expect(customOnClickHandler).toHaveBeenCalledTimes(0);
612 });
613
614 it('<input type=radio is=...> should have the same onChange/onInput/onClick behavior as <input type=radio>', async () => {
615 const container = document.createElement('div');
616 document.body.appendChild(container);
617 const root = ReactDOMClient.createRoot(container);
618 const regularOnInputHandler = jest.fn();
619 const regularOnChangeHandler = jest.fn();
620 const regularOnClickHandler = jest.fn();
621 const customOnInputHandler = jest.fn();
622 const customOnChangeHandler = jest.fn();
623 const customOnClickHandler = jest.fn();
624 function clearMocks() {
625 regularOnInputHandler.mockClear();
626 regularOnChangeHandler.mockClear();
627 regularOnClickHandler.mockClear();
628 customOnInputHandler.mockClear();
629 customOnChangeHandler.mockClear();
630 customOnClickHandler.mockClear();
631 }
632 await act(() => {
633 root.render(
634 <div>
635 <input
636 type="radio"
637 onInput={regularOnInputHandler}
638 onChange={regularOnChangeHandler}
639 onClick={regularOnClickHandler}
640 />
641 <input
642 is="my-custom-element"
643 type="radio"
644 onInput={customOnInputHandler}
645 onChange={customOnChangeHandler}
646 onClick={customOnClickHandler}
647 />
648 </div>,
649 );
650 });
651
652 const regularInput = container.querySelector(
653 'input:not([is=my-custom-element])',
654 );
655 const customInput = container.querySelector(
656 'input[is=my-custom-element]',
657 );
658 expect(regularInput).not.toBe(customInput);
659
660 // Clicking should trigger onClick and onChange on both inputs.
661 clearMocks();
662 setUntrackedChecked.call(regularInput, true);
663 regularInput.dispatchEvent(new Event('click', {bubbles: true}));
664 expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
665 expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
666 expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
667 setUntrackedChecked.call(customInput, true);
668 customInput.dispatchEvent(new Event('click', {bubbles: true}));
669 expect(customOnInputHandler).toHaveBeenCalledTimes(0);
670 expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
671 expect(customOnClickHandler).toHaveBeenCalledTimes(1);
672
673 // The native input event only produces a React onInput event.
674 clearMocks();
675 regularInput.dispatchEvent(new Event('input', {bubbles: true}));
676 expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
677 expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
678 expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
679 customInput.dispatchEvent(new Event('input', {bubbles: true}));
680 expect(customOnInputHandler).toHaveBeenCalledTimes(1);
681 expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
682 expect(customOnClickHandler).toHaveBeenCalledTimes(0);
683
684 // Clicking again should trigger onClick and onChange on both inputs.
685 clearMocks();
686 setUntrackedChecked.call(regularInput, false);
687 regularInput.dispatchEvent(new Event('click', {bubbles: true}));
688 expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
689 expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
690 expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
691 setUntrackedChecked.call(customInput, false);
692 customInput.dispatchEvent(new Event('click', {bubbles: true}));
693 expect(customOnInputHandler).toHaveBeenCalledTimes(0);
694 expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
695 expect(customOnClickHandler).toHaveBeenCalledTimes(1);
696 });
697
698 it('<select is=...> should have the same onChange/onInput/onClick behavior as <select>', async () => {
699 const container = document.createElement('div');
700 document.body.appendChild(container);
701 const root = ReactDOMClient.createRoot(container);
702 const regularOnInputHandler = jest.fn();
703 const regularOnChangeHandler = jest.fn();
704 const regularOnClickHandler = jest.fn();
705 const customOnInputHandler = jest.fn();
706 const customOnChangeHandler = jest.fn();
707 const customOnClickHandler = jest.fn();
708 function clearMocks() {
709 regularOnInputHandler.mockClear();
710 regularOnChangeHandler.mockClear();
711 regularOnClickHandler.mockClear();
712 customOnInputHandler.mockClear();
713 customOnChangeHandler.mockClear();
714 customOnClickHandler.mockClear();
715 }
716 await act(() => {
717 root.render(
718 <div>
719 <select
720 onInput={regularOnInputHandler}
721 onChange={regularOnChangeHandler}
722 onClick={regularOnClickHandler}
723 />
724 <select
725 is="my-custom-element"
726 onInput={customOnInputHandler}
727 onChange={customOnChangeHandler}
728 onClick={customOnClickHandler}
729 />
730 </div>,
731 );
732 });
733
734 const regularSelect = container.querySelector(
735 'select:not([is=my-custom-element])',
736 );
737 const customSelect = container.querySelector(
738 'select[is=my-custom-element]',
739 );
740 expect(regularSelect).not.toBe(customSelect);
741
742 // Clicking should only trigger onClick on both inputs.
743 clearMocks();
744 regularSelect.dispatchEvent(new Event('click', {bubbles: true}));
745 expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
746 expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
747 expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
748 customSelect.dispatchEvent(new Event('click', {bubbles: true}));
749 expect(customOnInputHandler).toHaveBeenCalledTimes(0);
750 expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
751 expect(customOnClickHandler).toHaveBeenCalledTimes(1);
752
753 // Native input event should only trigger onInput on both inputs.
754 clearMocks();
755 regularSelect.dispatchEvent(new Event('input', {bubbles: true}));
756 expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
757 expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
758 expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
759 customSelect.dispatchEvent(new Event('input', {bubbles: true}));
760 expect(customOnInputHandler).toHaveBeenCalledTimes(1);
761 expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
762 expect(customOnClickHandler).toHaveBeenCalledTimes(0);
763
764 // Native change event should trigger onChange.
765 clearMocks();
766 regularSelect.dispatchEvent(new Event('change', {bubbles: true}));
767 expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
768 expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
769 expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
770 customSelect.dispatchEvent(new Event('change', {bubbles: true}));
771 expect(customOnInputHandler).toHaveBeenCalledTimes(0);
772 expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
773 expect(customOnClickHandler).toHaveBeenCalledTimes(0);
774 });
775
776 it('onChange/onInput/onClick on div with various types of children', async () => {
777 const container = document.createElement('div');
778 document.body.appendChild(container);
779 const root = ReactDOMClient.createRoot(container);
780 const onChangeHandler = jest.fn();
781 const onInputHandler = jest.fn();
782 const onClickHandler = jest.fn();
783 function clearMocks() {
784 onChangeHandler.mockClear();
785 onInputHandler.mockClear();
786 onClickHandler.mockClear();
787 }
788 await act(() => {
789 root.render(
790 <div
791 onChange={onChangeHandler}
792 onInput={onInputHandler}
793 onClick={onClickHandler}>
794 <my-custom-element />
795 <input />
796 <input is="my-custom-element" />
797 </div>,
798 );
799 });
800 const customElement = container.querySelector('my-custom-element');
801 const regularInput = container.querySelector(
802 'input:not([is="my-custom-element"])',
803 );
804 const customInput = container.querySelector(
805 'input[is="my-custom-element"]',
806 );
807 expect(regularInput).not.toBe(customInput);
808
809 // Custom element has no special logic for input/change.
810 clearMocks();
811 customElement.dispatchEvent(new Event('input', {bubbles: true}));
812 expect(onChangeHandler).toHaveBeenCalledTimes(0);
813 expect(onInputHandler).toHaveBeenCalledTimes(1);
814 expect(onClickHandler).toHaveBeenCalledTimes(0);
815 customElement.dispatchEvent(new Event('change', {bubbles: true}));
816 expect(onChangeHandler).toHaveBeenCalledTimes(1);
817 expect(onInputHandler).toHaveBeenCalledTimes(1);
818 expect(onClickHandler).toHaveBeenCalledTimes(0);
819 customElement.dispatchEvent(new Event('click', {bubbles: true}));
820 expect(onChangeHandler).toHaveBeenCalledTimes(1);
821 expect(onInputHandler).toHaveBeenCalledTimes(1);
822 expect(onClickHandler).toHaveBeenCalledTimes(1);
823
824 // Regular input treats browser input as onChange.
825 clearMocks();
826 setUntrackedValue.call(regularInput, 'hello');
827 regularInput.dispatchEvent(new Event('input', {bubbles: true}));
828 expect(onChangeHandler).toHaveBeenCalledTimes(1);
829 expect(onInputHandler).toHaveBeenCalledTimes(1);
830 expect(onClickHandler).toHaveBeenCalledTimes(0);
831 regularInput.dispatchEvent(new Event('change', {bubbles: true}));
832 expect(onChangeHandler).toHaveBeenCalledTimes(1);
833 expect(onInputHandler).toHaveBeenCalledTimes(1);
834 expect(onClickHandler).toHaveBeenCalledTimes(0);
835 regularInput.dispatchEvent(new Event('click', {bubbles: true}));
836 expect(onChangeHandler).toHaveBeenCalledTimes(1);
837 expect(onInputHandler).toHaveBeenCalledTimes(1);
838 expect(onClickHandler).toHaveBeenCalledTimes(1);
839
840 // Custom input treats browser input as onChange.
841 clearMocks();
842 setUntrackedValue.call(customInput, 'hello');
843 customInput.dispatchEvent(new Event('input', {bubbles: true}));
844 expect(onChangeHandler).toHaveBeenCalledTimes(1);
845 expect(onInputHandler).toHaveBeenCalledTimes(1);
846 expect(onClickHandler).toHaveBeenCalledTimes(0);
847 customInput.dispatchEvent(new Event('change', {bubbles: true}));
848 expect(onChangeHandler).toHaveBeenCalledTimes(1);
849 expect(onInputHandler).toHaveBeenCalledTimes(1);
850 expect(onClickHandler).toHaveBeenCalledTimes(0);
851 customInput.dispatchEvent(new Event('click', {bubbles: true}));
852 expect(onChangeHandler).toHaveBeenCalledTimes(1);
853 expect(onInputHandler).toHaveBeenCalledTimes(1);
854 expect(onClickHandler).toHaveBeenCalledTimes(1);
855 });
856
857 it('custom element onChange/onInput/onClick with event target input child', async () => {
858 const container = document.createElement('div');
859 document.body.appendChild(container);
860 const root = ReactDOMClient.createRoot(container);
861 const onChangeHandler = jest.fn();
862 const onInputHandler = jest.fn();
863 const onClickHandler = jest.fn();
864 await act(() => {
865 root.render(
866 <my-custom-element
867 onChange={onChangeHandler}
868 onInput={onInputHandler}
869 onClick={onClickHandler}>
870 <input />
871 </my-custom-element>,
872 );
873 });
874
875 const input = container.querySelector('input');
876 setUntrackedValue.call(input, 'hello');
877 input.dispatchEvent(new Event('input', {bubbles: true}));
878 // Simulated onChange from the child's input event
879 // bubbles to the parent custom element.
880 expect(onChangeHandler).toHaveBeenCalledTimes(1);
881 expect(onInputHandler).toHaveBeenCalledTimes(1);
882 expect(onClickHandler).toHaveBeenCalledTimes(0);
883 // Consequently, the native change event is ignored.
884 input.dispatchEvent(new Event('change', {bubbles: true}));
885 expect(onChangeHandler).toHaveBeenCalledTimes(1);
886 expect(onInputHandler).toHaveBeenCalledTimes(1);
887 expect(onClickHandler).toHaveBeenCalledTimes(0);
888 input.dispatchEvent(new Event('click', {bubbles: true}));
889 expect(onChangeHandler).toHaveBeenCalledTimes(1);
890 expect(onInputHandler).toHaveBeenCalledTimes(1);
891 expect(onClickHandler).toHaveBeenCalledTimes(1);
892 });
893
894 it('custom element onChange/onInput/onClick with event target div child', async () => {
895 const container = document.createElement('div');
896 document.body.appendChild(container);
897 const root = ReactDOMClient.createRoot(container);
898 const onChangeHandler = jest.fn();
899 const onInputHandler = jest.fn();
900 const onClickHandler = jest.fn();
901 await act(() => {
902 root.render(
903 <my-custom-element
904 onChange={onChangeHandler}
905 onInput={onInputHandler}
906 onClick={onClickHandler}>
907 <div />
908 </my-custom-element>,
909 );
910 });
911
912 const div = container.querySelector('div');
913 div.dispatchEvent(new Event('input', {bubbles: true}));
914 expect(onChangeHandler).toHaveBeenCalledTimes(0);
915 expect(onInputHandler).toHaveBeenCalledTimes(1);
916 expect(onClickHandler).toHaveBeenCalledTimes(0);
917
918 div.dispatchEvent(new Event('change', {bubbles: true}));
919 // React always ignores change event invoked on non-custom and non-input targets.
920 // So change event emitted on a div does not propagate upwards.
921 expect(onChangeHandler).toHaveBeenCalledTimes(0);
922 expect(onInputHandler).toHaveBeenCalledTimes(1);
923 expect(onClickHandler).toHaveBeenCalledTimes(0);
924
925 div.dispatchEvent(new Event('click', {bubbles: true}));
926 expect(onChangeHandler).toHaveBeenCalledTimes(0);
927 expect(onInputHandler).toHaveBeenCalledTimes(1);
928 expect(onClickHandler).toHaveBeenCalledTimes(1);
929 });
930
931 it('div onChange/onInput/onClick with event target div child', async () => {
932 const container = document.createElement('div');
933 document.body.appendChild(container);
934 const root = ReactDOMClient.createRoot(container);
935 const onChangeHandler = jest.fn();
936 const onInputHandler = jest.fn();
937 const onClickHandler = jest.fn();
938 await act(() => {
939 root.render(
940 <div
941 onChange={onChangeHandler}
942 onInput={onInputHandler}
943 onClick={onClickHandler}>
944 <div />
945 </div>,
946 );
947 });
948
949 const div = container.querySelector('div > div');
950 div.dispatchEvent(new Event('input', {bubbles: true}));
951 expect(onChangeHandler).toHaveBeenCalledTimes(0);
952 expect(onInputHandler).toHaveBeenCalledTimes(1);
953 expect(onClickHandler).toHaveBeenCalledTimes(0);
954
955 div.dispatchEvent(new Event('change', {bubbles: true}));
956 // React always ignores change event invoked on non-custom and non-input targets.
957 // So change event emitted on a div does not propagate upwards.
958 expect(onChangeHandler).toHaveBeenCalledTimes(0);
959 expect(onInputHandler).toHaveBeenCalledTimes(1);
960 expect(onClickHandler).toHaveBeenCalledTimes(0);
961
962 div.dispatchEvent(new Event('click', {bubbles: true}));
963 expect(onChangeHandler).toHaveBeenCalledTimes(0);
964 expect(onInputHandler).toHaveBeenCalledTimes(1);
965 expect(onClickHandler).toHaveBeenCalledTimes(1);
966 });
967
968 it('custom element onChange/onInput/onClick with event target custom element child', async () => {
969 const container = document.createElement('div');
970 document.body.appendChild(container);
971 const root = ReactDOMClient.createRoot(container);
972 const onChangeHandler = jest.fn();
973 const onInputHandler = jest.fn();
974 const onClickHandler = jest.fn();
975 await act(() => {
976 root.render(
977 <my-custom-element
978 onChange={onChangeHandler}
979 onInput={onInputHandler}
980 onClick={onClickHandler}>
981 <other-custom-element />
982 </my-custom-element>,
983 );
984 });
985
986 const customChild = container.querySelector('other-custom-element');
987 customChild.dispatchEvent(new Event('input', {bubbles: true}));
988 // There is no simulated onChange, only raw onInput is dispatched.
989 expect(onChangeHandler).toHaveBeenCalledTimes(0);
990 expect(onInputHandler).toHaveBeenCalledTimes(1);
991 expect(onClickHandler).toHaveBeenCalledTimes(0);
992 // The native change event propagates to the parent as onChange.
993 customChild.dispatchEvent(new Event('change', {bubbles: true}));
994 expect(onChangeHandler).toHaveBeenCalledTimes(1);
995 expect(onInputHandler).toHaveBeenCalledTimes(1);
996 expect(onClickHandler).toHaveBeenCalledTimes(0);
997 customChild.dispatchEvent(new Event('click', {bubbles: true}));
998 expect(onChangeHandler).toHaveBeenCalledTimes(1);
999 expect(onInputHandler).toHaveBeenCalledTimes(1);
1000 expect(onClickHandler).toHaveBeenCalledTimes(1);
1001 });
1002
1003 it('custom elements should allow custom events with capture event listeners', async () => {
1004 const oncustomeventCapture = jest.fn();
1005 const oncustomevent = jest.fn();
1006 function Test() {
1007 return (
1008 <my-custom-element
1009 oncustomeventCapture={oncustomeventCapture}
1010 oncustomevent={oncustomevent}>
1011 <div />
1012 </my-custom-element>
1013 );
1014 }
1015 const container = document.createElement('div');
1016 const root = ReactDOMClient.createRoot(container);
1017 await act(() => {
1018 root.render(<Test />);
1019 });
1020 container
1021 .querySelector('my-custom-element > div')
1022 .dispatchEvent(new Event('customevent', {bubbles: false}));
1023 expect(oncustomeventCapture).toHaveBeenCalledTimes(1);
1024 expect(oncustomevent).toHaveBeenCalledTimes(0);
1025 });
1026
1027 it('innerHTML should not work on custom elements', async () => {
1028 const container = document.createElement('div');
1029 const root = ReactDOMClient.createRoot(container);
1030 await act(() => {
1031 root.render(<my-custom-element innerHTML="foo" />);
1032 });
1033 const customElement = container.querySelector('my-custom-element');
1034 expect(customElement.getAttribute('innerHTML')).toBe(null);
1035 expect(customElement.hasChildNodes()).toBe(false);
1036
1037 // Render again to verify the update codepath doesn't accidentally let
1038 // something through.
1039 await act(() => {
1040 root.render(<my-custom-element innerHTML="bar" />);
1041 });
1042 expect(customElement.getAttribute('innerHTML')).toBe(null);
1043 expect(customElement.hasChildNodes()).toBe(false);
1044 });
1045
1046 it('innerText should not work on custom elements', async () => {
1047 const container = document.createElement('div');
1048 const root = ReactDOMClient.createRoot(container);
1049 await act(() => {
1050 root.render(<my-custom-element innerText="foo" />);
1051 });
1052 const customElement = container.querySelector('my-custom-element');
1053 expect(customElement.getAttribute('innerText')).toBe(null);
1054 expect(customElement.hasChildNodes()).toBe(false);
1055
1056 // Render again to verify the update codepath doesn't accidentally let
1057 // something through.
1058 await act(() => {
1059 root.render(<my-custom-element innerText="bar" />);
1060 });
1061 expect(customElement.getAttribute('innerText')).toBe(null);
1062 expect(customElement.hasChildNodes()).toBe(false);
1063 });
1064
1065 it('textContent should not work on custom elements', async () => {
1066 const container = document.createElement('div');
1067 const root = ReactDOMClient.createRoot(container);
1068 await act(() => {
1069 root.render(<my-custom-element textContent="foo" />);
1070 });
1071 const customElement = container.querySelector('my-custom-element');
1072 expect(customElement.getAttribute('textContent')).toBe(null);
1073 expect(customElement.hasChildNodes()).toBe(false);
1074
1075 // Render again to verify the update codepath doesn't accidentally let
1076 // something through.
1077 await act(() => {
1078 root.render(<my-custom-element textContent="bar" />);
1079 });
1080 expect(customElement.getAttribute('textContent')).toBe(null);
1081 expect(customElement.hasChildNodes()).toBe(false);
1082 });
1083
1084 it('values should not be converted to booleans when assigning into custom elements', async () => {
1085 const container = document.createElement('div');
1086 document.body.appendChild(container);
1087 const root = ReactDOMClient.createRoot(container);
1088 await act(() => {
1089 root.render(<my-custom-element />);
1090 });
1091 const customElement = container.querySelector('my-custom-element');
1092 customElement.foo = null;
1093
1094 // true => string
1095 await act(() => {
1096 root.render(<my-custom-element foo={true} />);
1097 });
1098 expect(customElement.foo).toBe(true);
1099 await act(() => {
1100 root.render(<my-custom-element foo="bar" />);
1101 });
1102 expect(customElement.foo).toBe('bar');
1103
1104 // false => string
1105 await act(() => {
1106 root.render(<my-custom-element foo={false} />);
1107 });
1108 expect(customElement.foo).toBe(false);
1109 await act(() => {
1110 root.render(<my-custom-element foo="bar" />);
1111 });
1112 expect(customElement.foo).toBe('bar');
1113
1114 // true => null
1115 await act(() => {
1116 root.render(<my-custom-element foo={true} />);
1117 });
1118 expect(customElement.foo).toBe(true);
1119 await act(() => {
1120 root.render(<my-custom-element foo={null} />);
1121 });
1122 expect(customElement.foo).toBe(null);
1123
1124 // false => null
1125 await act(() => {
1126 root.render(<my-custom-element foo={false} />);
1127 });
1128 expect(customElement.foo).toBe(false);
1129 await act(() => {
1130 root.render(<my-custom-element foo={null} />);
1131 });
1132 expect(customElement.foo).toBe(null);
1133 });
1134
1135 it('boolean props should not be stringified in attributes', async () => {
1136 const container = document.createElement('div');
1137 document.body.appendChild(container);
1138 const root = ReactDOMClient.createRoot(container);
1139 await act(() => {
1140 root.render(<my-custom-element foo={true} />);
1141 });
1142 const customElement = container.querySelector('my-custom-element');
1143
1144 expect(customElement.getAttribute('foo')).toBe('');
1145
1146 // true => false
1147 await act(() => {
1148 root.render(<my-custom-element foo={false} />);
1149 });
1150
1151 expect(customElement.getAttribute('foo')).toBe(null);
1152 });
1153
1154 it('custom element custom event handlers assign multiple types', async () => {
1155 const container = document.createElement('div');
1156 document.body.appendChild(container);
1157 const root = ReactDOMClient.createRoot(container);
1158 const oncustomevent = jest.fn();
1159
1160 // First render with string
1161 await act(() => {
1162 root.render(<my-custom-element oncustomevent={'foo'} />);
1163 });
1164 const customelement = container.querySelector('my-custom-element');
1165 customelement.dispatchEvent(new Event('customevent'));
1166 expect(oncustomevent).toHaveBeenCalledTimes(0);
1167 expect(customelement.oncustomevent).toBe(undefined);
1168 expect(customelement.getAttribute('oncustomevent')).toBe('foo');
1169
1170 // string => event listener
1171 await act(() => {
1172 root.render(<my-custom-element oncustomevent={oncustomevent} />);
1173 });
1174 customelement.dispatchEvent(new Event('customevent'));
1175 expect(oncustomevent).toHaveBeenCalledTimes(1);
1176 expect(customelement.oncustomevent).toBe(undefined);
1177 expect(customelement.getAttribute('oncustomevent')).toBe(null);
1178
1179 // event listener => string
1180 await act(() => {
1181 root.render(<my-custom-element oncustomevent={'foo'} />);
1182 });
1183 customelement.dispatchEvent(new Event('customevent'));
1184 expect(oncustomevent).toHaveBeenCalledTimes(1);
1185 expect(customelement.oncustomevent).toBe(undefined);
1186 expect(customelement.getAttribute('oncustomevent')).toBe('foo');
1187
1188 // string => nothing
1189 await act(() => {
1190 root.render(<my-custom-element />);
1191 });
1192 customelement.dispatchEvent(new Event('customevent'));
1193 expect(oncustomevent).toHaveBeenCalledTimes(1);
1194 expect(customelement.oncustomevent).toBe(undefined);
1195 expect(customelement.getAttribute('oncustomevent')).toBe(null);
1196
1197 // nothing => event listener
1198 await act(() => {
1199 root.render(<my-custom-element oncustomevent={oncustomevent} />);
1200 });
1201 customelement.dispatchEvent(new Event('customevent'));
1202 expect(oncustomevent).toHaveBeenCalledTimes(2);
1203 expect(customelement.oncustomevent).toBe(undefined);
1204 expect(customelement.getAttribute('oncustomevent')).toBe(null);
1205 });
1206
1207 it('custom element custom event handlers assign multiple types with setter', async () => {
1208 const container = document.createElement('div');
1209 document.body.appendChild(container);
1210 const root = ReactDOMClient.createRoot(container);
1211 const oncustomevent = jest.fn();
1212
1213 // First render with nothing
1214 await act(() => {
1215 root.render(<my-custom-element />);
1216 });
1217 const customelement = container.querySelector('my-custom-element');
1218 // Install a setter to activate the `in` heuristic
1219 Object.defineProperty(customelement, 'oncustomevent', {
1220 set: function (x) {
1221 this._oncustomevent = x;
1222 },
1223 get: function () {
1224 return this._oncustomevent;
1225 },
1226 });
1227 expect(customelement.oncustomevent).toBe(undefined);
1228
1229 // nothing => event listener
1230 await act(() => {
1231 root.render(<my-custom-element oncustomevent={oncustomevent} />);
1232 });
1233 customelement.dispatchEvent(new Event('customevent'));
1234 expect(oncustomevent).toHaveBeenCalledTimes(1);
1235 expect(customelement.oncustomevent).toBe(null);
1236 expect(customelement.getAttribute('oncustomevent')).toBe(null);
1237
1238 // event listener => string
1239 await act(() => {
1240 root.render(<my-custom-element oncustomevent={'foo'} />);
1241 });
1242 customelement.dispatchEvent(new Event('customevent'));
1243 expect(oncustomevent).toHaveBeenCalledTimes(1);
1244 expect(customelement.oncustomevent).toBe('foo');
1245 expect(customelement.getAttribute('oncustomevent')).toBe(null);
1246
1247 // string => event listener
1248 await act(() => {
1249 root.render(<my-custom-element oncustomevent={oncustomevent} />);
1250 });
1251 customelement.dispatchEvent(new Event('customevent'));
1252 expect(oncustomevent).toHaveBeenCalledTimes(2);
1253 expect(customelement.oncustomevent).toBe(null);
1254 expect(customelement.getAttribute('oncustomevent')).toBe(null);
1255
1256 // event listener => nothing
1257 await act(() => {
1258 root.render(<my-custom-element />);
1259 });
1260 customelement.dispatchEvent(new Event('customevent'));
1261 expect(oncustomevent).toHaveBeenCalledTimes(2);
1262 expect(customelement.oncustomevent).toBe(undefined);
1263 expect(customelement.getAttribute('oncustomevent')).toBe(null);
1264 });
1265
1266 it('assigning to a custom element property should not remove attributes', async () => {
1267 const container = document.createElement('div');
1268 document.body.appendChild(container);
1269 const root = ReactDOMClient.createRoot(container);
1270 await act(() => {
1271 root.render(<my-custom-element foo="one" />);
1272 });
1273 const customElement = container.querySelector('my-custom-element');
1274 expect(customElement.getAttribute('foo')).toBe('one');
1275
1276 // Install a setter to activate the `in` heuristic
1277 Object.defineProperty(customElement, 'foo', {
1278 set: function (x) {
1279 this._foo = x;
1280 },
1281 get: function () {
1282 return this._foo;
1283 },
1284 });
1285 await act(() => {
1286 root.render(<my-custom-element foo="two" />);
1287 });
1288 expect(customElement.foo).toBe('two');
1289 expect(customElement.getAttribute('foo')).toBe('one');
1290 });
1291
1292 it('custom element properties should accept functions', async () => {
1293 const container = document.createElement('div');
1294 document.body.appendChild(container);
1295 const root = ReactDOMClient.createRoot(container);
1296 await act(() => {
1297 root.render(<my-custom-element />);
1298 });
1299 const customElement = container.querySelector('my-custom-element');
1300
1301 // Install a setter to activate the `in` heuristic
1302 Object.defineProperty(customElement, 'foo', {
1303 set: function (x) {
1304 this._foo = x;
1305 },
1306 get: function () {
1307 return this._foo;
1308 },
1309 });
1310 function myFunction() {
1311 return 'this is myFunction';
1312 }
1313 await act(() => {
1314 root.render(<my-custom-element foo={myFunction} />);
1315 });
1316 expect(customElement.foo).toBe(myFunction);
1317
1318 // Also remove and re-add the property for good measure
1319 await act(() => {
1320 root.render(<my-custom-element />);
1321 });
1322 expect(customElement.foo).toBe(undefined);
1323 await act(() => {
1324 root.render(<my-custom-element foo={myFunction} />);
1325 });
1326 expect(customElement.foo).toBe(myFunction);
1327 });
1328
1329 it('switching between null and undefined should update a property', async () => {
1330 const container = document.createElement('div');
1331 document.body.appendChild(container);
1332 const root = ReactDOMClient.createRoot(container);
1333 await act(() => {
1334 root.render(<my-custom-element foo={undefined} />);
1335 });
1336 const customElement = container.querySelector('my-custom-element');
1337 customElement.foo = undefined;
1338
1339 await act(() => {
1340 root.render(<my-custom-element foo={null} />);
1341 });
1342 expect(customElement.foo).toBe(null);
1343
1344 await act(() => {
1345 root.render(<my-custom-element foo={undefined} />);
1346 });
1347 expect(customElement.foo).toBe(undefined);
1348 });
1349
1350 it('warns when using popoverTarget={HTMLElement}', async () => {
1351 const popoverTarget = document.createElement('div');
1352 const container = document.createElement('div');
1353 const root = ReactDOMClient.createRoot(container);
1354
1355 await act(() => {
1356 root.render(
1357 <button key="one" popoverTarget={popoverTarget}>
1358 Toggle popover
1359 </button>,
1360 );
1361 });
1362
1363 assertConsoleErrorDev([
1364 'The `popoverTarget` prop expects the ID of an Element as a string. Received HTMLDivElement {} instead.\n' +
1365 ' in button (at **)',
1366 ]);
1367
1368 // Dedupe warning
1369 await act(() => {
1370 root.render(
1371 <button key="two" popoverTarget={popoverTarget}>
1372 Toggle popover
1373 </button>,
1374 );
1375 });
1376 });
1377 });
1378
1379 describe('deleteValueForProperty', () => {
1380 it('should remove attributes for normal properties', async () => {
1381 const container = document.createElement('div');
1382 const root = ReactDOMClient.createRoot(container);
1383 await act(() => {
1384 root.render(<div title="foo" />);
1385 });
1386 expect(container.firstChild.getAttribute('title')).toBe('foo');
1387 await act(() => {
1388 root.render(<div />);
1389 });
1390 expect(container.firstChild.getAttribute('title')).toBe(null);
1391 });
1392
1393 it('should not remove attributes for special properties', async () => {
1394 const container = document.createElement('div');
1395 const root = ReactDOMClient.createRoot(container);
1396 await act(() => {
1397 root.render(
1398 <input type="text" value="foo" onChange={function () {}} />,
1399 );
1400 });
1401 if (disableInputAttributeSyncing) {
1402 expect(container.firstChild.hasAttribute('value')).toBe(false);
1403 } else {
1404 expect(container.firstChild.getAttribute('value')).toBe('foo');
1405 }
1406 expect(container.firstChild.value).toBe('foo');
1407 await act(() => {
1408 root.render(<input type="text" onChange={function () {}} />);
1409 });
1410 assertConsoleErrorDev([
1411 'A component is changing a controlled input to be uncontrolled. ' +
1412 'This is likely caused by the value changing from a defined to undefined, ' +
1413 'which should not happen. Decide between using a controlled or uncontrolled ' +
1414 'input element for the lifetime of the component. ' +
1415 'More info: https://react.dev/link/controlled-components\n' +
1416 ' in input (at **)',
1417 ]);
1418 if (disableInputAttributeSyncing) {
1419 expect(container.firstChild.hasAttribute('value')).toBe(false);
1420 } else {
1421 expect(container.firstChild.getAttribute('value')).toBe('foo');
1422 }
1423 expect(container.firstChild.value).toBe('foo');
1424 });
1425
1426 it('should not remove attributes for custom component tag', async () => {
1427 const container = document.createElement('div');
1428 const root = ReactDOMClient.createRoot(container);
1429 await act(() => {
1430 root.render(<my-icon size="5px" />);
1431 });
1432 expect(container.firstChild.getAttribute('size')).toBe('5px');
1433 });
1434
1435 it('custom elements should remove by setting undefined to restore defaults', async () => {
1436 const container = document.createElement('div');
1437 document.body.appendChild(container);
1438 const root = ReactDOMClient.createRoot(container);
1439 await act(() => {
1440 root.render(<my-custom-element />);
1441 });
1442 const customElement = container.querySelector('my-custom-element');
1443
1444 // Non-setter but existing property to active the `in` heuristic
1445 customElement.raw = 1;
1446
1447 // Install a setter to activate the `in` heuristic
1448 Object.defineProperty(customElement, 'object', {
1449 set: function (value = null) {
1450 this._object = value;
1451 },
1452 get: function () {
1453 return this._object;
1454 },
1455 });
1456
1457 Object.defineProperty(customElement, 'string', {
1458 set: function (value = '') {
1459 this._string = value;
1460 },
1461 get: function () {
1462 return this._string;
1463 },
1464 });
1465
1466 const obj = {};
1467 await act(() => {
1468 root.render(<my-custom-element raw={2} object={obj} string="hi" />);
1469 });
1470 expect(customElement.raw).toBe(2);
1471 expect(customElement.object).toBe(obj);
1472 expect(customElement.string).toBe('hi');
1473
1474 // Removing the properties should reset to defaults by passing undefined
1475 await act(() => {
1476 root.render(<my-custom-element />);
1477 });
1478 expect(customElement.raw).toBe(undefined);
1479 expect(customElement.object).toBe(null);
1480 expect(customElement.string).toBe('');
1481 });
1482 });
1483 });