Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 42 additions & 18 deletions packages/react-dom-bindings/src/client/ReactDOMInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,23 +98,50 @@ 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.
// 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');
}
node.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');

// 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');
}
if (typeChanged) {
node.type = type;
}
} else if (typeChanged) {
node.removeAttribute('type');
}

Expand Down Expand Up @@ -188,17 +215,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');
}
}
Expand Down
102 changes: 102 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMInput-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<input type="text" name="first" value={value} onChange={() => {}} />
<input
type="text"
name="second"
value="unchanged"
onChange={() => {}}
/>
</div>
);
}

await act(() => {
root.render(<App value="initial" />);
});

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(<App value="updated" />);
});

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(
<input type="text" name="foo" value="bar" onChange={() => {}} />,
);
});

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(
<input type="text" name="foo" value="bar" onChange={() => {}} />,
);
});

expect(typeWriteCount).toBe(0);
expect(node.type).toBe('text');
expect(node.name).toBe('foo');
});
});