Skip to content

Commit

Permalink
Refactor DOM Bindings Completely Off of DOMProperty Meta Programming (f…
Browse files Browse the repository at this point in the history
…acebook#26546)

There are four places we have special cases based off the DOMProperty
config:

1) DEV-only: ReactDOMUnknownPropertyHook warns for passing booleans to
non-boolean attributes. We just need a simple list of all properties
that are affected by that. We could probably move this in under setProp
instead and have it covered by that list.
2) DEV-only: Hydration. This just needs to read the value from an
attribute and compare it to what we'd expect to see if it was rendered
on the client. This could use some simplification/unification of the
code but I decided to just keep it simple and duplicated since code size
isn't an issue.
3) DOMServerFormatConfig pushAttribute: This just maps the special case
to how to emit it as a HTML attribute.
4) ReactDOMComponent setProp: This just maps the special case to how to
emit it as setAttribute or removeAttribute.

Basically we just have to remember to keep pushAttribute and setProp
aligned. There's only one long switch in prod per environment.

This just turns it all to a giant simple switch statement with string
cases. This is in theory the most optimizable since syntactically all
the information for a hash table is there. However, unfortunately we
know that most VMs don't optimize this very well and instead just turn
them into a bunch of ifs. JSC is best. We can minimize the cost by just
moving common attribute to the beginning of the list.

If we shipped this, maybe VMs will get it together to start optimizing
this case but there's a chicken and egg problem here and the game theory
reality is that we probably don't want to regress. Therefore, I intend
to do a follow up after landing this which reintroduces an object
indirection for simple property aliases. That should be enough to make
the remaining cases palatable. I'll also extract the most common
attributes to the beginning or separate ifs.

Ran attribute-behavior fixture and the table is the same.
  • Loading branch information
