From 4adcee69a04058a4858837752e7e8f011bb652f9 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 26 Feb 2015 01:17:05 -0800 Subject: [PATCH] Add cloneElement Implementation This is a new version of cloneWithProps but this one is moving out of add-ons. Unlike cloneWithProps, this one doesn't have special logic for style, className and children. This one also preserves the original ref. This is critical when upgrading from a mutative pattern where a child might have a ref on it. It also preserves context, which is similar to how context would work when it is parent based. It also ensures that we're compatible with the old mutative pattern which makes updates easier. --- src/browser/ui/React.js | 3 + src/classic/element/ReactElement.js | 54 ++++ src/classic/element/ReactElementValidator.js | 73 +++-- .../__tests__/ReactElementClone-test.js | 268 ++++++++++++++++++ 4 files changed, 371 insertions(+), 27 deletions(-) create mode 100644 src/classic/element/__tests__/ReactElementClone-test.js diff --git a/src/browser/ui/React.js b/src/browser/ui/React.js index 6215fe3434..5232085661 100644 --- a/src/browser/ui/React.js +++ b/src/browser/ui/React.js @@ -39,10 +39,12 @@ ReactDefaultInjection.inject(); var createElement = ReactElement.createElement; var createFactory = ReactElement.createFactory; +var cloneElement = ReactElement.cloneElement; if (__DEV__) { createElement = ReactElementValidator.createElement; createFactory = ReactElementValidator.createFactory; + cloneElement = ReactElementValidator.cloneElement; } var render = ReactPerf.measure('React', 'render', ReactMount.render); @@ -62,6 +64,7 @@ var React = { }, createClass: ReactClass.createClass, createElement: createElement, + cloneElement: cloneElement, createFactory: createFactory, createMixin: function(mixin) { // Currently a noop. Will be used to validate and trace mixins. diff --git a/src/classic/element/ReactElement.js b/src/classic/element/ReactElement.js index c582f95a6a..13313ae418 100644 --- a/src/classic/element/ReactElement.js +++ b/src/classic/element/ReactElement.js @@ -228,6 +228,60 @@ ReactElement.cloneAndReplaceProps = function(oldElement, newProps) { return newElement; }; +ReactElement.cloneElement = function(element, config, children) { + var propName; + + // Original props are copied + var props = assign({}, element.props); + + // Reserved names are extracted + var key = element.key; + var ref = element.ref; + + // Owner will be preserved, unless ref is overridden + var owner = element._owner; + + if (config != null) { + if (config.ref !== undefined) { + // Silently steal the ref from the parent. + ref = config.ref; + owner = ReactCurrentOwner.current; + } + if (config.key !== undefined) { + key = '' + config.key; + } + // Remaining properties override existing props + for (propName in config) { + if (config.hasOwnProperty(propName) && + !RESERVED_PROPS.hasOwnProperty(propName)) { + props[propName] = config[propName]; + } + } + } + + // Children can be more than one argument, and those are transferred onto + // the newly allocated props object. + var childrenLength = arguments.length - 2; + if (childrenLength === 1) { + props.children = children; + } else if (childrenLength > 1) { + var childArray = Array(childrenLength); + for (var i = 0; i < childrenLength; i++) { + childArray[i] = arguments[i + 2]; + } + props.children = childArray; + } + + return new ReactElement( + element.type, + key, + ref, + owner, + element._context, + props + ); +}; + /** * @param {?object} object * @return {boolean} True if `object` is a valid component. diff --git a/src/classic/element/ReactElementValidator.js b/src/classic/element/ReactElementValidator.js index 35cd38d6cf..1f25c23d42 100644 --- a/src/classic/element/ReactElementValidator.js +++ b/src/classic/element/ReactElementValidator.js @@ -336,6 +336,42 @@ function checkAndWarnForMutatedProps(element) { } } +/** + * Given an element, validate that its props follow the propTypes definition, + * provided by the type. + * + * @param {ReactElement} element + */ +function validatePropTypes(element) { + if (element.type == null) { + // This has already warned. Don't throw. + return; + } + // Extract the component class from the element. Converts string types + // to a composite class which may have propTypes. + // TODO: Validating a string's propTypes is not decoupled from the + // rendering target which is problematic. + var componentClass = ReactNativeComponent.getComponentClassForElement( + element + ); + var name = componentClass.displayName || componentClass.name; + if (componentClass.propTypes) { + checkPropTypes( + name, + componentClass.propTypes, + element.props, + ReactPropTypeLocations.prop + ); + } + if (typeof componentClass.getDefaultProps === 'function') { + warning( + componentClass.getDefaultProps.isReactClassApproved, + 'getDefaultProps is only used on classic React.createClass ' + + 'definitions. Use a static property named `defaultProps` instead.' + ); + } +} + var ReactElementValidator = { checkAndWarnForMutatedProps: checkAndWarnForMutatedProps, @@ -362,33 +398,7 @@ var ReactElementValidator = { validateChildKeys(arguments[i], type); } - if (type) { - // Extract the component class from the element. Converts string types - // to a composite class which may have propTypes. - // TODO: Validating a string's propTypes is not decoupled from the - // rendering target which is problematic. - var componentClass = ReactNativeComponent.getComponentClassForElement( - element - ); - var name = componentClass.displayName || componentClass.name; - if (__DEV__) { - if (componentClass.propTypes) { - checkPropTypes( - name, - componentClass.propTypes, - element.props, - ReactPropTypeLocations.prop - ); - } - } - if (typeof componentClass.getDefaultProps === 'function') { - warning( - componentClass.getDefaultProps.isReactClassApproved, - 'getDefaultProps is only used on classic React.createClass ' + - 'definitions. Use a static property named `defaultProps` instead.' - ); - } - } + validatePropTypes(element); return element; }, @@ -428,6 +438,15 @@ var ReactElementValidator = { return validatedFactory; + }, + + cloneElement: function(element, props, children) { + var newElement = ReactElement.cloneElement.apply(this, arguments); + for (var i = 2; i < arguments.length; i++) { + validateChildKeys(arguments[i], newElement.type); + } + validatePropTypes(newElement); + return newElement; } }; diff --git a/src/classic/element/__tests__/ReactElementClone-test.js b/src/classic/element/__tests__/ReactElementClone-test.js new file mode 100644 index 0000000000..66b086c341 --- /dev/null +++ b/src/classic/element/__tests__/ReactElementClone-test.js @@ -0,0 +1,268 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails react-core + */ + +'use strict'; + +require('mock-modules'); + +var mocks = require('mocks'); + +var React; +var ReactTestUtils; + +describe('ReactElementClone', function() { + + beforeEach(function() { + React = require('React'); + ReactTestUtils = require('ReactTestUtils'); + }); + + it('should clone a DOM component with new props', function() { + var Grandparent = React.createClass({ + render: function() { + return } />; + } + }); + var Parent = React.createClass({ + render: function() { + return ( +
+ {React.cloneElement(this.props.child, { className: 'xyz' })} +
+ ); + } + }); + var component = ReactTestUtils.renderIntoDocument(); + expect(component.getDOMNode().childNodes[0].className).toBe('xyz'); + }); + + it('should clone a composite component with new props', function() { + var Child = React.createClass({ + render: function() { + return
; + } + }); + var Grandparent = React.createClass({ + render: function() { + return } />; + } + }); + var Parent = React.createClass({ + render: function() { + return ( +
+ {React.cloneElement(this.props.child, { className: 'xyz' })} +
+ ); + } + }); + var component = ReactTestUtils.renderIntoDocument(); + expect(component.getDOMNode().childNodes[0].className).toBe('xyz'); + }); + + it('should keep the original ref if it is not overridden', function() { + var Grandparent = React.createClass({ + render: function() { + return } />; + } + }); + + var Parent = React.createClass({ + render: function() { + return ( +
+ {React.cloneElement(this.props.child, { className: 'xyz' })} +
+ ); + } + }); + + var component = ReactTestUtils.renderIntoDocument(); + expect(component.refs.yolo.tagName).toBe('DIV'); + }); + + it('should transfer the key property', function() { + var Component = React.createClass({ + render: function() { + return null; + } + }); + var clone = React.cloneElement(, {key: 'xyz'}); + expect(clone.key).toBe('xyz'); + }); + + it('should transfer children', function() { + var Component = React.createClass({ + render: function() { + expect(this.props.children).toBe('xyz'); + return
; + } + }); + + ReactTestUtils.renderIntoDocument( + React.cloneElement(, {children: 'xyz'}) + ); + }); + + it('should shallow clone children', function() { + var Component = React.createClass({ + render: function() { + expect(this.props.children).toBe('xyz'); + return
; + } + }); + + ReactTestUtils.renderIntoDocument( + React.cloneElement(xyz, {}) + ); + }); + + it('should accept children as rest arguments', function() { + var Component = React.createClass({ + render: function() { + return null; + } + }); + + var clone = React.cloneElement( + xyz, + { children: }, +
, + + ); + + expect(clone.props.children).toEqual([ +
, + + ]); + }); + + it('should support keys and refs', function() { + var Parent = React.createClass({ + render: function() { + var clone = + React.cloneElement(this.props.children, {key: 'xyz', ref: 'xyz'}); + expect(clone.key).toBe('xyz'); + expect(clone.ref).toBe('xyz'); + return
{clone}
; + } + }); + + var Grandparent = React.createClass({ + render: function() { + return ; + } + }); + + var component = ReactTestUtils.renderIntoDocument(); + expect(component.refs.parent.refs.xyz.tagName).toBe('SPAN'); + }); + + it('should steal the ref if a new ref is specified', function() { + var Parent = React.createClass({ + render: function() { + var clone = React.cloneElement(this.props.children, {ref: 'xyz'}); + return
{clone}
; + } + }); + + var Grandparent = React.createClass({ + render: function() { + return ; + } + }); + + var component = ReactTestUtils.renderIntoDocument(); + expect(component.refs.child).toBeUndefined(); + expect(component.refs.parent.refs.xyz.tagName).toBe('SPAN'); + }); + + it('should overwrite props', function() { + var Component = React.createClass({ + render: function() { + expect(this.props.myprop).toBe('xyz'); + return
; + } + }); + + ReactTestUtils.renderIntoDocument( + React.cloneElement(, {myprop: 'xyz'}) + ); + }); + + it('warns for keys for arrays of elements in rest args', function() { + spyOn(console, 'warn'); + + React.cloneElement(
, null, [
,
]); + + expect(console.warn.argsForCall.length).toBe(1); + expect(console.warn.argsForCall[0][0]).toContain( + 'Each child in an array or iterator should have a unique "key" prop.' + ); + }); + + it('does not warns for arrays of elements with keys', function() { + spyOn(console, 'warn'); + + React.cloneElement(
, null, [
,
]); + + expect(console.warn.argsForCall.length).toBe(0); + }); + + it('does not warn when the element is directly in rest args', function() { + spyOn(console, 'warn'); + + React.cloneElement(
, null,
,
); + + expect(console.warn.argsForCall.length).toBe(0); + }); + + it('does not warn when the array contains a non-element', function() { + spyOn(console, 'warn'); + + React.cloneElement(
, null, [{}, {}]); + + expect(console.warn.argsForCall.length).toBe(0); + }); + + it('should check declared prop types after clone', function() { + spyOn(console, 'warn'); + var Component = React.createClass({ + propTypes: { + color: React.PropTypes.string.isRequired + }, + render: function() { + return React.createElement('div', null, 'My color is ' + this.color); + } + }); + var Parent = React.createClass({ + render: function() { + return React.cloneElement(this.props.child, {color: 123}); + } + }); + var GrandParent = React.createClass({ + render: function() { + return React.createElement( + Parent, + { child: React.createElement(Component, {color: 'red'}) } + ); + } + }); + ReactTestUtils.renderIntoDocument(React.createElement(GrandParent)); + expect(console.warn.argsForCall.length).toBe(1); + expect(console.warn.calls[0].args[0]).toBe( + 'Warning: Failed propType: ' + + 'Invalid prop `color` of type `number` supplied to `Component`, ' + + 'expected `string`. Check the render method of `Parent`.' + ); + }); + +});