diff --git a/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js b/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js index 99f92cba9b3..ab5ca9a5495 100644 --- a/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js +++ b/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js @@ -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(, 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(, container); + customElement.dispatchEvent(new Event('change', {bubbles: true})); + expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount); + ReactDOM.render(, 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(, 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(, container); + customElement.dispatchEvent(new Event('input', {bubbles: true})); + expect(eventHandler).toHaveBeenCalledTimes(expectedHandlerCallCount); + ReactDOM.render(, 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( + , + 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( + , + container, + ); + + const customElement = container.querySelector('my-custom-element'); + customElement.dispatchEvent(new Event('customevent')); + expect(eventHandler).toHaveBeenCalledTimes(1); + + ReactDOM.render(, container); + customElement.dispatchEvent(new Event('customevent')); + expect(eventHandler).toHaveBeenCalledTimes(1); + + ReactDOM.render( + , + container, + ); + customElement.dispatchEvent(new Event('customevent')); + expect(eventHandler).toHaveBeenCalledTimes(2); + }); + + it(' should have the same onChange/onInput/onClick behavior as ', () => { + 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( +
+ + +
, + 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(' should have the same onChange/onInput/onClick behavior as ', () => { + 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( +
+ + +
, + 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('', () => { + 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( +
+ +
, + 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( +
+ + + +
, + 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( + + + , + 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( + +
+ , + 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( +
+
+
, + 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( + + + , + 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(); diff --git a/packages/react-dom/src/events/plugins/ChangeEventPlugin.js b/packages/react-dom/src/events/plugins/ChangeEventPlugin.js index 43d338a0cc5..8985c50c71f 100644 --- a/packages/react-dom/src/events/plugins/ChangeEventPlugin.js +++ b/packages/react-dom/src/events/plugins/ChangeEventPlugin.js @@ -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) {