sebmarkbage authored and AndyPengc12 committed Apr 15, 2024
1 parent c8ec58d commit 003e913
Show file tree
Hide file tree
Showing 7 changed files with 2,696 additions and 1,099 deletions.
15 changes: 15 additions & 0 deletions packages/react-dom-bindings/src/client/CSSPropertyOperations.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ export function createDangerousStringForStyles(styles) {
* @param {object} styles
*/
export function setValueForStyles(node, styles) {
if (styles != null && typeof styles !== 'object') {
throw new Error(
'The `style` prop expects a mapping from style properties to values, ' +
"not a string. For example, style={{marginRight: spacing + 'em'}} when " +
'using JSX.',
);
}
if (__DEV__) {
if (styles) {
// Freeze the next style object so that we can assume it won't be
// mutated. We have already warned for this in the past.
Object.freeze(styles);
}
}

const style = node.style;
for (const styleName in styles) {
if (!styles.hasOwnProperty(styleName)) {
Expand Down
328 changes: 29 additions & 299 deletions packages/react-dom-bindings/src/client/DOMPropertyOperations.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,181 +7,14 @@
* @flow
*/

import {
BOOLEAN,
OVERLOADED_BOOLEAN,
NUMERIC,
POSITIVE_NUMERIC,
} from '../shared/DOMProperty';

import isAttributeNameSafe from '../shared/isAttributeNameSafe';
import sanitizeURL from '../shared/sanitizeURL';
import {
enableTrustedTypesIntegration,
enableCustomElementPropertySupport,
enableFilterEmptyStringAttributesDOM,
} from 'shared/ReactFeatureFlags';
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
import {getFiberCurrentPropsFromNode} from './ReactDOMComponentTree';

import type {PropertyInfo} from '../shared/DOMProperty';

/**
* Get the value for a property on a node. Only used in DEV for SSR validation.
* The "expected" argument is used as a hint of what the expected value is.
* Some properties have multiple equivalent values.
*/
export function getValueForProperty(
node: Element,
name: string,
expected: mixed,
propertyInfo: PropertyInfo,
): mixed {
if (__DEV__) {
const attributeName = propertyInfo.attributeName;

if (!node.hasAttribute(attributeName)) {
// shouldRemoveAttribute
switch (typeof expected) {
case 'function':
case 'symbol': // eslint-disable-line
return expected;
case 'boolean': {
if (!propertyInfo.acceptsBooleans) {
return expected;
}
}
}
switch (propertyInfo.type) {
case BOOLEAN: {
if (!expected) {
return expected;
}
break;
}
case OVERLOADED_BOOLEAN: {
if (expected === false) {
return expected;
}
break;
}
case NUMERIC: {
if (isNaN(expected)) {
return expected;
}
break;
}
case POSITIVE_NUMERIC: {
if (isNaN(expected) || (expected: any) < 1) {
return expected;
}
break;
}
}
if (enableFilterEmptyStringAttributesDOM) {
if (propertyInfo.removeEmptyString && expected === '') {
if (__DEV__) {
if (name === 'src') {
console.error(
'An empty string ("") was passed to the %s attribute. ' +
'This may cause the browser to download the whole page again over the network. ' +
'To fix this, either do not render the element at all ' +
'or pass null to %s instead of an empty string.',
name,
name,
);
} else {
console.error(
'An empty string ("") was passed to the %s attribute. ' +
'To fix this, either do not render the element at all ' +
'or pass null to %s instead of an empty string.',
name,
name,
);
}
}
return expected;
}
}
return expected === undefined ? undefined : null;
}

// Even if this property uses a namespace we use getAttribute
// because we assume its namespaced name is the same as our config.
// To use getAttributeNS we need the local name which we don't have
// in our config atm.
const value = node.getAttribute(attributeName);

if (expected == null) {
// We had an attribute but shouldn't have had one, so read it
// for the error message.
return value;
}

// shouldRemoveAttribute
switch (typeof expected) {
case 'function':
case 'symbol': // eslint-disable-line
return value;
}
switch (propertyInfo.type) {
case BOOLEAN: {
if (expected) {
// If this was a boolean, it doesn't matter what the value is
// the fact that we have it is the same as the expected.
// As long as it's positive.
return expected;
}
return value;
}
case OVERLOADED_BOOLEAN: {
if (value === '') {
return true;
}
if (expected === false) {
// We had an attribute but shouldn't have had one, so read it
// for the error message.
return value;
}
break;
}
case NUMERIC: {
if (isNaN(expected)) {
// We had an attribute but shouldn't have had one, so read it
// for the error message.
return value;
}
break;
}
case POSITIVE_NUMERIC: {
if (isNaN(expected) || (expected: any) < 1) {
// We had an attribute but shouldn't have had one, so read it
// for the error message.
return value;
}
break;
}
}
if (__DEV__) {
checkAttributeStringCoercion(expected, name);
}
if (propertyInfo.sanitizeURL) {
// We have already verified this above.
// eslint-disable-next-line react-internal/safe-string-coercion
if (value === '' + (sanitizeURL(expected): any)) {
return expected;
}
return value;
}
// We have already verified this above.
// eslint-disable-next-line react-internal/safe-string-coercion
if (value === '' + (expected: any)) {
return expected;
}
return value;
}
}

/**
* Get the value for a attribute on a node. Only used in DEV for SSR validation.
* The third argument is used as a hint of what the expected value is. Some
Expand Down Expand Up @@ -271,138 +104,6 @@ export function getValueForAttributeOnCustomComponent(
}
}

/**
* Sets the value for a property on a node.
*
* @param {DOMElement} node
* @param {string} name
* @param {*} value
*/
export function setValueForProperty(
node: Element,
propertyInfo: PropertyInfo,
value: mixed,
) {
const attributeName = propertyInfo.attributeName;

if (value === null) {
node.removeAttribute(attributeName);
return;
}

// shouldRemoveAttribute
switch (typeof value) {
case 'undefined':
case 'function':
case 'symbol': // eslint-disable-line
node.removeAttribute(attributeName);
return;
case 'boolean': {
if (!propertyInfo.acceptsBooleans) {
node.removeAttribute(attributeName);
return;
}
}
}
if (enableFilterEmptyStringAttributesDOM) {
if (propertyInfo.removeEmptyString && value === '') {
if (__DEV__) {
if (attributeName === 'src') {
console.error(
'An empty string ("") was passed to the %s attribute. ' +
'This may cause the browser to download the whole page again over the network. ' +
'To fix this, either do not render the element at all ' +
'or pass null to %s instead of an empty string.',
attributeName,
attributeName,
);
} else {
console.error(
'An empty string ("") was passed to the %s attribute. ' +
'To fix this, either do not render the element at all ' +
'or pass null to %s instead of an empty string.',
attributeName,
attributeName,
);
}
}
node.removeAttribute(attributeName);
return;
}
}

switch (propertyInfo.type) {
case BOOLEAN:
if (value) {
node.setAttribute(attributeName, '');
} else {
node.removeAttribute(attributeName);
return;
}
break;
case OVERLOADED_BOOLEAN:
if (value === true) {
node.setAttribute(attributeName, '');
} else if (value === false) {
node.removeAttribute(attributeName);
} else {
if (__DEV__) {
checkAttributeStringCoercion(value, attributeName);
}
node.setAttribute(attributeName, (value: any));
}
return;
case NUMERIC:
if (!isNaN(value)) {
if (__DEV__) {
checkAttributeStringCoercion(value, attributeName);
}
node.setAttribute(attributeName, (value: any));
} else {
node.removeAttribute(attributeName);
}
break;
case POSITIVE_NUMERIC:
if (!isNaN(value) && (value: any) >= 1) {
if (__DEV__) {
checkAttributeStringCoercion(value, attributeName);
}
node.setAttribute(attributeName, (value: any));
} else {
node.removeAttribute(attributeName);
}
break;
default: {
if (__DEV__) {
checkAttributeStringCoercion(value, attributeName);
}
let attributeValue;
// `setAttribute` with objects becomes only `[object]` in IE8/9,
// ('' + value) makes it output the correct toString()-value.
if (enableTrustedTypesIntegration) {
if (propertyInfo.sanitizeURL) {
attributeValue = (sanitizeURL(value): any);
} else {
attributeValue = (value: any);
}
} else {
// We have already verified this above.
// eslint-disable-next-line react-internal/safe-string-coercion
attributeValue = '' + (value: any);
if (propertyInfo.sanitizeURL) {
attributeValue = sanitizeURL(attributeValue);
}
}
const attributeNamespace = propertyInfo.attributeNamespace;
if (attributeNamespace) {
node.setAttributeNS(attributeNamespace, attributeName, attributeValue);
} else {
node.setAttribute(attributeName, attributeValue);
}
}
}
}

export function setValueForAttribute(
node: Element,
name: string,
Expand Down Expand Up @@ -439,6 +140,35 @@ export function setValueForAttribute(
}
}

export function setValueForNamespacedAttribute(
node: Element,
namespace: string,
name: string,
value: mixed,
) {
if (value === null) {
node.removeAttribute(name);
return;
}
switch (typeof value) {
case 'undefined':
case 'function':
case 'symbol':
case 'boolean': {
node.removeAttribute(name);
return;
}
}
if (__DEV__) {
checkAttributeStringCoercion(value, name);
}
node.setAttributeNS(
namespace,
name,
enableTrustedTypesIntegration ? (value: any) : '' + (value: any),
);
}

export function setValueForPropertyOnCustomComponent(
node: Element,
name: string,
Expand Down
Loading

0 comments on commit 003e913

Please sign in to comment.