diff --git a/packages/react/src/__tests__/ReactJSXRuntime-test.js b/packages/react/src/__tests__/ReactJSXRuntime-test.js index d25079258327..0ed8391f3605 100644 --- a/packages/react/src/__tests__/ReactJSXRuntime-test.js +++ b/packages/react/src/__tests__/ReactJSXRuntime-test.js @@ -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); + }); }); diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js index d52355b18468..03e46d6d952b 100644 --- a/packages/react/src/jsx/ReactJSXElement.js +++ b/packages/react/src/jsx/ReactJSXElement.js @@ -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; @@ -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)) { + // 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]; + } } } } @@ -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]; } @@ -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; @@ -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]; + } } } } @@ -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]; }