diff --git a/src/core/ReactElementValidator.js b/src/core/ReactElementValidator.js index a231f3c3c8ce1..84f8b547292fe 100644 --- a/src/core/ReactElementValidator.js +++ b/src/core/ReactElementValidator.js @@ -22,6 +22,7 @@ var ReactElement = require('ReactElement'); var ReactPropTypeLocations = require('ReactPropTypeLocations'); var ReactCurrentOwner = require('ReactCurrentOwner'); +var getIteratorFn = require('getIteratorFn'); var monitorCodeUse = require('monitorCodeUse'); /** @@ -68,7 +69,7 @@ function validateExplicitKey(component, parentType) { warnAndMonitorForKeyUse( 'react_key_warning', - 'Each child in an array should have a unique "key" prop.', + 'Each child in an array or iterator should have a unique "key" prop.', component, parentType ); @@ -123,7 +124,9 @@ function warnAndMonitorForKeyUse(warningID, message, component, parentType) { // property, it may be the creator of the child that's responsible for // assigning it a key. var childOwnerName = null; - if (component._owner && component._owner !== ReactCurrentOwner.current) { + if (component && + component._owner && + component._owner !== ReactCurrentOwner.current) { // Name of the component that originally created this child. childOwnerName = component._owner.constructor.displayName; @@ -161,7 +164,6 @@ function monitorUseOfObjectMap() { * @internal * @param {*} component Statically passed child of any type. * @param {*} parentType component's parent's type. - * @return {boolean} */ function validateChildKeys(component, parentType) { if (Array.isArray(component)) { @@ -174,10 +176,24 @@ function validateChildKeys(component, parentType) { } else if (ReactElement.isValidElement(component)) { // This component was passed in a valid location. component._store.validated = true; - } else if (component && typeof component === 'object') { - monitorUseOfObjectMap(); - for (var name in component) { - validatePropertyKey(name, component[name], parentType); + } else if (component) { + var iteratorFn = getIteratorFn(component); + // Entry iterators provide implicit keys. + if (iteratorFn && iteratorFn !== component.entries) { + var iterator = iteratorFn.call(component); + var step; + while (!(step = iterator.next()).done) { + if (ReactElement.isValidElement(step.value)) { + validateExplicitKey(step.value, parentType); + } + } + } else if (typeof component === 'object') { + monitorUseOfObjectMap(); + for (var key in component) { + if (component.hasOwnProperty(key)) { + validatePropertyKey(key, component[key], parentType); + } + } } } } diff --git a/src/core/__tests__/ReactElement-test.js b/src/core/__tests__/ReactElement-test.js index ff9ec13e79742..dd0927342923d 100644 --- a/src/core/__tests__/ReactElement-test.js +++ b/src/core/__tests__/ReactElement-test.js @@ -169,10 +169,101 @@ describe('ReactElement', function() { expect(console.warn.argsForCall.length).toBe(1); expect(console.warn.argsForCall[0][0]).toContain( - 'Each child in an array should have a unique "key" prop' + 'Each child in an array or iterator should have a unique "key" prop.' ); }); + it('warns for keys for iterables of elements in rest args', function() { + spyOn(console, 'warn'); + var Component = React.createFactory(ComponentFactory); + + var iterable = { + '@@iterator': function() { + var i = 0; + return { + next: function() { + var done = ++i > 2; + return { value: done ? undefined : Component(), done: done }; + } + }; + } + }; + + Component(null, iterable); + + 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'); + var Component = React.createFactory(ComponentFactory); + + Component(null, [ Component({key: '#1'}), Component({key: '#2'}) ]); + + expect(console.warn.argsForCall.length).toBe(0); + }); + + it('does not warns for iterable elements with keys', function() { + spyOn(console, 'warn'); + var Component = React.createFactory(ComponentFactory); + + var iterable = { + '@@iterator': function() { + var i = 0; + return { + next: function() { + var done = ++i > 2; + return { + value: done ? undefined : Component({key: '#' + i}), + done: done + }; + } + }; + } + }; + + Component(null, iterable); + + expect(console.warn.argsForCall.length).toBe(0); + }); + + it('warns for numeric keys on objects in rest args', function() { + spyOn(console, 'warn'); + var Component = React.createFactory(ComponentFactory); + + Component(null, { 1: Component(), 2: Component() }); + + expect(console.warn.argsForCall.length).toBe(1); + expect(console.warn.argsForCall[0][0]).toContain( + 'Child objects should have non-numeric keys so ordering is preserved.' + ); + }); + + it('does not warn for numeric keys in entry iterables in rest args', function() { + spyOn(console, 'warn'); + var Component = React.createFactory(ComponentFactory); + + var iterable = { + '@@iterator': function() { + var i = 0; + return { + next: function() { + var done = ++i > 2; + return { value: done ? undefined : [i, Component()], done: done }; + } + }; + } + }; + iterable.entries = iterable['@@iterator']; + + Component(null, iterable); + + expect(console.warn.argsForCall.length).toBe(0); + }); + it('does not warn when the element is directly in rest args', function() { spyOn(console, 'warn'); var Component = React.createFactory(ComponentFactory); diff --git a/src/utils/__tests__/traverseAllChildren-test.js b/src/utils/__tests__/traverseAllChildren-test.js index 8becfc2c87988..625f89f5b81e7 100644 --- a/src/utils/__tests__/traverseAllChildren-test.js +++ b/src/utils/__tests__/traverseAllChildren-test.js @@ -125,7 +125,64 @@ describe('traverseAllChildren', function() { ); }); - // Todo: test that nums/strings are converted to ReactComponents. + it('should traverse children of different kinds', function() { + var div =
; + var span = ; + var a = ; + + var traverseContext = []; + var traverseFn = + jasmine.createSpy().andCallFake(function(context, kid, key, index) { + context.push(true); + }); + + var instance = ( +
+ {div} + {[{span}]} + {{a: a}} + {'string'} + {1234} + {true} + {false} + {null} + {undefined} +
+ ); + + traverseAllChildren(instance.props.children, traverseFn, traverseContext); + + expect(traverseFn.calls.length).toBe(9); + expect(traverseContext.length).toEqual(9); + + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, div, '.$divNode', 0 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, span, '.1:0:$span:$spanNode', 1 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, a, '.2:$a:$aNode', 2 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, 'string', '.3', 3 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, 1234, '.4', 4 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, null, '.5', 5 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, null, '.6', 6 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, null, '.7', 7 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, null, '.8', 8 + ); + }); it('should be called for each child in nested structure', function() { var zero =
; @@ -231,4 +288,107 @@ describe('traverseAllChildren', function() { ); }); + it('should be called for each child in an iterable', function() { + var threeDivIterable = { + '@@iterator': function() { + var i = 0; + return { + next: function() { + if (i++ < 3) { + return { value:
, done: false }; + } else { + return { value: undefined, done: true }; + } + } + }; + } + }; + + var traverseContext = []; + var traverseFn = + jasmine.createSpy().andCallFake(function(context, kid, key, index) { + context.push(kid); + }); + + var instance = ( +
+ {threeDivIterable} +
+ ); + + traverseAllChildren(instance.props.children, traverseFn, traverseContext); + expect(traverseFn.calls.length).toBe(3); + + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, + traverseContext[0], + '.$#1', + 0 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, + traverseContext[1], + '.$#2', + 1 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, + traverseContext[2], + '.$#3', + 2 + ); + }); + + it('should use keys from entry iterables', function() { + var threeDivEntryIterable = { + '@@iterator': function() { + var i = 0; + return { + next: function() { + if (i++ < 3) { + return { value: ['#'+i,
], done: false }; + } else { + return { value: undefined, done: true }; + } + } + }; + } + }; + threeDivEntryIterable.entries = threeDivEntryIterable['@@iterator']; + + var traverseContext = []; + var traverseFn = + jasmine.createSpy().andCallFake(function(context, kid, key, index) { + context.push(kid); + }); + + var instance = ( +
+ {threeDivEntryIterable} +
+ ); + + traverseAllChildren(instance.props.children, traverseFn, traverseContext); + expect(traverseFn.calls.length).toBe(3); + + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, + traverseContext[0], + '.$#1:0', + 0 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, + traverseContext[1], + '.$#2:0', + 1 + ); + expect(traverseFn).toHaveBeenCalledWith( + traverseContext, + traverseContext[2], + '.$#3:0', + 2 + ); + }); + }); diff --git a/src/utils/getIteratorFn.js b/src/utils/getIteratorFn.js new file mode 100644 index 0000000000000..9fa8bbedf8aa4 --- /dev/null +++ b/src/utils/getIteratorFn.js @@ -0,0 +1,43 @@ +/** + * Copyright 2013-2014, 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. + * + * @providesModule getIteratorFn + * @typechecks static-only + */ + +"use strict"; + +/* global Symbol */ +var ITERATOR_SYMBOL = typeof Symbol === 'function' && Symbol.iterator; +var FAUX_ITERATOR_SYMBOL = '@@iterator'; // Before Symbol spec. + +/** + * Returns the iterator method function contained on the iterable object. + * + * Be sure to invoke the function with the iterable as context: + * + * var iteratorFn = getIteratorFn(myIterable); + * if (iteratorFn) { + * var iterator = iteratorFn.call(myIterable); + * ... + * } + * + * @param {?object} maybeIterable + * @return {?function} + */ +function getIteratorFn(maybeIterable) { + var iteratorFn = maybeIterable && ( + (ITERATOR_SYMBOL && maybeIterable[ITERATOR_SYMBOL]) || + maybeIterable[FAUX_ITERATOR_SYMBOL] + ); + if (typeof iteratorFn === 'function') { + return iteratorFn; + } +} + +module.exports = getIteratorFn; diff --git a/src/utils/traverseAllChildren.js b/src/utils/traverseAllChildren.js index 03a6a06b68ad6..9120c6fdd46ab 100644 --- a/src/utils/traverseAllChildren.js +++ b/src/utils/traverseAllChildren.js @@ -14,6 +14,7 @@ var ReactElement = require('ReactElement'); var ReactInstanceHandles = require('ReactInstanceHandles'); +var getIteratorFn = require('getIteratorFn'); var invariant = require('invariant'); var SEPARATOR = ReactInstanceHandles.SEPARATOR; @@ -88,58 +89,91 @@ function wrapUserProvidedKey(key) { * process. * @return {!number} The number of children in this subtree. */ -var traverseAllChildrenImpl = - function(children, nameSoFar, indexSoFar, callback, traverseContext) { - var nextName, nextIndex; - var subtreeCount = 0; // Count of children found in the current subtree. - if (Array.isArray(children)) { - for (var i = 0; i < children.length; i++) { - var child = children[i]; - nextName = ( - nameSoFar + - (nameSoFar ? SUBSEPARATOR : SEPARATOR) + - getComponentKey(child, i) - ); - nextIndex = indexSoFar + subtreeCount; - subtreeCount += traverseAllChildrenImpl( - child, - nextName, - nextIndex, - callback, - traverseContext - ); - } - } else { - var type = typeof children; - var isOnlyChild = nameSoFar === ''; +function traverseAllChildrenImpl( + children, + nameSoFar, + indexSoFar, + callback, + traverseContext +) { + var type = typeof children; + + if (type === 'undefined' || type === 'boolean') { + // All of the above are perceived as null. + children = null; + } + + if (children === null || + type === 'string' || + type === 'number' || + ReactElement.isValidElement(children)) { + callback( + traverseContext, + children, // If it's the only child, treat the name as if it was wrapped in an array - // so that it's consistent if the number of children grows - var storageName = - isOnlyChild ? SEPARATOR + getComponentKey(children, 0) : nameSoFar; - if (children == null || type === 'boolean') { - // All of the above are perceived as null. - callback(traverseContext, null, storageName, indexSoFar); - subtreeCount = 1; - } else if (type === 'string' || type === 'number' || - ReactElement.isValidElement(children)) { - callback(traverseContext, children, storageName, indexSoFar); - subtreeCount = 1; - } else if (type === 'object') { - invariant( - !children || children.nodeType !== 1, - 'traverseAllChildren(...): Encountered an invalid child; DOM ' + - 'elements are not valid children of React components.' - ); - for (var key in children) { - if (children.hasOwnProperty(key)) { + // so that it's consistent if the number of children grows. + nameSoFar === '' ? SEPARATOR + getComponentKey(children, 0) : nameSoFar, + indexSoFar + ); + return 1; + } + + var child, nextName, nextIndex; + var subtreeCount = 0; // Count of children found in the current subtree. + + if (Array.isArray(children)) { + for (var i = 0; i < children.length; i++) { + child = children[i]; + nextName = ( + nameSoFar + + (nameSoFar ? SUBSEPARATOR : SEPARATOR) + + getComponentKey(child, i) + ); + nextIndex = indexSoFar + subtreeCount; + subtreeCount += traverseAllChildrenImpl( + child, + nextName, + nextIndex, + callback, + traverseContext + ); + } + } else { + var iteratorFn = getIteratorFn(children); + if (iteratorFn) { + var iterator = iteratorFn.call(children); + var step; + if (iteratorFn !== children.entries) { + while (!(step = iterator.next()).done) { + child = step.value; + nextName = ( + nameSoFar + + (nameSoFar ? SUBSEPARATOR : SEPARATOR) + + getComponentKey(child, i) + ); + nextIndex = indexSoFar + subtreeCount; + subtreeCount += traverseAllChildrenImpl( + child, + nextName, + nextIndex, + callback, + traverseContext + ); + } + } else { + // Iterator will provide entry [k,v] tuples rather than values. + while (!(step = iterator.next()).done) { + var entry = step.value; + if (entry) { + child = entry[1]; nextName = ( nameSoFar + (nameSoFar ? SUBSEPARATOR : SEPARATOR) + - wrapUserProvidedKey(key) + SUBSEPARATOR + - getComponentKey(children[key], 0) + wrapUserProvidedKey(entry[0]) + SUBSEPARATOR + + getComponentKey(child, 0) ); nextIndex = indexSoFar + subtreeCount; subtreeCount += traverseAllChildrenImpl( - children[key], + child, nextName, nextIndex, callback, @@ -148,9 +182,35 @@ var traverseAllChildrenImpl = } } } + } else if (type === 'object') { + invariant( + children.nodeType !== 1, + 'traverseAllChildren(...): Encountered an invalid child; DOM ' + + 'elements are not valid children of React components.' + ); + for (var key in children) { + if (children.hasOwnProperty(key)) { + child = children[key]; + nextName = ( + nameSoFar + (nameSoFar ? SUBSEPARATOR : SEPARATOR) + + wrapUserProvidedKey(key) + SUBSEPARATOR + + getComponentKey(child, 0) + ); + nextIndex = indexSoFar + subtreeCount; + subtreeCount += traverseAllChildrenImpl( + child, + nextName, + nextIndex, + callback, + traverseContext + ); + } + } } - return subtreeCount; - }; + } + + return subtreeCount; +} /** * Traverses children that are typically specified as `props.children`, but