From 217a0661eb01abc0d168f4b4e95bb8e58dffb636 Mon Sep 17 00:00:00 2001 From: aryashashank Date: Thu, 9 Apr 2026 17:13:43 +0530 Subject: [PATCH 1/2] Fix unnecessary DOM mutations in updateInput for unchanged inputs (#36229) --- .../src/client/ReactDOMInput.js | 55 ++++++---- .../src/__tests__/ReactDOMInput-test.js | 102 ++++++++++++++++++ 2 files changed, 138 insertions(+), 19 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMInput.js b/packages/react-dom-bindings/src/client/ReactDOMInput.js index b6e665e12883..1877133bd3b3 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMInput.js +++ b/packages/react-dom-bindings/src/client/ReactDOMInput.js @@ -98,23 +98,43 @@ export function updateInput( ) { const node: HTMLInputElement = (element: any); - // Temporarily disconnect the input from any radio buttons. - // Changing the type or name as the same time as changing the checked value - // needs to be atomically applied. We can only ensure that by disconnecting - // the name while do the mutations and then reapply the name after that's done. - node.name = ''; - - if ( + const isTypeValid = type != null && typeof type !== 'function' && typeof type !== 'symbol' && - typeof type !== 'boolean' - ) { + typeof type !== 'boolean'; + const isNameValid = + name != null && + typeof name !== 'function' && + typeof name !== 'symbol' && + typeof name !== 'boolean'; + + // Determine if type or name is actually changing compared to the DOM. + const typeChanged = isTypeValid + ? // $FlowFixMe[incompatible-type] + node.type !== type + : node.hasAttribute('type'); + const nameStr = isNameValid ? toString(getToStringValue(name)) : null; + const nameChanged = + nameStr !== null ? node.name !== nameStr : node.hasAttribute('name'); + + // Temporarily disconnect the input from any radio buttons. + // Changing the type or name at the same time as changing the checked value + // needs to be atomically applied. We can only ensure that by disconnecting + // the name while doing the mutations and then reapply the name after that's done. + // We only need to do this if type or name is actually changing. + if (typeChanged || nameChanged) { + node.name = ''; + } + + if (isTypeValid) { if (__DEV__) { checkAttributeStringCoercion(type, 'type'); } - node.type = type; - } else { + if (typeChanged) { + node.type = type; + } + } else if (typeChanged) { node.removeAttribute('type'); } @@ -188,17 +208,14 @@ export function updateInput( checked && typeof checked !== 'function' && typeof checked !== 'symbol'; } - if ( - name != null && - typeof name !== 'function' && - typeof name !== 'symbol' && - typeof name !== 'boolean' - ) { + if (isNameValid) { if (__DEV__) { checkAttributeStringCoercion(name, 'name'); } - node.name = toString(getToStringValue(name)); - } else { + if (typeChanged || nameChanged) { + node.name = (nameStr: any); + } + } else if (typeChanged || nameChanged) { node.removeAttribute('name'); } } diff --git a/packages/react-dom/src/__tests__/ReactDOMInput-test.js b/packages/react-dom/src/__tests__/ReactDOMInput-test.js index 04bd96fe2e83..faa7fde2ae86 100644 --- a/packages/react-dom/src/__tests__/ReactDOMInput-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMInput-test.js @@ -3109,4 +3109,106 @@ describe('ReactDOMInput', () => { expect(log).toEqual(['']); expect(node.value).toBe('a'); }); + + it('should not perform unnecessary DOM mutations for unchanged inputs', async () => { + // Regression test for https://github.com/facebook/react/issues/36229 + // When updating a parent that contains many inputs, only the input whose + // props actually changed should get DOM writes. + function App({value}) { + return ( +
+ {}} /> + {}} + /> +
+ ); + } + + await act(() => { + root.render(); + }); + + const firstInput = container.querySelectorAll('input')[0]; + const secondInput = container.querySelectorAll('input')[1]; + + expect(firstInput.value).toBe('initial'); + expect(secondInput.value).toBe('unchanged'); + + // Install setters on the second (unchanged) input that throw if called. + // These properties should not be written to because nothing changed. + const originalNameDescriptor = Object.getOwnPropertyDescriptor( + secondInput, + 'name', + ) || {value: secondInput.name, writable: true, configurable: true}; + + Object.defineProperty(secondInput, 'name', { + get() { + return originalNameDescriptor.value; + }, + set(v) { + if (v !== originalNameDescriptor.value) { + throw new Error( + `name was assigned "${v}", but it should not have been mutated!`, + ); + } + // Allow same-value assignments (shouldn't happen, but don't + // fail the test for a no-op write). + originalNameDescriptor.value = v; + }, + configurable: true, + }); + + // Update only the first input's value + await act(() => { + root.render(); + }); + + expect(firstInput.value).toBe('updated'); + expect(secondInput.value).toBe('unchanged'); + expect(secondInput.name).toBe('second'); + }); + + it('should not write type or name when they have not changed', async () => { + await act(() => { + root.render( + {}} />, + ); + }); + + const node = container.firstChild; + expect(node.type).toBe('text'); + expect(node.name).toBe('foo'); + + // Override type setter to detect writes + let typeWriteCount = 0; + const origType = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'type', + ); + Object.defineProperty(node, 'type', { + get() { + return origType.get.call(node); + }, + set(v) { + typeWriteCount++; + origType.set.call(node, v); + }, + configurable: true, + }); + + // Re-render with the same props (e.g. parent re-rendered) + await act(() => { + root.render( + {}} />, + ); + }); + + expect(typeWriteCount).toBe(0); + expect(node.type).toBe('text'); + expect(node.name).toBe('foo'); + }); }); From 7d06b75e50e86a4af467f2ad3a7c506cebcce12c Mon Sep 17 00:00:00 2001 From: aryashashank Date: Sun, 12 Apr 2026 13:31:29 +0530 Subject: [PATCH 2/2] Convert type to lowercase to avoid false positives --- .../src/client/ReactDOMInput.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMInput.js b/packages/react-dom-bindings/src/client/ReactDOMInput.js index 1877133bd3b3..7bc454a8fb71 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMInput.js +++ b/packages/react-dom-bindings/src/client/ReactDOMInput.js @@ -110,10 +110,17 @@ export function updateInput( typeof name !== 'boolean'; // Determine if type or name is actually changing compared to the DOM. - const typeChanged = isTypeValid - ? // $FlowFixMe[incompatible-type] - node.type !== type - : node.hasAttribute('type'); + // node.type is always lowercased by the browser per the HTML spec, so we + // lowercase the prop value to avoid false positives from casing differences. + let typeChanged; + if (isTypeValid) { + if (__DEV__) { + checkAttributeStringCoercion(type, 'type'); + } + typeChanged = node.type !== ('' + type).toLowerCase(); + } else { + typeChanged = node.hasAttribute('type'); + } const nameStr = isNameValid ? toString(getToStringValue(name)) : null; const nameChanged = nameStr !== null ? node.name !== nameStr : node.hasAttribute('name');