diff --git a/vendor/fbtransform/transforms/__tests__/react-test.js b/vendor/fbtransform/transforms/__tests__/react-test.js index 546db630d7..574643bcd4 100644 --- a/vendor/fbtransform/transforms/__tests__/react-test.js +++ b/vendor/fbtransform/transforms/__tests__/react-test.js @@ -30,6 +30,26 @@ describe('react jsx', function() { ); }; + // These are placeholder variables in scope that we can use to assert that a + // specific variable reference was passed, rather than an object clone of it. + var x = 123456; + var y = 789012; + var z = 345678; + + var HEADER = + '/**\n' + + ' * @jsx React.DOM\n' + + ' */\n'; + + var expectObjectAssign = function(code) { + var Component = jest.genMockFunction(); + var Child = jest.genMockFunction(); + var objectAssignMock = jest.genMockFunction(); + Object.assign = objectAssignMock; + eval(transform(HEADER + code).code); + return expect(objectAssignMock); + } + it('should convert simple tags', function() { var code = [ '/**@jsx React.DOM*/', @@ -357,4 +377,54 @@ describe('react jsx', function() { expect(() => transform(code)).toThrow(); }); + it('wraps props in Object.assign for spread attributes', function() { + var code = HEADER + + ''; + var result = HEADER + + 'Component(Object.assign({}, x , {y: \n2, z: true}))'; + expect(transform(code).code).toBe(result); + }); + + it('does not call Object.assign when there are no spreads', function() { + expectObjectAssign( + '' + ).not.toBeCalled(); + }); + + it('calls assign with a new target object for spreads', function() { + expectObjectAssign( + '' + ).toBeCalledWith({}, x); + }); + + it('calls assign with an empty object when the spread is first', function() { + expectObjectAssign( + '' + ).toBeCalledWith({}, x, { y: 2 }); + }); + + it('coalesces consecutive properties into a single object', function() { + expectObjectAssign( + '' + ).toBeCalledWith({}, x, { y: 2, z: true }); + }); + + it('avoids an unnecessary empty object when spread is not first', function() { + expectObjectAssign( + '' + ).toBeCalledWith({x: 1}, y); + }); + + it('passes the same value multiple times to Object.assign', function() { + expectObjectAssign( + '' + ).toBeCalledWith({x: 1, y: "2"}, z, z); + }); + + it('evaluates sequences before passing them to Object.assign', function() { + expectObjectAssign( + 'Text' + ).toBeCalledWith({x: "1"}, { y: 2 }, {z: 3}); + }); + }); diff --git a/vendor/fbtransform/transforms/react.js b/vendor/fbtransform/transforms/react.js index c79eaa85f5..be9395653b 100644 --- a/vendor/fbtransform/transforms/react.js +++ b/vendor/fbtransform/transforms/react.js @@ -49,6 +49,14 @@ var JSX_ATTRIBUTE_TRANSFORMS = { } }; +/** + * Removes all non-whitespace/parenthesis characters + */ +var reNonWhiteParen = /([^\s\(\)])/g; +function stripNonWhiteParen(value) { + return value.replace(reNonWhiteParen, ''); +} + function visitReactTag(traverse, object, path, state) { var jsxObjIdent = utils.getDocblock(state).jsx; var openingElement = object.openingElement; @@ -57,6 +65,7 @@ function visitReactTag(traverse, object, path, state) { utils.catchup(openingElement.range[0], state, trimLeft); + if (nameObject.type === Syntax.XJSNamespacedName && nameObject.namespace) { throw new Error('Namespace tags are not supported. ReactJSX is not XML.'); } @@ -75,23 +84,74 @@ function visitReactTag(traverse, object, path, state) { var hasAttributes = attributesObject.length; + var hasAtLeastOneSpreadProperty = attributesObject.some(function(attr) { + return attr.type === Syntax.XJSSpreadAttribute; + }); + // if we don't have any attributes, pass in null - if (hasAttributes) { + if (hasAtLeastOneSpreadProperty) { + utils.append('Object.assign({', state); + } else if (hasAttributes) { utils.append('{', state); } else { utils.append('null', state); } + // keep track of if the previous attribute was a spread attribute + var previousWasSpread = false; + // write attributes attributesObject.forEach(function(attr, index) { + var isLast = index === attributesObject.length - 1; + + if (attr.type === Syntax.XJSSpreadAttribute) { + // Plus 1 to skip `{`. + utils.move(attr.range[0] + 1, state); + + // Close the previous object or initial object + if (!previousWasSpread) { + utils.append('}, ', state); + } + + // Move to the expression start, ignoring everything except parenthesis + // and whitespace. + utils.catchup(attr.argument.range[0], state, stripNonWhiteParen); + + traverse(attr.argument, path, state); + + utils.catchup(attr.argument.range[1], state); + + // Move to the end, ignoring parenthesis and the closing `}` + utils.catchup(attr.range[1] - 1, state, stripNonWhiteParen); + + if (!isLast) { + utils.append(', ', state); + } + + utils.move(attr.range[1], state); + + previousWasSpread = true; + + return; + } + + // If the next attribute is a spread, we're effective last in this object + if (!isLast) { + isLast = attributesObject[index + 1].type === Syntax.XJSSpreadAttribute; + } + if (attr.name.namespace) { throw new Error( 'Namespace attributes are not supported. ReactJSX is not XML.'); } var name = attr.name.name; - var isLast = index === attributesObject.length - 1; utils.catchup(attr.range[0], state, trimLeft); + + if (previousWasSpread) { + utils.append('{', state); + } + utils.append(quoteAttrName(name), state); utils.append(': ', state); @@ -119,6 +179,9 @@ function visitReactTag(traverse, object, path, state) { } utils.catchup(attr.range[1], state, trimLeft); + + previousWasSpread = false; + }); if (!openingElement.selfClosing) { @@ -126,10 +189,14 @@ function visitReactTag(traverse, object, path, state) { utils.move(openingElement.range[1], state); } - if (hasAttributes) { + if (hasAttributes && !previousWasSpread) { utils.append('}', state); } + if (hasAtLeastOneSpreadProperty) { + utils.append(')', state); + } + // filter out whitespace var childrenToRender = object.children.filter(function(child) { return !(child.type === Syntax.Literal