Repository: facebook/react
Pull request: Fix change events for custom elements


## Summary

This patch makes `onChange` handlers listen to `change` events for custom elements. There was some odd behavior with the `onChange` handler and `change` events, but this patch will make it work for custom elements.

This fixes https://github.com/facebook/react/issues/22888 and I manually tested that it fixes Dan's example.

<!--
 Explain the **motivation** for making this change. What existing problem does the pull request solve?
-->

## How did you test this change?

I tested it with the [broken example](https://github.com/facebook/react/issues/11347#issuecomment-988970952) and added tests in `DOMPropertyOperations-test.js`.
<!--
  Demonstrate the code is solid. Example: The exact commands you ran and their output, screenshots / videos if the pull request changes the user interface.
  How exactly did you verify that your PR solves the issue you wanted to solve?
  If you leave this empty, your PR will very likely be closed.
-->


Changed files:
filename: packages/react-dom/src/__tests__/DOMPropertyOperations-test.js
patch: @@ -22,6 +22,17 @@ describe('DOMPropertyOperations', () => {
     ReactDOM = require('react-dom');
   });
 
+  // Sets a value in a way that React doesn't see,
+  // so that a subsequent "change" event will trigger the event handler.
+  const setUntrackedValue = Object.getOwnPropertyDescriptor(
+    HTMLInputElement.prototype,
+    'value',
+  ).set;
+  const setUntrackedChecked = Object.getOwnPropertyDescriptor(
+    HTMLInputElement.prototype,
+    'checked',
+  ).set;
+
   describe('setValueForProperty', () => {
     it('should set values as properties by default', () => {
       const container = document.createElement('div');
@@ -280,6 +291,575 @@ describe('DOMPropertyOperations', () => {
       expect(syntheticClickEvent.nativeEvent).toBe(nativeClickEvent);
     });
 
+    // @gate enableCustomElementPropertySupport
+    it('custom elements should have working onChange event listeners', () => {
+      let reactChangeEvent = null;
+      const eventHandler = jest.fn(event => (reactChangeEvent = event));
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      ReactDOM.render(<my-custom-element onChange={eventHandler} />, container);
+      const customElement = container.querySelector('my-custom-element');
+      let expectedHandlerCallCount = 0;
+
+      const changeEvent = new Event('change', {bubbles: true});
+      customElement.dispatchEvent(changeEvent);
+      expectedHandlerCallCount++;
+      expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
+      expect(reactChangeEvent.nativeEvent).toBe(changeEvent);
+
+      // Also make sure that removing and re-adding the event listener works
+      ReactDOM.render(<my-custom-element />, container);
+      customElement.dispatchEvent(new Event('change', {bubbles: true}));
+      expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
+      ReactDOM.render(<my-custom-element onChange={eventHandler} />, container);
+      customElement.dispatchEvent(new Event('change', {bubbles: true}));
+      expectedHandlerCallCount++;
+      expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
+    });
+
+    it('custom elements should have working onInput event listeners', () => {
+      let reactInputEvent = null;
+      const eventHandler = jest.fn(event => (reactInputEvent = event));
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      ReactDOM.render(<my-custom-element onInput={eventHandler} />, container);
+      const customElement = container.querySelector('my-custom-element');
+      let expectedHandlerCallCount = 0;
+
+      const inputEvent = new Event('input', {bubbles: true});
+      customElement.dispatchEvent(inputEvent);
+      expectedHandlerCallCount++;
+      expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
+      expect(reactInputEvent.nativeEvent).toBe(inputEvent);
+
+      // Also make sure that removing and re-adding the event listener works
+      ReactDOM.render(<my-custom-element />, container);
+      customElement.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
+      ReactDOM.render(<my-custom-element onInput={eventHandler} />, container);
+      customElement.dispatchEvent(new Event('input', {bubbles: true}));
+      expectedHandlerCallCount++;
+      expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount);
+    });
+
+    // @gate enableCustomElementPropertySupport
+    it('custom elements should have separate onInput and onChange handling', () => {
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const inputEventHandler = jest.fn();
+      const changeEventHandler = jest.fn();
+      ReactDOM.render(
+        <my-custom-element
+          onInput={inputEventHandler}
+          onChange={changeEventHandler}
+        />,
+        container,
+      );
+      const customElement = container.querySelector('my-custom-element');
+
+      customElement.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(inputEventHandler).toHaveBeenCalledTimes(1);
+      expect(changeEventHandler).toHaveBeenCalledTimes(0);
+
+      customElement.dispatchEvent(new Event('change', {bubbles: true}));
+      expect(inputEventHandler).toHaveBeenCalledTimes(1);
+      expect(changeEventHandler).toHaveBeenCalledTimes(1);
+    });
+
+    // @gate enableCustomElementPropertySupport
+    it('custom elements should be able to remove and re-add custom event listeners', () => {
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const eventHandler = jest.fn();
+      ReactDOM.render(
+        <my-custom-element oncustomevent={eventHandler} />,
+        container,
+      );
+
+      const customElement = container.querySelector('my-custom-element');
+      customElement.dispatchEvent(new Event('customevent'));
+      expect(eventHandler).toHaveBeenCalledTimes(1);
+
+      ReactDOM.render(<my-custom-element />, container);
+      customElement.dispatchEvent(new Event('customevent'));
+      expect(eventHandler).toHaveBeenCalledTimes(1);
+
+      ReactDOM.render(
+        <my-custom-element oncustomevent={eventHandler} />,
+        container,
+      );
+      customElement.dispatchEvent(new Event('customevent'));
+      expect(eventHandler).toHaveBeenCalledTimes(2);
+    });
+
+    it('<input is=...> should have the same onChange/onInput/onClick behavior as <input>', () => {
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const regularOnInputHandler = jest.fn();
+      const regularOnChangeHandler = jest.fn();
+      const regularOnClickHandler = jest.fn();
+      const customOnInputHandler = jest.fn();
+      const customOnChangeHandler = jest.fn();
+      const customOnClickHandler = jest.fn();
+      function clearMocks() {
+        regularOnInputHandler.mockClear();
+        regularOnChangeHandler.mockClear();
+        regularOnClickHandler.mockClear();
+        customOnInputHandler.mockClear();
+        customOnChangeHandler.mockClear();
+        customOnClickHandler.mockClear();
+      }
+      ReactDOM.render(
+        <div>
+          <input
+            onInput={regularOnInputHandler}
+            onChange={regularOnChangeHandler}
+            onClick={regularOnClickHandler}
+          />
+          <input
+            is="my-custom-element"
+            onInput={customOnInputHandler}
+            onChange={customOnChangeHandler}
+            onClick={customOnClickHandler}
+          />
+        </div>,
+        container,
+      );
+
+      const regularInput = container.querySelector(
+        'input:not([is=my-custom-element])',
+      );
+      const customInput = container.querySelector(
+        'input[is=my-custom-element]',
+      );
+      expect(regularInput).not.toBe(customInput);
+
+      // Typing should trigger onInput and onChange for both kinds of inputs.
+      clearMocks();
+      setUntrackedValue.call(regularInput, 'hello');
+      regularInput.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
+      expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
+      expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
+      setUntrackedValue.call(customInput, 'hello');
+      customInput.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(customOnInputHandler).toHaveBeenCalledTimes(1);
+      expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
+      expect(customOnClickHandler).toHaveBeenCalledTimes(0);
+
+      // The native change event itself does not produce extra React events.
+      clearMocks();
+      regularInput.dispatchEvent(new Event('change', {bubbles: true}));
+      expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
+      expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
+      customInput.dispatchEvent(new Event('change', {bubbles: true}));
+      expect(customOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
+      expect(customOnClickHandler).toHaveBeenCalledTimes(0);
+
+      // The click event is handled by both inputs.
+      clearMocks();
+      regularInput.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
+      expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
+      customInput.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(customOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
+      expect(customOnClickHandler).toHaveBeenCalledTimes(1);
+
+      // Typing again should trigger onInput and onChange for both kinds of inputs.
+      clearMocks();
+      setUntrackedValue.call(regularInput, 'goodbye');
+      regularInput.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
+      expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
+      expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
+      setUntrackedValue.call(customInput, 'goodbye');
+      customInput.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(customOnInputHandler).toHaveBeenCalledTimes(1);
+      expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
+      expect(customOnClickHandler).toHaveBeenCalledTimes(0);
+    });
+
+    it('<input type=radio is=...> should have the same onChange/onInput/onClick behavior as <input type=radio>', () => {
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const regularOnInputHandler = jest.fn();
+      const regularOnChangeHandler = jest.fn();
+      const regularOnClickHandler = jest.fn();
+      const customOnInputHandler = jest.fn();
+      const customOnChangeHandler = jest.fn();
+      const customOnClickHandler = jest.fn();
+      function clearMocks() {
+        regularOnInputHandler.mockClear();
+        regularOnChangeHandler.mockClear();
+        regularOnClickHandler.mockClear();
+        customOnInputHandler.mockClear();
+        customOnChangeHandler.mockClear();
+        customOnClickHandler.mockClear();
+      }
+      ReactDOM.render(
+        <div>
+          <input
+            type="radio"
+            onInput={regularOnInputHandler}
+            onChange={regularOnChangeHandler}
+            onClick={regularOnClickHandler}
+          />
+          <input
+            is="my-custom-element"
+            type="radio"
+            onInput={customOnInputHandler}
+            onChange={customOnChangeHandler}
+            onClick={customOnClickHandler}
+          />
+        </div>,
+        container,
+      );
+
+      const regularInput = container.querySelector(
+        'input:not([is=my-custom-element])',
+      );
+      const customInput = container.querySelector(
+        'input[is=my-custom-element]',
+      );
+      expect(regularInput).not.toBe(customInput);
+
+      // Clicking should trigger onClick and onChange on both inputs.
+      clearMocks();
+      setUntrackedChecked.call(regularInput, true);
+      regularInput.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
+      expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
+      setUntrackedChecked.call(customInput, true);
+      customInput.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(customOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
+      expect(customOnClickHandler).toHaveBeenCalledTimes(1);
+
+      // The native input event only produces a React onInput event.
+      clearMocks();
+      regularInput.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
+      expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
+      expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
+      customInput.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(customOnInputHandler).toHaveBeenCalledTimes(1);
+      expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
+      expect(customOnClickHandler).toHaveBeenCalledTimes(0);
+
+      // Clicking again should trigger onClick and onChange on both inputs.
+      clearMocks();
+      setUntrackedChecked.call(regularInput, false);
+      regularInput.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
+      expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
+      setUntrackedChecked.call(customInput, false);
+      customInput.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(customOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
+      expect(customOnClickHandler).toHaveBeenCalledTimes(1);
+    });
+
+    it('<select is=...> should have the same onChange/onInput/onClick behavior as <select>', () => {
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const regularOnInputHandler = jest.fn();
+      const regularOnChangeHandler = jest.fn();
+      const regularOnClickHandler = jest.fn();
+      const customOnInputHandler = jest.fn();
+      const customOnChangeHandler = jest.fn();
+      const customOnClickHandler = jest.fn();
+      function clearMocks() {
+        regularOnInputHandler.mockClear();
+        regularOnChangeHandler.mockClear();
+        regularOnClickHandler.mockClear();
+        customOnInputHandler.mockClear();
+        customOnChangeHandler.mockClear();
+        customOnClickHandler.mockClear();
+      }
+      ReactDOM.render(
+        <div>
+          <select
+            onInput={regularOnInputHandler}
+            onChange={regularOnChangeHandler}
+            onClick={regularOnClickHandler}
+          />
+          <select
+            is="my-custom-element"
+            onInput={customOnInputHandler}
+            onChange={customOnChangeHandler}
+            onClick={customOnClickHandler}
+          />
+        </div>,
+        container,
+      );
+
+      const regularSelect = container.querySelector(
+        'select:not([is=my-custom-element])',
+      );
+      const customSelect = container.querySelector(
+        'select[is=my-custom-element]',
+      );
+      expect(regularSelect).not.toBe(customSelect);
+
+      // Clicking should only trigger onClick on both inputs.
+      clearMocks();
+      regularSelect.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
+      expect(regularOnClickHandler).toHaveBeenCalledTimes(1);
+      customSelect.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(customOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
+      expect(customOnClickHandler).toHaveBeenCalledTimes(1);
+
+      // Native input event should only trigger onInput on both inputs.
+      clearMocks();
+      regularSelect.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(regularOnInputHandler).toHaveBeenCalledTimes(1);
+      expect(regularOnChangeHandler).toHaveBeenCalledTimes(0);
+      expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
+      customSelect.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(customOnInputHandler).toHaveBeenCalledTimes(1);
+      expect(customOnChangeHandler).toHaveBeenCalledTimes(0);
+      expect(customOnClickHandler).toHaveBeenCalledTimes(0);
+
+      // Native change event should trigger onChange.
+      clearMocks();
+      regularSelect.dispatchEvent(new Event('change', {bubbles: true}));
+      expect(regularOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(regularOnChangeHandler).toHaveBeenCalledTimes(1);
+      expect(regularOnClickHandler).toHaveBeenCalledTimes(0);
+      customSelect.dispatchEvent(new Event('change', {bubbles: true}));
+      expect(customOnInputHandler).toHaveBeenCalledTimes(0);
+      expect(customOnChangeHandler).toHaveBeenCalledTimes(1);
+      expect(customOnClickHandler).toHaveBeenCalledTimes(0);
+    });
+
+    // @gate enableCustomElementPropertySupport
+    it('onChange/onInput/onClick on div with various types of children', () => {
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const onChangeHandler = jest.fn();
+      const onInputHandler = jest.fn();
+      const onClickHandler = jest.fn();
+      function clearMocks() {
+        onChangeHandler.mockClear();
+        onInputHandler.mockClear();
+        onClickHandler.mockClear();
+      }
+      ReactDOM.render(
+        <div
+          onChange={onChangeHandler}
+          onInput={onInputHandler}
+          onClick={onClickHandler}>
+          <my-custom-element />
+          <input />
+          <input is="my-custom-element" />
+        </div>,
+        container,
+      );
+      const customElement = container.querySelector('my-custom-element');
+      const regularInput = container.querySelector(
+        'input:not([is="my-custom-element"])',
+      );
+      const customInput = container.querySelector(
+        'input[is="my-custom-element"]',
+      );
+      expect(regularInput).not.toBe(customInput);
+
+      // Custom element has no special logic for input/change.
+      clearMocks();
+      customElement.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(0);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+      customElement.dispatchEvent(new Event('change', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+      customElement.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(1);
+
+      // Regular input treats browser input as onChange.
+      clearMocks();
+      setUntrackedValue.call(regularInput, 'hello');
+      regularInput.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+      regularInput.dispatchEvent(new Event('change', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+      regularInput.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(1);
+
+      // Custom input treats browser input as onChange.
+      clearMocks();
+      setUntrackedValue.call(customInput, 'hello');
+      customInput.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+      customInput.dispatchEvent(new Event('change', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+      customInput.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(1);
+    });
+
+    it('custom element onChange/onInput/onClick with event target input child', () => {
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const onChangeHandler = jest.fn();
+      const onInputHandler = jest.fn();
+      const onClickHandler = jest.fn();
+      ReactDOM.render(
+        <my-custom-element
+          onChange={onChangeHandler}
+          onInput={onInputHandler}
+          onClick={onClickHandler}>
+          <input />
+        </my-custom-element>,
+        container,
+      );
+
+      const input = container.querySelector('input');
+      setUntrackedValue.call(input, 'hello');
+      input.dispatchEvent(new Event('input', {bubbles: true}));
+      // Simulated onChange from the child's input event
+      // bubbles to the parent custom element.
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+      // Consequently, the native change event is ignored.
+      input.dispatchEvent(new Event('change', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+      input.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(1);
+    });
+
+    it('custom element onChange/onInput/onClick with event target div child', () => {
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const onChangeHandler = jest.fn();
+      const onInputHandler = jest.fn();
+      const onClickHandler = jest.fn();
+      ReactDOM.render(
+        <my-custom-element
+          onChange={onChangeHandler}
+          onInput={onInputHandler}
+          onClick={onClickHandler}>
+          <div />
+        </my-custom-element>,
+        container,
+      );
+
+      const div = container.querySelector('div');
+      div.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(0);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+
+      div.dispatchEvent(new Event('change', {bubbles: true}));
+      // React always ignores change event invoked on non-custom and non-input targets.
+      // So change event emitted on a div does not propagate upwards.
+      expect(onChangeHandler).toBeCalledTimes(0);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+
+      div.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(0);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(1);
+    });
+
+    it('div onChange/onInput/onClick with event target div child', () => {
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const onChangeHandler = jest.fn();
+      const onInputHandler = jest.fn();
+      const onClickHandler = jest.fn();
+      ReactDOM.render(
+        <div
+          onChange={onChangeHandler}
+          onInput={onInputHandler}
+          onClick={onClickHandler}>
+          <div />
+        </div>,
+        container,
+      );
+
+      const div = container.querySelector('div > div');
+      div.dispatchEvent(new Event('input', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(0);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+
+      div.dispatchEvent(new Event('change', {bubbles: true}));
+      // React always ignores change event invoked on non-custom and non-input targets.
+      // So change event emitted on a div does not propagate upwards.
+      expect(onChangeHandler).toBeCalledTimes(0);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+
+      div.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(0);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(1);
+    });
+
+    // @gate enableCustomElementPropertySupport
+    it('custom element onChange/onInput/onClick with event target custom element child', () => {
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const onChangeHandler = jest.fn();
+      const onInputHandler = jest.fn();
+      const onClickHandler = jest.fn();
+      ReactDOM.render(
+        <my-custom-element
+          onChange={onChangeHandler}
+          onInput={onInputHandler}
+          onClick={onClickHandler}>
+          <other-custom-element />
+        </my-custom-element>,
+        container,
+      );
+
+      const customChild = container.querySelector('other-custom-element');
+      customChild.dispatchEvent(new Event('input', {bubbles: true}));
+      // There is no simulated onChange, only raw onInput is dispatched.
+      expect(onChangeHandler).toBeCalledTimes(0);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+      // The native change event propagates to the parent as onChange.
+      customChild.dispatchEvent(new Event('change', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(0);
+      customChild.dispatchEvent(new Event('click', {bubbles: true}));
+      expect(onChangeHandler).toBeCalledTimes(1);
+      expect(onInputHandler).toBeCalledTimes(1);
+      expect(onClickHandler).toBeCalledTimes(1);
+    });
+
     // @gate enableCustomElementPropertySupport
     it('custom elements should allow custom events with capture event listeners', () => {
       const oncustomeventCapture = jest.fn();

filename: packages/react-dom/src/events/plugins/ChangeEventPlugin.js
patch: @@ -23,12 +23,16 @@ import {updateValueIfChanged} from '../../client/inputValueTracking';
 import {setDefaultValue} from '../../client/ReactDOMInput';
 import {enqueueStateRestore} from '../ReactDOMControlledComponent';
 
-import {disableInputAttributeSyncing} from 'shared/ReactFeatureFlags';
+import {
+  disableInputAttributeSyncing,
+  enableCustomElementPropertySupport,
+} from 'shared/ReactFeatureFlags';
 import {batchedUpdates} from '../ReactDOMUpdateBatching';
 import {
   processDispatchQueue,
   accumulateTwoPhaseListeners,
 } from '../DOMPluginEventSystem';
+import isCustomComponent from '../../shared/isCustomComponent';
 
 function registerEvents() {
   registerTwoPhaseEvent('onChange', [
@@ -292,6 +296,12 @@ function extractEvents(
     }
   } else if (shouldUseClickEvent(targetNode)) {
     getTargetInstFunc = getTargetInstForClickEvent;
+  } else if (
+    enableCustomElementPropertySupport &&
+    targetInst &&
+    isCustomComponent(targetInst.elementType, targetInst.memoizedProps)
+  ) {
+    getTargetInstFunc = getTargetInstForChangeEvent;
   }
 
   if (getTargetInstFunc) {