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`.' + ); + }); + +});