Skip to content

Commit

Permalink
Fast JSX: Don't clone props object (#28768)
Browse files Browse the repository at this point in the history
(Unless "key" is spread onto the element.)

Historically, the JSX runtime clones the props object that is passed in.
We've done this for two reasons.

One reason is that there are certain prop names that are reserved by
React, like `key` and (before React 19) `ref`. These are not actual
props and are not observable by the target component; React uses them
internally but removes them from the props object before passing them to
userspace.

The second reason is that the classic JSX runtime, `createElement`, is
both a compiler target _and_ a public API that can be called manually.
Therefore, we can't assume that the props object that is passed into
`createElement` won't be mutated by userspace code after it is passed
in.

However, the new JSX runtime, `jsx`, is not a public API — it's solely a
compiler target, and the compiler _will_ always pass a fresh, inline
object. So the only reason to clone the props is if a reserved prop name
is used.

In React 19, `ref` is no longer a reserved prop name, and `key` will
only appear in the props object if it is spread onto the element.
(Because if `key` is statically defined, the compiler will pass it as a
separate argument to the `jsx` function.) So the only remaining reason
to clone the props object is if `key` is spread onto the element, which
is a rare case, and also triggers a warning in development.

In a future release, we will not remove a spread key from the props
object. (But we'll still warn.) We'll always pass the object straight
through.

The expected impact is much faster JSX element creation, which in many
apps is a significant slice of the overall runtime cost of rendering.
  • Loading branch information
acdlite committed Apr 5, 2024
1 parent bfd8da8 commit d1547de
Show file tree
Hide file tree
Showing 2 changed files with 101 additions and 44 deletions.
33 changes: 33 additions & 0 deletions packages/react/src/__tests__/ReactJSXRuntime-test.js
Expand Up @@ -374,4 +374,37 @@ describe('ReactJSXRuntime', () => {
}
expect(didCall).toBe(false);
});

