@samitouri / QOS-React-2 / commits / d1547defe3

Fast JSX: Don't clone props object (#28768)

(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.

Andrew Clark committed Apr 5, 2024 at 13:25 UTC d1547defe34cee6326a61059148afc83228d8ecf
2 files changed +101 -44
packages/react/src/__tests__/ReactJSXRuntime-test.js
+33
@@ -374,4 +374,37 @@ describe('ReactJSXRuntime', () => {
374 }
375 expect(didCall).toBe(false);
376 });
377 +
378 + // @gate enableRefAsProp
379 + // @gate disableStringRefs
380 + it('does not clone props object if key is not spread', async () => {
381 + const config = {
382 + foo: 'foo',
383 + bar: 'bar',
384 + };
385 +
386 + const element = __DEV__
387 + ? JSXDEVRuntime.jsxDEV('div', config)
388 + : JSXRuntime.jsx('div', config);
389 + expect(element.props).toBe(config);
390 +
391 + const configWithKey = {
392 + foo: 'foo',
393 + bar: 'bar',
394 + // This only happens when the key is spread onto the element. A statically
395 + // defined key is passed as a separate argument to the jsx() runtime.
396 + key: 'key',
397 + };
398 +
399 + let elementWithSpreadKey;
400 + expect(() => {
401 + elementWithSpreadKey = __DEV__
402 + ? JSXDEVRuntime.jsxDEV('div', configWithKey)
403 + : JSXRuntime.jsx('div', configWithKey);
404 + }).toErrorDev(
405 + 'A props object containing a "key" prop is being spread into JSX',
406 + {withoutStack: true},
407 + );
408 + expect(elementWithSpreadKey.props).not.toBe(configWithKey);
409 + });
410 });
packages/react/src/jsx/ReactJSXElement.js
+68 -44
@@ -314,11 +314,6 @@ function ReactElement(type, key, _ref, self, source, owner, props) {
314 * @param {string} key
315 */
316 export function jsxProd(type, config, maybeKey) {
317 - let propName;
318 -
319 - // Reserved names are extracted
320 - const props = {};
321 -
317 let key = null;
318 let ref = null;
319
@@ -351,22 +346,39 @@ export function jsxProd(type, config, maybeKey) {
346 }
347 }
348
354 - // Remaining properties are added to a new props object
355 - for (propName in config) {
356 - if (
357 - hasOwnProperty.call(config, propName) &&
358 - // Skip over reserved prop names
359 - propName !== 'key' &&
360 - (enableRefAsProp || propName !== 'ref')
361 - ) {
362 - if (enableRefAsProp && !disableStringRefs && propName === 'ref') {
363 - props.ref = coerceStringRef(
364 - config[propName],
365 - ReactCurrentOwner.current,
366 - type,
367 - );
368 - } else {
369 - props[propName] = config[propName];
349 + let props;
350 + if (enableRefAsProp && disableStringRefs && !('key' in config)) {
351 + // If key was not spread in, we can reuse the original props object. This
352 + // only works for `jsx`, not `createElement`, because `jsx` is a compiler
353 + // target and the compiler always passes a new object. For `createElement`,
354 + // we can't assume a new object is passed every time because it can be
355 + // called manually.
356 + //
357 + // Spreading key is a warning in dev. In a future release, we will not
358 + // remove a spread key from the props object. (But we'll still warn.) We'll
359 + // always pass the object straight through.
360 + props = config;
361 + } else {
362 + // We need to remove reserved props (key, prop, ref). Create a fresh props
363 + // object and copy over all the non-reserved props. We don't use `delete`
364 + // because in V8 it will deopt the object to dictionary mode.
365 + props = {};
366 + for (const propName in config) {
367 + if (
368 + hasOwnProperty.call(config, propName) &&
369 + // Skip over reserved prop names
370 + propName !== 'key' &&
371 + (enableRefAsProp || propName !== 'ref')
372 + ) {
373 + if (enableRefAsProp && !disableStringRefs && propName === 'ref') {
374 + props.ref = coerceStringRef(
375 + config[propName],
376 + ReactCurrentOwner.current,
377 + type,
378 + );
379 + } else {
380 + props[propName] = config[propName];
381 + }
382 }
383 }
384 }
@@ -375,7 +387,7 @@ export function jsxProd(type, config, maybeKey) {
387 // Resolve default props
388 if (type && type.defaultProps) {
389 const defaultProps = type.defaultProps;
378 - for (propName in defaultProps) {
390 + for (const propName in defaultProps) {
391 if (props[propName] === undefined) {
392 props[propName] = defaultProps[propName];
393 }
@@ -538,11 +550,6 @@ export function jsxDEV(type, config, maybeKey, isStaticChildren, source, self) {
550 }
551 }
552
541 - let propName;
542 -
543 - // Reserved names are extracted
544 - const props = {};
545 -
553 let key = null;
554 let ref = null;
555
@@ -578,22 +585,39 @@ export function jsxDEV(type, config, maybeKey, isStaticChildren, source, self) {
585 }
586 }
587
581 - // Remaining properties are added to a new props object
582 - for (propName in config) {
583 - if (
584 - hasOwnProperty.call(config, propName) &&
585 - // Skip over reserved prop names
586 - propName !== 'key' &&
587 - (enableRefAsProp || propName !== 'ref')
588 - ) {
589 - if (enableRefAsProp && !disableStringRefs && propName === 'ref') {
590 - props.ref = coerceStringRef(
591 - config[propName],
592 - ReactCurrentOwner.current,
593 - type,
594 - );
595 - } else {
596 - props[propName] = config[propName];
588 + let props;
589 + if (enableRefAsProp && disableStringRefs && !('key' in config)) {
590 + // If key was not spread in, we can reuse the original props object. This
591 + // only works for `jsx`, not `createElement`, because `jsx` is a compiler
592 + // target and the compiler always passes a new object. For `createElement`,
593 + // we can't assume a new object is passed every time because it can be
594 + // called manually.
595 + //
596 + // Spreading key is a warning in dev. In a future release, we will not
597 + // remove a spread key from the props object. (But we'll still warn.) We'll
598 + // always pass the object straight through.
599 + props = config;
600 + } else {
601 + // We need to remove reserved props (key, prop, ref). Create a fresh props
602 + // object and copy over all the non-reserved props. We don't use `delete`
603 + // because in V8 it will deopt the object to dictionary mode.
604 + props = {};
605 + for (const propName in config) {
606 + if (
607 + hasOwnProperty.call(config, propName) &&
608 + // Skip over reserved prop names
609 + propName !== 'key' &&
610 + (enableRefAsProp || propName !== 'ref')
611 + ) {
612 + if (enableRefAsProp && !disableStringRefs && propName === 'ref') {
613 + props.ref = coerceStringRef(
614 + config[propName],
615 + ReactCurrentOwner.current,
616 + type,
617 + );
618 + } else {
619 + props[propName] = config[propName];
620 + }
621 }
622 }
623 }
@@ -602,7 +626,7 @@ export function jsxDEV(type, config, maybeKey, isStaticChildren, source, self) {
626 // Resolve default props
627 if (type && type.defaultProps) {
628 const defaultProps = type.defaultProps;
605 - for (propName in defaultProps) {
629 + for (const propName in defaultProps) {
630 if (props[propName] === undefined) {
631 props[propName] = defaultProps[propName];
632 }