@samitouri / QOS-React-2 / commits / 05a55a4b09

Fix change events for custom elements (#22938)

* Bypass react event system for custom elements * Going to try fixing react event system instead * finally got it to call onChange, but perhaps too many times * update test * Removed ReactDOMComponent changes, now works but still doubles for bubbles * Maybe i should only support bubbling events * removed some old stuff * cleaned up changeeventplugin stuff * prettier, lint * removed changeeventplugin stuff * remove unneeded gate for onInput test * Go back to using ChangeEventPlugin * Add input+change test * lint * Move logic to shouldUseChangeEvent * use memoizedProps instead of pendingProps * Run form control behavior before custom element behavior * add bubbling test * forgot to append container to body * add child event target test * expand <input is=...> test expectations * Make tests more realistic * Add extra test * Add missing gating * Actually fix gating Co-authored-by: Dan Abramov <dan.abramov@me.com>

Joey Arhar committed Jan 19, 2022 at 12:34 UTC 05a55a4b09b7b7c8f63778fb8252a001ca66f8d7
2 files changed +591 -1
packages/react-dom/src/__tests__/DOMPropertyOperations-test.js
+580
@@ -22,6 +22,17 @@ describe('DOMPropertyOperations', () => {
22 ReactDOM = require('react-dom');
23 });
24
25 + // Sets a value in a way that React doesn't see,
26 + // so that a subsequent "change" event will trigger the event handler.
27 + const setUntrackedValue = Object.getOwnPropertyDescriptor(
28 + HTMLInputElement.prototype,
29 + 'value',
30 + ).set;
31 + const setUntrackedChecked = Object.getOwnPropertyDescriptor(
32 + HTMLInputElement.prototype,
33 + 'checked',
34 + ).set;
35 +
36 describe('setValueForProperty', () => {
37 it('should set values as properties by default', () => {
38 const container = document.createElement('div');
@@ -280,6 +291,575 @@ describe('DOMPropertyOperations', () => {
291 expect(syntheticClickEvent.nativeEvent).toBe(nativeClickEvent);
292 });
293
294 + // @gate enableCustomElementPropertySupport
295 + it('custom elements should have working onChange event listeners', () => {
296 + let reactChangeEvent = null;
297 + const eventHandler = jest.fn(event => (reactChangeEvent = event));
298 + const container = document.createElement('div');
299 + document.body.appendChild(container);
300 + ReactDOM.render(<my-custom-element onChange={eventHandler} />, container);
301 + const customElement = container.querySelector('my-custom-element');
302 + let expectedHandlerCallCount = 0;
303 +
304 + const changeEvent = new Event('change', {bubbles: true});
305 + customElement.dispatchEvent(changeEvent);
306 + expectedHandlerCallCount++;
307 + expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
308 + expect(reactChangeEvent.nativeEvent).toBe(changeEvent);
309 +
310 + // Also make sure that removing and re-adding the event listener works
311 + ReactDOM.render(<my-custom-element />, container);
312 + customElement.dispatchEvent(new Event('change', {bubbles: true}));
313 + expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
314 + ReactDOM.render(<my-custom-element onChange={eventHandler} />, container);
315 + customElement.dispatchEvent(new Event('change', {bubbles: true}));
316 + expectedHandlerCallCount++;
317 + expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
318 + });
319 +
320 + it('custom elements should have working onInput event listeners', () => {
321 + let reactInputEvent = null;
322 + const eventHandler = jest.fn(event => (reactInputEvent = event));
323 + const container = document.createElement('div');
324 + document.body.appendChild(container);
325 + ReactDOM.render(<my-custom-element onInput={eventHandler} />, container);
326 + const customElement = container.querySelector('my-custom-element');
327 + let expectedHandlerCallCount = 0;
328 +
329 + const inputEvent = new Event('input', {bubbles: true});
330 + customElement.dispatchEvent(inputEvent);
331 + expectedHandlerCallCount++;
332 + expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
333 + expect(reactInputEvent.nativeEvent).toBe(inputEvent);
334 +
335 + // Also make sure that removing and re-adding the event listener works
336 + ReactDOM.render(<my-custom-element />, container);
337 + customElement.dispatchEvent(new Event('input', {bubbles: true}));
338 + expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
339 + ReactDOM.render(<my-custom-element onInput={eventHandler} />, container);
340 + customElement.dispatchEvent(new Event('input', {bubbles: true}));
341 + expectedHandlerCallCount++;
342 + expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
343 + });
344 +
345 + // @gate enableCustomElementPropertySupport
346 + it('custom elements should have separate onInput and onChange handling', () => {
347 + const container = document.createElement('div');
348 + document.body.appendChild(container);
349 + const inputEventHandler = jest.fn();
350 + const changeEventHandler = jest.fn();
351 + ReactDOM.render(
352 + <my-custom-element
353 + onInput={inputEventHandler}
354 + onChange={changeEventHandler}
355 + />,
356 + container,
357 + );
358 + const customElement = container.querySelector('my-custom-element');
359 +
360 + customElement.dispatchEvent(new Event('input', {bubbles: true}));
361 + expect(inputEventHandler).toHaveBeenCalledTimes(1);
362 + expect(changeEventHandler).toHaveBeenCalledTimes(0);
363 +
364 + customElement.dispatchEvent(new Event('change', {bubbles: true}));
365 + expect(inputEventHandler).toHaveBeenCalledTimes(1);
366 + expect(changeEventHandler).toHaveBeenCalledTimes(1);
367 + });
368 +
369 + // @gate enableCustomElementPropertySupport
370 + it('custom elements should be able to remove and re-add custom event listeners', () => {
371 + const container = document.createElement('div');
372 + document.body.appendChild(container);
373 + const eventHandler = jest.fn();
374 + ReactDOM.render(
375 + <my-custom-element oncustomevent={eventHandler} />,
376 + container,
377 + );
378 +
379 + const customElement = container.querySelector('my-custom-element');
380 + customElement.dispatchEvent(new Event('customevent'));
381 + expect(eventHandler).toHaveBeenCalledTimes(1);
382 +
383 + ReactDOM.render(<my-custom-element />, container);
384 + customElement.dispatchEvent(new Event('customevent'));
385 + expect(eventHandler).toHaveBeenCalledTimes(1);
386 +
387 + ReactDOM.render(
388 + <my-custom-element oncustomevent={eventHandler} />,
389 + container,
390 + );
391 + customElement.dispatchEvent(new Event('customevent'));
392 + expect(eventHandler).toHaveBeenCalledTimes(2);
393 + });
394 +
395 + it('<input is=...> should have the same onChange/onInput/onClick behavior as <input>', () => {
396 + const container = document.createElement('div');
397 + document.body.appendChild(container);
398 + const regularOnInputHandler = jest.fn();
399 + const regularOnChangeHandler = jest.fn();
400 + const regularOnClickHandler = jest.fn();
401 + const customOnInputHandler = jest.fn();
402 + const customOnChangeHandler = jest.fn();
403 + const customOnClickHandler = jest.fn();
404 + function clearMocks() {
405 + regularOnInputHandler.mockClear();
406 + regularOnChangeHandler.mockClear();
407 + regularOnClickHandler.mockClear();
408 + customOnInputHandler.mockClear();
409 + customOnChangeHandler.mockClear();
410 + customOnClickHandler.mockClear();
411 + }
412 + ReactDOM.render(
413 + <div>
414 + <input
415 + onInput={regularOnInputHandler}
416 + onChange={regularOnChangeHandler}
417 + onClick={regularOnClickHandler}
418 + />
419 + <input
420 + is="my-custom-element"
421 + onInput={customOnInputHandler}
422 + onChange={customOnChangeHandler}
423 + onClick={customOnClickHandler}
424 + />
425 + </div>,
426 + container,
427 + );
428 +
429 + const regularInput = container.querySelector(
430 + 'input:not([is=my-custom-element])',
431 + );
432 + const customInput = container.querySelector(
433 + 'input[is=my-custom-element]',
434 + );
435 + expect(regularInput).not.toBe(customInput);
436 +
437 + // Typing should trigger onInput and onChange for both kinds of inputs.
438 + clearMocks();
439 + setUntrackedValue.call(regularInput, 'hello');
440 + regularInput.dispatchEvent(new Event('input', {bubbles: true}));
441 + expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
442 + expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
443 + expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
444 + setUntrackedValue.call(customInput, 'hello');
445 + customInput.dispatchEvent(new Event('input', {bubbles: true}));
446 + expect(customOnInputHandler).toHaveBeenCalledTimes(1);
447 + expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
448 + expect(customOnClickHandler).toHaveBeenCalledTimes(0);
449 +
450 + // The native change event itself does not produce extra React events.
451 + clearMocks();
452 + regularInput.dispatchEvent(new Event('change', {bubbles: true}));
453 + expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
454 + expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
455 + expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
456 + customInput.dispatchEvent(new Event('change', {bubbles: true}));
457 + expect(customOnInputHandler).toHaveBeenCalledTimes(0);
458 + expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
459 + expect(customOnClickHandler).toHaveBeenCalledTimes(0);
460 +
461 + // The click event is handled by both inputs.
462 + clearMocks();
463 + regularInput.dispatchEvent(new Event('click', {bubbles: true}));
464 + expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
465 + expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
466 + expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
467 + customInput.dispatchEvent(new Event('click', {bubbles: true}));
468 + expect(customOnInputHandler).toHaveBeenCalledTimes(0);
469 + expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
470 + expect(customOnClickHandler).toHaveBeenCalledTimes(1);
471 +
472 + // Typing again should trigger onInput and onChange for both kinds of inputs.
473 + clearMocks();
474 + setUntrackedValue.call(regularInput, 'goodbye');
475 + regularInput.dispatchEvent(new Event('input', {bubbles: true}));
476 + expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
477 + expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
478 + expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
479 + setUntrackedValue.call(customInput, 'goodbye');
480 + customInput.dispatchEvent(new Event('input', {bubbles: true}));
481 + expect(customOnInputHandler).toHaveBeenCalledTimes(1);
482 + expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
483 + expect(customOnClickHandler).toHaveBeenCalledTimes(0);
484 + });
485 +
486 + it('<input type=radio is=...> should have the same onChange/onInput/onClick behavior as <input type=radio>', () => {
487 + const container = document.createElement('div');
488 + document.body.appendChild(container);
489 + const regularOnInputHandler = jest.fn();
490 + const regularOnChangeHandler = jest.fn();
491 + const regularOnClickHandler = jest.fn();
492 + const customOnInputHandler = jest.fn();
493 + const customOnChangeHandler = jest.fn();
494 + const customOnClickHandler = jest.fn();
495 + function clearMocks() {
496 + regularOnInputHandler.mockClear();
497 + regularOnChangeHandler.mockClear();
498 + regularOnClickHandler.mockClear();
499 + customOnInputHandler.mockClear();
500 + customOnChangeHandler.mockClear();
501 + customOnClickHandler.mockClear();
502 + }
503 + ReactDOM.render(
504 + <div>
505 + <input
506 + type="radio"
507 + onInput={regularOnInputHandler}
508 + onChange={regularOnChangeHandler}
509 + onClick={regularOnClickHandler}
510 + />
511 + <input
512 + is="my-custom-element"
513 + type="radio"
514 + onInput={customOnInputHandler}
515 + onChange={customOnChangeHandler}
516 + onClick={customOnClickHandler}
517 + />
518 + </div>,
519 + container,
520 + );
521 +
522 + const regularInput = container.querySelector(
523 + 'input:not([is=my-custom-element])',
524 + );
525 + const customInput = container.querySelector(
526 + 'input[is=my-custom-element]',
527 + );
528 + expect(regularInput).not.toBe(customInput);
529 +
530 + // Clicking should trigger onClick and onChange on both inputs.
531 + clearMocks();
532 + setUntrackedChecked.call(regularInput, true);
533 + regularInput.dispatchEvent(new Event('click', {bubbles: true}));
534 + expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
535 + expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
536 + expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
537 + setUntrackedChecked.call(customInput, true);
538 + customInput.dispatchEvent(new Event('click', {bubbles: true}));
539 + expect(customOnInputHandler).toHaveBeenCalledTimes(0);
540 + expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
541 + expect(customOnClickHandler).toHaveBeenCalledTimes(1);
542 +
543 + // The native input event only produces a React onInput event.
544 + clearMocks();
545 + regularInput.dispatchEvent(new Event('input', {bubbles: true}));
546 + expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
547 + expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
548 + expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
549 + customInput.dispatchEvent(new Event('input', {bubbles: true}));
550 + expect(customOnInputHandler).toHaveBeenCalledTimes(1);
551 + expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
552 + expect(customOnClickHandler).toHaveBeenCalledTimes(0);
553 +
554 + // Clicking again should trigger onClick and onChange on both inputs.
555 + clearMocks();
556 + setUntrackedChecked.call(regularInput, false);
557 + regularInput.dispatchEvent(new Event('click', {bubbles: true}));
558 + expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
559 + expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
560 + expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
561 + setUntrackedChecked.call(customInput, false);
562 + customInput.dispatchEvent(new Event('click', {bubbles: true}));
563 + expect(customOnInputHandler).toHaveBeenCalledTimes(0);
564 + expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
565 + expect(customOnClickHandler).toHaveBeenCalledTimes(1);
566 + });
567 +
568 + it('<select is=...> should have the same onChange/onInput/onClick behavior as <select>', () => {
569 + const container = document.createElement('div');
570 + document.body.appendChild(container);
571 + const regularOnInputHandler = jest.fn();
572 + const regularOnChangeHandler = jest.fn();
573 + const regularOnClickHandler = jest.fn();
574 + const customOnInputHandler = jest.fn();
575 + const customOnChangeHandler = jest.fn();
576 + const customOnClickHandler = jest.fn();
577 + function clearMocks() {
578 + regularOnInputHandler.mockClear();
579 + regularOnChangeHandler.mockClear();
580 + regularOnClickHandler.mockClear();
581 + customOnInputHandler.mockClear();
582 + customOnChangeHandler.mockClear();
583 + customOnClickHandler.mockClear();
584 + }
585 + ReactDOM.render(
586 + <div>
587 + <select
588 + onInput={regularOnInputHandler}
589 + onChange={regularOnChangeHandler}
590 + onClick={regularOnClickHandler}
591 + />
592 + <select
593 + is="my-custom-element"
594 + onInput={customOnInputHandler}
595 + onChange={customOnChangeHandler}
596 + onClick={customOnClickHandler}
597 + />
598 + </div>,
599 + container,
600 + );
601 +
602 + const regularSelect = container.querySelector(
603 + 'select:not([is=my-custom-element])',
604 + );
605 + const customSelect = container.querySelector(
606 + 'select[is=my-custom-element]',
607 + );
608 + expect(regularSelect).not.toBe(customSelect);
609 +
610 + // Clicking should only trigger onClick on both inputs.
611 + clearMocks();
612 + regularSelect.dispatchEvent(new Event('click', {bubbles: true}));
613 + expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
614 + expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
615 + expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
616 + customSelect.dispatchEvent(new Event('click', {bubbles: true}));
617 + expect(customOnInputHandler).toHaveBeenCalledTimes(0);
618 + expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
619 + expect(customOnClickHandler).toHaveBeenCalledTimes(1);
620 +
621 + // Native input event should only trigger onInput on both inputs.
622 + clearMocks();
623 + regularSelect.dispatchEvent(new Event('input', {bubbles: true}));
624 + expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
625 + expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
626 + expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
627 + customSelect.dispatchEvent(new Event('input', {bubbles: true}));
628 + expect(customOnInputHandler).toHaveBeenCalledTimes(1);
629 + expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
630 + expect(customOnClickHandler).toHaveBeenCalledTimes(0);
631 +
632 + // Native change event should trigger onChange.
633 + clearMocks();
634 + regularSelect.dispatchEvent(new Event('change', {bubbles: true}));
635 + expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
636 + expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
637 + expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
638 + customSelect.dispatchEvent(new Event('change', {bubbles: true}));
639 + expect(customOnInputHandler).toHaveBeenCalledTimes(0);
640 + expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
641 + expect(customOnClickHandler).toHaveBeenCalledTimes(0);
642 + });
643 +
644 + // @gate enableCustomElementPropertySupport
645 + it('onChange/onInput/onClick on div with various types of children', () => {
646 + const container = document.createElement('div');
647 + document.body.appendChild(container);
648 + const onChangeHandler = jest.fn();
649 + const onInputHandler = jest.fn();
650 + const onClickHandler = jest.fn();
651 + function clearMocks() {
652 + onChangeHandler.mockClear();
653 + onInputHandler.mockClear();
654 + onClickHandler.mockClear();
655 + }
656 + ReactDOM.render(
657 + <div
658 + onChange={onChangeHandler}
659 + onInput={onInputHandler}
660 + onClick={onClickHandler}>
661 + <my-custom-element />
662 + <input />
663 + <input is="my-custom-element" />
664 + </div>,
665 + container,
666 + );
667 + const customElement = container.querySelector('my-custom-element');
668 + const regularInput = container.querySelector(
669 + 'input:not([is="my-custom-element"])',
670 + );
671 + const customInput = container.querySelector(
672 + 'input[is="my-custom-element"]',
673 + );
674 + expect(regularInput).not.toBe(customInput);
675 +
676 + // Custom element has no special logic for input/change.
677 + clearMocks();
678 + customElement.dispatchEvent(new Event('input', {bubbles: true}));
679 + expect(onChangeHandler).toBeCalledTimes(0);
680 + expect(onInputHandler).toBeCalledTimes(1);
681 + expect(onClickHandler).toBeCalledTimes(0);
682 + customElement.dispatchEvent(new Event('change', {bubbles: true}));
683 + expect(onChangeHandler).toBeCalledTimes(1);
684 + expect(onInputHandler).toBeCalledTimes(1);
685 + expect(onClickHandler).toBeCalledTimes(0);
686 + customElement.dispatchEvent(new Event('click', {bubbles: true}));
687 + expect(onChangeHandler).toBeCalledTimes(1);
688 + expect(onInputHandler).toBeCalledTimes(1);
689 + expect(onClickHandler).toBeCalledTimes(1);
690 +
691 + // Regular input treats browser input as onChange.
692 + clearMocks();
693 + setUntrackedValue.call(regularInput, 'hello');
694 + regularInput.dispatchEvent(new Event('input', {bubbles: true}));
695 + expect(onChangeHandler).toBeCalledTimes(1);
696 + expect(onInputHandler).toBeCalledTimes(1);
697 + expect(onClickHandler).toBeCalledTimes(0);
698 + regularInput.dispatchEvent(new Event('change', {bubbles: true}));
699 + expect(onChangeHandler).toBeCalledTimes(1);
700 + expect(onInputHandler).toBeCalledTimes(1);
701 + expect(onClickHandler).toBeCalledTimes(0);
702 + regularInput.dispatchEvent(new Event('click', {bubbles: true}));
703 + expect(onChangeHandler).toBeCalledTimes(1);
704 + expect(onInputHandler).toBeCalledTimes(1);
705 + expect(onClickHandler).toBeCalledTimes(1);
706 +
707 + // Custom input treats browser input as onChange.
708 + clearMocks();
709 + setUntrackedValue.call(customInput, 'hello');
710 + customInput.dispatchEvent(new Event('input', {bubbles: true}));
711 + expect(onChangeHandler).toBeCalledTimes(1);
712 + expect(onInputHandler).toBeCalledTimes(1);
713 + expect(onClickHandler).toBeCalledTimes(0);
714 + customInput.dispatchEvent(new Event('change', {bubbles: true}));
715 + expect(onChangeHandler).toBeCalledTimes(1);
716 + expect(onInputHandler).toBeCalledTimes(1);
717 + expect(onClickHandler).toBeCalledTimes(0);
718 + customInput.dispatchEvent(new Event('click', {bubbles: true}));
719 + expect(onChangeHandler).toBeCalledTimes(1);
720 + expect(onInputHandler).toBeCalledTimes(1);
721 + expect(onClickHandler).toBeCalledTimes(1);
722 + });
723 +
724 + it('custom element onChange/onInput/onClick with event target input child', () => {
725 + const container = document.createElement('div');
726 + document.body.appendChild(container);
727 + const onChangeHandler = jest.fn();
728 + const onInputHandler = jest.fn();
729 + const onClickHandler = jest.fn();
730 + ReactDOM.render(
731 + <my-custom-element
732 + onChange={onChangeHandler}
733 + onInput={onInputHandler}
734 + onClick={onClickHandler}>
735 + <input />
736 + </my-custom-element>,
737 + container,
738 + );
739 +
740 + const input = container.querySelector('input');
741 + setUntrackedValue.call(input, 'hello');
742 + input.dispatchEvent(new Event('input', {bubbles: true}));
743 + // Simulated onChange from the child's input event
744 + // bubbles to the parent custom element.
745 + expect(onChangeHandler).toBeCalledTimes(1);
746 + expect(onInputHandler).toBeCalledTimes(1);
747 + expect(onClickHandler).toBeCalledTimes(0);
748 + // Consequently, the native change event is ignored.
749 + input.dispatchEvent(new Event('change', {bubbles: true}));
750 + expect(onChangeHandler).toBeCalledTimes(1);
751 + expect(onInputHandler).toBeCalledTimes(1);
752 + expect(onClickHandler).toBeCalledTimes(0);
753 + input.dispatchEvent(new Event('click', {bubbles: true}));
754 + expect(onChangeHandler).toBeCalledTimes(1);
755 + expect(onInputHandler).toBeCalledTimes(1);
756 + expect(onClickHandler).toBeCalledTimes(1);
757 + });
758 +
759 + it('custom element onChange/onInput/onClick with event target div child', () => {
760 + const container = document.createElement('div');
761 + document.body.appendChild(container);
762 + const onChangeHandler = jest.fn();
763 + const onInputHandler = jest.fn();
764 + const onClickHandler = jest.fn();
765 + ReactDOM.render(
766 + <my-custom-element
767 + onChange={onChangeHandler}
768 + onInput={onInputHandler}
769 + onClick={onClickHandler}>
770 + <div />
771 + </my-custom-element>,
772 + container,
773 + );
774 +
775 + const div = container.querySelector('div');
776 + div.dispatchEvent(new Event('input', {bubbles: true}));
777 + expect(onChangeHandler).toBeCalledTimes(0);
778 + expect(onInputHandler).toBeCalledTimes(1);
779 + expect(onClickHandler).toBeCalledTimes(0);
780 +
781 + div.dispatchEvent(new Event('change', {bubbles: true}));
782 + // React always ignores change event invoked on non-custom and non-input targets.
783 + // So change event emitted on a div does not propagate upwards.
784 + expect(onChangeHandler).toBeCalledTimes(0);
785 + expect(onInputHandler).toBeCalledTimes(1);
786 + expect(onClickHandler).toBeCalledTimes(0);
787 +
788 + div.dispatchEvent(new Event('click', {bubbles: true}));
789 + expect(onChangeHandler).toBeCalledTimes(0);
790 + expect(onInputHandler).toBeCalledTimes(1);
791 + expect(onClickHandler).toBeCalledTimes(1);
792 + });
793 +
794 + it('div onChange/onInput/onClick with event target div child', () => {
795 + const container = document.createElement('div');
796 + document.body.appendChild(container);
797 + const onChangeHandler = jest.fn();
798 + const onInputHandler = jest.fn();
799 + const onClickHandler = jest.fn();
800 + ReactDOM.render(
801 + <div
802 + onChange={onChangeHandler}
803 + onInput={onInputHandler}
804 + onClick={onClickHandler}>
805 + <div />
806 + </div>,
807 + container,
808 + );
809 +
810 + const div = container.querySelector('div > div');
811 + div.dispatchEvent(new Event('input', {bubbles: true}));
812 + expect(onChangeHandler).toBeCalledTimes(0);
813 + expect(onInputHandler).toBeCalledTimes(1);
814 + expect(onClickHandler).toBeCalledTimes(0);
815 +
816 + div.dispatchEvent(new Event('change', {bubbles: true}));
817 + // React always ignores change event invoked on non-custom and non-input targets.
818 + // So change event emitted on a div does not propagate upwards.
819 + expect(onChangeHandler).toBeCalledTimes(0);
820 + expect(onInputHandler).toBeCalledTimes(1);
821 + expect(onClickHandler).toBeCalledTimes(0);
822 +
823 + div.dispatchEvent(new Event('click', {bubbles: true}));
824 + expect(onChangeHandler).toBeCalledTimes(0);
825 + expect(onInputHandler).toBeCalledTimes(1);
826 + expect(onClickHandler).toBeCalledTimes(1);
827 + });
828 +
829 + // @gate enableCustomElementPropertySupport
830 + it('custom element onChange/onInput/onClick with event target custom element child', () => {
831 + const container = document.createElement('div');
832 + document.body.appendChild(container);
833 + const onChangeHandler = jest.fn();
834 + const onInputHandler = jest.fn();
835 + const onClickHandler = jest.fn();
836 + ReactDOM.render(
837 + <my-custom-element
838 + onChange={onChangeHandler}
839 + onInput={onInputHandler}
840 + onClick={onClickHandler}>
841 + <other-custom-element />
842 + </my-custom-element>,
843 + container,
844 + );
845 +
846 + const customChild = container.querySelector('other-custom-element');
847 + customChild.dispatchEvent(new Event('input', {bubbles: true}));
848 + // There is no simulated onChange, only raw onInput is dispatched.
849 + expect(onChangeHandler).toBeCalledTimes(0);
850 + expect(onInputHandler).toBeCalledTimes(1);
851 + expect(onClickHandler).toBeCalledTimes(0);
852 + // The native change event propagates to the parent as onChange.
853 + customChild.dispatchEvent(new Event('change', {bubbles: true}));
854 + expect(onChangeHandler).toBeCalledTimes(1);
855 + expect(onInputHandler).toBeCalledTimes(1);
856 + expect(onClickHandler).toBeCalledTimes(0);
857 + customChild.dispatchEvent(new Event('click', {bubbles: true}));
858 + expect(onChangeHandler).toBeCalledTimes(1);
859 + expect(onInputHandler).toBeCalledTimes(1);
860 + expect(onClickHandler).toBeCalledTimes(1);
861 + });
862 +
863 // @gate enableCustomElementPropertySupport
864 it('custom elements should allow custom events with capture event listeners', () => {
865 const oncustomeventCapture = jest.fn();
packages/react-dom/src/events/plugins/ChangeEventPlugin.js
+11 -1
@@ -23,12 +23,16 @@ import {updateValueIfChanged} from '../../client/inputValueTracking';
23 import {setDefaultValue} from '../../client/ReactDOMInput';
24 import {enqueueStateRestore} from '../ReactDOMControlledComponent';
25
26 -import {disableInputAttributeSyncing} from 'shared/ReactFeatureFlags';
26 +import {
27 + disableInputAttributeSyncing,
28 + enableCustomElementPropertySupport,
29 +} from 'shared/ReactFeatureFlags';
30 import {batchedUpdates} from '../ReactDOMUpdateBatching';
31 import {
32 processDispatchQueue,
33 accumulateTwoPhaseListeners,
34 } from '../DOMPluginEventSystem';
35 +import isCustomComponent from '../../shared/isCustomComponent';
36
37 function registerEvents() {
38 registerTwoPhaseEvent('onChange', [
@@ -292,6 +296,12 @@ function extractEvents(
296 }
297 } else if (shouldUseClickEvent(targetNode)) {
298 getTargetInstFunc = getTargetInstForClickEvent;
299 + } else if (
300 + enableCustomElementPropertySupport &&
301 + targetInst &&
302 + isCustomComponent(targetInst.elementType, targetInst.memoizedProps)
303 + ) {
304 + getTargetInstFunc = getTargetInstForChangeEvent;
305 }
306
307 if (getTargetInstFunc) {