// @gate enableRefAsProp
// @gate disableStringRefs
it('does not clone props object if key is not spread', async () => {
const config = {
foo: 'foo',
bar: 'bar',
};

const element = __DEV__
? JSXDEVRuntime.jsxDEV('div', config)
: JSXRuntime.jsx('div', config);
expect(element.props).toBe(config);

const configWithKey = {
foo: 'foo',
bar: 'bar',
// This only happens when the key is spread onto the element. A statically
// defined key is passed as a separate argument to the jsx() runtime.
key: 'key',
};

let elementWithSpreadKey;
expect(() => {
elementWithSpreadKey = __DEV__
? JSXDEVRuntime.jsxDEV('div', configWithKey)
: JSXRuntime.jsx('div', configWithKey);
}).toErrorDev(
'A props object containing a "key" prop is being spread into JSX',
{withoutStack: true},
);
expect(elementWithSpreadKey.props).not.toBe(configWithKey);
});
});
112 changes: 68 additions & 44 deletions packages/react/src/jsx/ReactJSXElement.js
Expand Up @@ -314,11 +314,6 @@ function ReactElement(type, key, _ref, self, source, owner, props) {
* @param {string} key
*/
export function jsxProd(type, config, maybeKey) {
let propName;

// Reserved names are extracted
const props = {};

let key = null;
let ref = null;

Expand Down Expand Up @@ -351,22 +346,39 @@ export function jsxProd(type, config, maybeKey) {
}
}

// Remaining properties are added to a new props object
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
// Skip over reserved prop names
propName !== 'key' &&
(enableRefAsProp || propName !== 'ref')
) {
if (enableRefAsProp && !disableStringRefs && propName === 'ref') {
props.ref = coerceStringRef(
config[propName],
ReactCurrentOwner.current,
type,
);
} else {
props[propName] = config[propName];
let props;
if (enableRefAsProp && disableStringRefs && !('key' in config)) {

This comment has been minimized.

Copy link
@yungsters

yungsters Apr 22, 2024

Contributor

@acdlite – Question about the enableRefAsProp and disableStringRefs checks here. If ref isn't present, could we not also allow "good call sites" to benefit from the fast path?

For example, something like:

const hasLegacyRef = !(enableRefAsProp && disableStringRefs) && 'ref' in config;
if (!hasLegacyRef && !('key' in config)) {

This comment has been minimized.

Copy link
@yungsters

yungsters Apr 22, 2024

Contributor

Oh, sorry. I just saw #28816 which you've already engaged on.

// If key was not spread in, we can reuse the original props object. This
// only works for `jsx`, not `createElement`, because `jsx` is a compiler
// target and the compiler always passes a new object. For `createElement`,
// we can't assume a new object is passed every time because it can be
// called manually.
//
// Spreading key is a warning in dev. In a future release, we will not
// remove a spread key from the props object. (But we'll still warn.) We'll
// always pass the object straight through.
props = config;

This comment has been minimized.

Copy link
@yungsters

yungsters Apr 6, 2024

Contributor

❤️

} else {
// We need to remove reserved props (key, prop, ref). Create a fresh props
// object and copy over all the non-reserved props. We don't use `delete`
// because in V8 it will deopt the object to dictionary mode.
props = {};
for (const propName in config) {
if (
hasOwnProperty.call(config, propName) &&
// Skip over reserved prop names
propName !== 'key' &&
(enableRefAsProp || propName !== 'ref')
) {
if (enableRefAsProp && !disableStringRefs && propName === 'ref') {
props.ref = coerceStringRef(
config[propName],
ReactCurrentOwner.current,
type,
);
} else {
props[propName] = config[propName];
}
}
}
}
Expand All @@ -375,7 +387,7 @@ export function jsxProd(type, config, maybeKey) {
// Resolve default props
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
for (const propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];

This comment has been minimized.

Copy link
@yungsters

yungsters May 2, 2024

Contributor

Is it a problem that — in the Fast JSX code path — this line can mutate the config object that was supplied to createElement? (It would seem that if defaultProps is supported for this element and we used the Fast JSX code path, we need to clone config before we potentially clobber values onto it.)

}
Expand Down Expand Up @@ -538,11 +550,6 @@ export function jsxDEV(type, config, maybeKey, isStaticChildren, source, self) {
}
}

let propName;

// Reserved names are extracted
const props = {};

let key = null;
let ref = null;

Expand Down Expand Up @@ -578,22 +585,39 @@ export function jsxDEV(type, config, maybeKey, isStaticChildren, source, self) {
}
}

// Remaining properties are added to a new props object
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
// Skip over reserved prop names
propName !== 'key' &&
(enableRefAsProp || propName !== 'ref')
) {
if (enableRefAsProp && !disableStringRefs && propName === 'ref') {
props.ref = coerceStringRef(
config[propName],
ReactCurrentOwner.current,
type,
);
} else {
props[propName] = config[propName];
let props;
if (enableRefAsProp && disableStringRefs && !('key' in config)) {
// If key was not spread in, we can reuse the original props object. This
// only works for `jsx`, not `createElement`, because `jsx` is a compiler
// target and the compiler always passes a new object. For `createElement`,
// we can't assume a new object is passed every time because it can be
// called manually.
//
// Spreading key is a warning in dev. In a future release, we will not
// remove a spread key from the props object. (But we'll still warn.) We'll
// always pass the object straight through.
props = config;
} else {
// We need to remove reserved props (key, prop, ref). Create a fresh props
// object and copy over all the non-reserved props. We don't use `delete`
// because in V8 it will deopt the object to dictionary mode.
props = {};
for (const propName in config) {
if (
hasOwnProperty.call(config, propName) &&
// Skip over reserved prop names
propName !== 'key' &&
(enableRefAsProp || propName !== 'ref')
) {
if (enableRefAsProp && !disableStringRefs && propName === 'ref') {
props.ref = coerceStringRef(
config[propName],
ReactCurrentOwner.current,
type,
);
} else {
props[propName] = config[propName];
}
}
}
}
Expand All @@ -602,7 +626,7 @@ export function jsxDEV(type, config, maybeKey, isStaticChildren, source, self) {
// Resolve default props
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
for (const propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
Expand Down

0 comments on commit d1547de

Please sign in to comment.