/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @emails react-core */ 'use strict'; let React; let ReactDOM; let ReactDOMClient; let Scheduler; let act; let waitForAll; let waitForDiscrete; let assertLog; const setUntrackedChecked = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, 'checked', ).set; const setUntrackedValue = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, 'value', ).set; const setUntrackedTextareaValue = Object.getOwnPropertyDescriptor( HTMLTextAreaElement.prototype, 'value', ).set; describe('ChangeEventPlugin', () => { let container; beforeEach(() => { jest.resetModules(); // TODO pull this into helper method, reduce repetition. // mock the browser APIs which are used in schedule: // - calling 'window.postMessage' should actually fire postmessage handlers const originalAddEventListener = global.addEventListener; let postMessageCallback; global.addEventListener = function (eventName, callback, useCapture) { if (eventName === 'message') { postMessageCallback = callback; } else { originalAddEventListener(eventName, callback, useCapture); } }; global.postMessage = function (messageKey, targetOrigin) { const postMessageEvent = {source: window, data: messageKey}; if (postMessageCallback) { postMessageCallback(postMessageEvent); } }; React = require('react'); ReactDOM = require('react-dom'); ReactDOMClient = require('react-dom/client'); act = require('internal-test-utils').act; Scheduler = require('scheduler'); const InternalTestUtils = require('internal-test-utils'); waitForAll = InternalTestUtils.waitForAll; waitForDiscrete = InternalTestUtils.waitForDiscrete; assertLog = InternalTestUtils.assertLog; container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { document.body.removeChild(container); container = null; }); // We try to avoid firing "duplicate" React change events. // However, to tell which events are "duplicates" and should be ignored, // we are tracking the "current" input value, and only respect events // that occur after it changes. In most of these tests, we verify that we // keep track of the "current" value and only fire events when it changes. // See https://github.com/facebook/react/pull/5746. it('should consider initial text value to be current', async () => { let called = 0; function cb(e) { called++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); const node = container.firstChild; node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); // There should be no React change events because the value stayed the same. expect(called).toBe(0); }); it('should consider initial text value to be current (capture)', async () => { let called = 0; function cb(e) { called++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render( , ); }); const node = container.firstChild; node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); // There should be no React change events because the value stayed the same. expect(called).toBe(0); }); it('should not invoke a change event for textarea same value', async () => { let called = 0; function cb(e) { called++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); const node = container.firstChild; node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); // There should be no React change events because the value stayed the same. expect(called).toBe(0); }); it('should not invoke a change event for textarea same value (capture)', async () => { let called = 0; function cb(e) { called++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); const node = container.firstChild; node.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); node.dispatchEvent(new Event('change', {bubbles: true, cancelable: true})); // There should be no React change events because the value stayed the same. expect(called).toBe(0); }); it('should consider initial checkbox checked=true to be current', async () => { let called = 0; function cb(e) { called++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render( , ); }); const node = container.firstChild; // Secretly, set `checked` to false, so that dispatching the `click` will // make it `true` again. Thus, at the time of the event, React should not // consider it a change from the initial `true` value. setUntrackedChecked.call(node, false); node.dispatchEvent( new MouseEvent('click', {bubbles: true, cancelable: true}), ); // There should be no React change events because the value stayed the same. expect(called).toBe(0); }); it('should consider initial checkbox checked=false to be current', async () => { let called = 0; function cb(e) { called++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render( , ); }); const node = container.firstChild; // Secretly, set `checked` to true, so that dispatching the `click` will // make it `false` again. Thus, at the time of the event, React should not // consider it a change from the initial `false` value. setUntrackedChecked.call(node, true); node.dispatchEvent( new MouseEvent('click', {bubbles: true, cancelable: true}), ); // There should be no React change events because the value stayed the same. expect(called).toBe(0); }); it('should fire change for checkbox input', async () => { let called = 0; function cb(e) { called++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); const node = container.firstChild; expect(node.checked).toBe(false); node.dispatchEvent( new MouseEvent('click', {bubbles: true, cancelable: true}), ); // Note: unlike with text input events, dispatching `click` actually // toggles the checkbox and updates its `checked` value. expect(node.checked).toBe(true); expect(called).toBe(1); expect(node.checked).toBe(true); node.dispatchEvent( new MouseEvent('click', {bubbles: true, cancelable: true}), ); expect(node.checked).toBe(false); expect(called).toBe(2); }); it('should not fire change setting the value programmatically', async () => { let called = 0; function cb(e) { called++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); const input = container.firstChild; // Set it programmatically. input.value = 'bar'; // Even if a DOM input event fires, React sees that the real input value now // ('bar') is the same as the "current" one we already recorded. input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); expect(input.value).toBe('bar'); // In this case we don't expect to get a React event. expect(called).toBe(0); // However, we can simulate user typing by calling the underlying setter. setUntrackedValue.call(input, 'foo'); // Now, when the event fires, the real input value ('foo') differs from the // "current" one we previously recorded ('bar'). input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); expect(input.value).toBe('foo'); // In this case React should fire an event for it. expect(called).toBe(1); // Verify again that extra events without real changes are ignored. input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); expect(called).toBe(1); }); it('should not distinguish equal string and number values', async () => { let called = 0; function cb(e) { called++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); const input = container.firstChild; // When we set `value` as a property, React updates the "current" value // that it tracks internally. The "current" value is later used to determine // whether a change event is a duplicate or not. // Even though we set value to a number, we still shouldn't get a change // event because as a string, it's equal to the initial value ('42'). input.value = 42; input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true})); expect(input.value).toBe('42'); expect(called).toBe(0); }); // See a similar input test above for a detailed description of why. it('should not fire change when setting checked programmatically', async () => { let called = 0; function cb(e) { called++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render( , ); }); const input = container.firstChild; // Set the value, updating the "current" value that React tracks to true. input.checked = true; // Under the hood, uncheck the box so that the click will "check" it again. setUntrackedChecked.call(input, false); input.click(); expect(input.checked).toBe(true); // We don't expect a React event because at the time of the click, the real // checked value (true) was the same as the last recorded "current" value // (also true). expect(called).toBe(0); // However, simulating a normal click should fire a React event because the // real value (false) would have changed from the last tracked value (true). input.click(); expect(called).toBe(1); }); it('should unmount', async () => { const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); const input = container.firstChild; await act(() => { root.unmount(); }); }); it('should only fire change for checked radio button once', async () => { let called = 0; function cb(e) { called++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(); }); const input = container.firstChild; setUntrackedChecked.call(input, true); input.dispatchEvent(new Event('click', {bubbles: true, cancelable: true})); input.dispatchEvent(new Event('click', {bubbles: true, cancelable: true})); expect(called).toBe(1); }); it('should track radio button cousins in a group', async () => { let called1 = 0; let called2 = 0; function cb1(e) { called1++; expect(e.type).toBe('change'); } function cb2(e) { called2++; expect(e.type).toBe('change'); } const root = ReactDOMClient.createRoot(container); await act(() => { root.render(