From 5cbb68258bd02dcb4b0f0867a833f5997bdadf03 Mon Sep 17 00:00:00 2001 From: Ben Alpert Date: Sat, 14 May 2016 15:30:08 -0700 Subject: [PATCH] Add (failing) React ART tests This helps us make sure we don't break React ART in a minor or patch release. The idea is to not change these files when making minor or patch changes. Copied directly from react-art with requires fixed. (I also picked a different haste name just in case.) --- .eslintignore | 1 + package.json | 1 + src/renderers/art/ReactART.js | 643 +++++++++++++++++++ src/renderers/art/__tests__/ReactART-test.js | 289 +++++++++ 4 files changed, 934 insertions(+) create mode 100644 src/renderers/art/ReactART.js create mode 100644 src/renderers/art/__tests__/ReactART-test.js diff --git a/.eslintignore b/.eslintignore index 87837497a0..0ffbe9528c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,5 @@ # We can probably lint these later but not important at this point +src/renderers/art src/shared/vendor # But not in docs/_js/examples/* docs/_js/*.js diff --git a/package.json b/package.json index 6397c7e19f..1b20001d2e 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "version": "16.0.0-alpha", "devDependencies": { + "art": "^0.10.1", "async": "^1.5.0", "babel-cli": "^6.6.5", "babel-core": "^6.0.0", diff --git a/src/renderers/art/ReactART.js b/src/renderers/art/ReactART.js new file mode 100644 index 0000000000..c1d61dbe37 --- /dev/null +++ b/src/renderers/art/ReactART.js @@ -0,0 +1,643 @@ +/** + * Copyright (c) 2013-present 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 ReactARTFifteen + */ + +'use strict'; + +require('art/modes/current').setCurrent( + require('art/modes/fast-noSideEffects') // Flip this to DOM mode for debugging +); + +const Transform = require('art/core/transform'); +const Mode = require('art/modes/current'); + +const React = require('React'); +const ReactDOM = require('ReactDOM'); +const ReactInstanceMap = require('ReactInstanceMap'); +const ReactMultiChild = require('ReactMultiChild'); +const ReactUpdates = require('ReactUpdates'); + +const emptyObject = require('emptyObject'); +const invariant = require('invariant'); + +const assign = require('object-assign'); +const pooledTransform = new Transform(); + +// Utilities + +function childrenAsString(children) { + if (!children) { + return ''; + } + if (typeof children === 'string') { + return children; + } + if (children.length) { + return children.join('\n'); + } + return ''; +} + +function createComponent(name) { + const ReactARTComponent = function(element) { + this.node = null; + this.subscriptions = null; + this.listeners = null; + this._mountImage = null; + this._renderedChildren = null; + this.construct(element); + }; + ReactARTComponent.displayName = name; + for (let i = 1, l = arguments.length; i < l; i++) { + assign(ReactARTComponent.prototype, arguments[i]); + } + + return ReactARTComponent; +} + +/** + * Insert `node` into `parentNode` after `referenceNode`. + */ +function injectAfter(parentNode, referenceNode, node) { + let beforeNode; + if (node.parentNode === parentNode && + node.previousSibling === referenceNode) { + return; + } + if (referenceNode == null) { + // node is supposed to be first. + beforeNode = parentNode.firstChild; + } else { + // node is supposed to be after referenceNode. + beforeNode = referenceNode.nextSibling; + } + if (beforeNode && beforeNode.previousSibling !== node) { + // Cases where `node === beforeNode` should get filtered out by earlier + // checks and the behavior isn't well-defined. + invariant( + node !== beforeNode, + 'ReactART: Can not insert node before itself' + ); + node.injectBefore(beforeNode); + } else if (node.parentNode !== parentNode) { + node.inject(parentNode); + } +} + +// ContainerMixin for components that can hold ART nodes + +const ContainerMixin = assign({}, ReactMultiChild.Mixin, { + + /** + * Moves a child component to the supplied index. + * + * @param {ReactComponent} child Component to move. + * @param {number} toIndex Destination index of the element. + * @protected + */ + moveChild: function(child, afterNode, toIndex, lastIndex) { + const childNode = child._mountImage; + injectAfter(this.node, afterNode, childNode); + }, + + /** + * Creates a child component. + * + * @param {ReactComponent} child Component to create. + * @param {object} childNode ART node to insert. + * @protected + */ + createChild: function(child, afterNode, childNode) { + child._mountImage = childNode; + injectAfter(this.node, afterNode, childNode); + }, + + /** + * Removes a child component. + * + * @param {ReactComponent} child Child to remove. + * @protected + */ + removeChild: function(child) { + child._mountImage.eject(); + child._mountImage = null; + }, + + updateChildrenAtRoot: function(nextChildren, transaction) { + this.updateChildren(nextChildren, transaction, emptyObject); + }, + + mountAndInjectChildrenAtRoot: function(children, transaction) { + this.mountAndInjectChildren(children, transaction, emptyObject); + }, + + /** + * Override to bypass batch updating because it is not necessary. + * + * @param {?object} nextChildren. + * @param {ReactReconcileTransaction} transaction + * @internal + * @override {ReactMultiChild.Mixin.updateChildren} + */ + updateChildren: function(nextChildren, transaction, context) { + this._updateChildren(nextChildren, transaction, context); + }, + + // Shorthands + + mountAndInjectChildren: function(children, transaction, context) { + const mountedImages = this.mountChildren( + children, + transaction, + context + ); + // Each mount image corresponds to one of the flattened children + let i = 0; + for (let key in this._renderedChildren) { + if (this._renderedChildren.hasOwnProperty(key)) { + const child = this._renderedChildren[key]; + child._mountImage = mountedImages[i]; + mountedImages[i].inject(this.node); + i++; + } + } + } + +}); + +// Surface is a React DOM Component, not an ART component. It serves as the +// entry point into the ART reconciler. + +const Surface = React.createClass({ + + displayName: 'Surface', + + mixins: [ContainerMixin], + + componentDidMount: function() { + const domNode = ReactDOM.findDOMNode(this); + + this.node = Mode.Surface(+this.props.width, +this.props.height, domNode); + + const transaction = ReactUpdates.ReactReconcileTransaction.getPooled(); + transaction.perform( + this.mountAndInjectChildren, + this, + this.props.children, + transaction, + ReactInstanceMap.get(this)._context + ); + ReactUpdates.ReactReconcileTransaction.release(transaction); + }, + + componentDidUpdate: function(oldProps) { + const node = this.node; + if (this.props.width != oldProps.width || + this.props.height != oldProps.height) { + node.resize(+this.props.width, +this.props.height); + } + + const transaction = ReactUpdates.ReactReconcileTransaction.getPooled(); + transaction.perform( + this.updateChildren, + this, + this.props.children, + transaction, + ReactInstanceMap.get(this)._context + ); + ReactUpdates.ReactReconcileTransaction.release(transaction); + + if (node.render) { + node.render(); + } + }, + + componentWillUnmount: function() { + this.unmountChildren(); + }, + + render: function() { + // This is going to be a placeholder because we don't know what it will + // actually resolve to because ART may render canvas, vml or svg tags here. + // We only allow a subset of properties since others might conflict with + // ART's properties. + const props = this.props; + + // TODO: ART's Canvas Mode overrides surface title and cursor + const Tag = Mode.Surface.tagName; + return ( + + ); + } + +}); + +// Various nodes that can go into a surface + +const EventTypes = { + onMouseMove: 'mousemove', + onMouseOver: 'mouseover', + onMouseOut: 'mouseout', + onMouseUp: 'mouseup', + onMouseDown: 'mousedown', + onClick: 'click' +}; + +const NodeMixin = { + + construct: function(element) { + this._currentElement = element; + }, + + getNativeNode: function() { + return this.node; + }, + + getPublicInstance: function() { + return this.node; + }, + + putEventListener: function(type, listener) { + const subscriptions = this.subscriptions || (this.subscriptions = {}); + const listeners = this.listeners || (this.listeners = {}); + listeners[type] = listener; + if (listener) { + if (!subscriptions[type]) { + subscriptions[type] = this.node.subscribe(type, listener, this); + } + } else { + if (subscriptions[type]) { + subscriptions[type](); + delete subscriptions[type]; + } + } + }, + + handleEvent: function(event) { + const listener = this.listeners[event.type]; + if (!listener) { + return; + } + if (typeof listener === 'function') { + listener.call(this, event); + } else if (listener.handleEvent) { + listener.handleEvent(event); + } + }, + + destroyEventListeners: function() { + const subscriptions = this.subscriptions; + if (subscriptions) { + for (let type in subscriptions) { + subscriptions[type](); + } + } + this.subscriptions = null; + this.listeners = null; + }, + + applyNodeProps: function(oldProps, props) { + const node = this.node; + + const scaleX = props.scaleX != null ? props.scaleX : + props.scale != null ? props.scale : 1; + const scaleY = props.scaleY != null ? props.scaleY : + props.scale != null ? props.scale : 1; + + pooledTransform + .transformTo(1, 0, 0, 1, 0, 0) + .move(props.x || 0, props.y || 0) + .rotate(props.rotation || 0, props.originX, props.originY) + .scale(scaleX, scaleY, props.originX, props.originY); + + if (props.transform != null) { + pooledTransform.transform(props.transform); + } + + if (node.xx !== pooledTransform.xx || node.yx !== pooledTransform.yx || + node.xy !== pooledTransform.xy || node.yy !== pooledTransform.yy || + node.x !== pooledTransform.x || node.y !== pooledTransform.y) { + node.transformTo(pooledTransform); + } + + if (props.cursor !== oldProps.cursor || props.title !== oldProps.title) { + node.indicate(props.cursor, props.title); + } + + if (node.blend && props.opacity !== oldProps.opacity) { + node.blend(props.opacity == null ? 1 : props.opacity); + } + + if (props.visible !== oldProps.visible) { + if (props.visible == null || props.visible) { + node.show(); + } else { + node.hide(); + } + } + + for (let type in EventTypes) { + this.putEventListener(EventTypes[type], props[type]); + } + }, + + mountComponentIntoNode: function(rootID, container) { + throw new Error( + 'You cannot render an ART component standalone. ' + + 'You need to wrap it in a Surface.' + ); + } + +}; + +// Group + +const Group = createComponent('Group', NodeMixin, ContainerMixin, { + + mountComponent: function( + transaction, + nativeParent, + nativeContainerInfo, + context + ) { + this.node = Mode.Group(); + const props = this._currentElement.props; + this.applyGroupProps(emptyObject, props); + this.mountAndInjectChildren(props.children, transaction, context); + return this.node; + }, + + receiveComponent: function(nextComponent, transaction, context) { + const props = nextComponent.props; + const oldProps = this._currentElement.props; + this.applyGroupProps(oldProps, props); + this.updateChildren(props.children, transaction, context); + this._currentElement = nextComponent; + }, + + applyGroupProps: function(oldProps, props) { + this.node.width = props.width; + this.node.height = props.height; + this.applyNodeProps(oldProps, props); + }, + + unmountComponent: function() { + this.destroyEventListeners(); + this.unmountChildren(); + } + +}); + +// ClippingRectangle +const ClippingRectangle = createComponent( + 'ClippingRectangle', NodeMixin, ContainerMixin, { + + mountComponent: function( + transaction, + nativeParent, + nativeContainerInfo, + context + ) { + this.node = Mode.ClippingRectangle(); + const props = this._currentElement.props; + this.applyClippingProps(emptyObject, props); + this.mountAndInjectChildren(props.children, transaction, context); + return this.node; + }, + + receiveComponent: function(nextComponent, transaction, context) { + const props = nextComponent.props; + const oldProps = this._currentElement.props; + this.applyClippingProps(oldProps, props); + this.updateChildren(props.children, transaction, context); + this._currentElement = nextComponent; + }, + + applyClippingProps: function(oldProps, props) { + this.node.width = props.width; + this.node.height = props.height; + this.node.x = props.x; + this.node.y = props.y; + this.applyNodeProps(oldProps, props); + }, + + unmountComponent: function() { + this.destroyEventListeners(); + this.unmountChildren(); + } + +}); + + +// Renderables + +const RenderableMixin = assign({}, NodeMixin, { + + applyRenderableProps: function(oldProps, props) { + if (oldProps.fill !== props.fill) { + if (props.fill && props.fill.applyFill) { + props.fill.applyFill(this.node); + } else { + this.node.fill(props.fill); + } + } + if ( + oldProps.stroke !== props.stroke || + oldProps.strokeWidth !== props.strokeWidth || + oldProps.strokeCap !== props.strokeCap || + oldProps.strokeJoin !== props.strokeJoin || + // TODO: Consider a deep check of stokeDash. + // This may benefit the VML version in IE. + oldProps.strokeDash !== props.strokeDash + ) { + this.node.stroke( + props.stroke, + props.strokeWidth, + props.strokeCap, + props.strokeJoin, + props.strokeDash + ); + } + this.applyNodeProps(oldProps, props); + }, + + unmountComponent: function() { + this.destroyEventListeners(); + } + +}); + +// Shape + +const Shape = createComponent('Shape', RenderableMixin, { + + construct: function(element) { + this._currentElement = element; + this._oldDelta = null; + this._oldPath = null; + }, + + mountComponent: function( + transaction, + nativeParent, + nativeContainerInfo, + context + ) { + this.node = Mode.Shape(); + const props = this._currentElement.props; + this.applyShapeProps(emptyObject, props); + return this.node; + }, + + receiveComponent: function(nextComponent, transaction, context) { + const props = nextComponent.props; + const oldProps = this._currentElement.props; + this.applyShapeProps(oldProps, props); + this._currentElement = nextComponent; + }, + + applyShapeProps: function(oldProps, props) { + const oldDelta = this._oldDelta; + const oldPath = this._oldPath; + const path = props.d || childrenAsString(props.children); + + if (path.delta !== oldDelta || + path !== oldPath || + oldProps.width !== props.width || + oldProps.height !== props.height) { + + this.node.draw( + path, + props.width, + props.height + ); + + this._oldPath = path; + this._oldDelta = path.delta; + } + + this.applyRenderableProps(oldProps, props); + } + +}); + +// Text + +const Text = createComponent('Text', RenderableMixin, { + + construct: function(element) { + this._currentElement = element; + this._oldString = null; + }, + + mountComponent: function( + transaction, + nativeParent, + nativeContainerInfo, + context + ) { + const props = this._currentElement.props; + const newString = childrenAsString(props.children); + this.node = Mode.Text(newString, props.font, props.alignment, props.path); + this._oldString = newString; + this.applyRenderableProps(emptyObject, props); + return this.node; + }, + + isSameFont: function(oldFont, newFont) { + if (oldFont === newFont) { + return true; + } + if (typeof newFont === 'string' || typeof oldFont === 'string') { + return false; + } + return ( + newFont.fontSize === oldFont.fontSize && + newFont.fontStyle === oldFont.fontStyle && + newFont.fontVariant === oldFont.fontVariant && + newFont.fontWeight === oldFont.fontWeight && + newFont.fontFamily === oldFont.fontFamily + ); + }, + + receiveComponent: function(nextComponent, transaction, context) { + const props = nextComponent.props; + const oldProps = this._currentElement.props; + + const oldString = this._oldString; + const newString = childrenAsString(props.children); + + if (oldString !== newString || + !this.isSameFont(oldProps.font, props.font) || + oldProps.alignment !== props.alignment || + oldProps.path !== props.path) { + this.node.draw( + newString, + props.font, + props.alignment, + props.path + ); + this._oldString = newString; + } + + this.applyRenderableProps(oldProps, props); + this._currentElement = nextComponent; + } + +}); + +// Declarative fill type objects - API design not finalized + +const slice = Array.prototype.slice; + +function LinearGradient(stops, x1, y1, x2, y2) { + this.args = slice.call(arguments); +} + +LinearGradient.prototype.applyFill = function(node) { + node.fillLinear.apply(node, this.args); +}; + +function RadialGradient(stops, fx, fy, rx, ry, cx, cy) { + this.args = slice.call(arguments); +} + +RadialGradient.prototype.applyFill = function(node) { + node.fillRadial.apply(node, this.args); +}; + +function Pattern(url, width, height, left, top) { + this.args = slice.call(arguments); +} + +Pattern.prototype.applyFill = function(node) { + node.fillImage.apply(node, this.args); +}; + +module.exports = { + ClippingRectangle, + Group, + LinearGradient, + Path: Mode.Path, + Pattern, + RadialGradient, + Shape, + Surface, + Text, + Transform, +}; diff --git a/src/renderers/art/__tests__/ReactART-test.js b/src/renderers/art/__tests__/ReactART-test.js new file mode 100644 index 0000000000..37a06a4d82 --- /dev/null +++ b/src/renderers/art/__tests__/ReactART-test.js @@ -0,0 +1,289 @@ +/** + * Copyright (c) 2013-present 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 + */ + +/*jslint evil: true */ + +'use strict'; + +jest + .unmock('ReactARTFifteen'); + +var React = require('React'); +var ReactDOM = require('ReactDOM'); +var ReactTestUtils = require('ReactTestUtils'); + +var Group; +var Shape; +var Surface; +var TestComponent; + +var Missing = {}; + +var ReactART = require('ReactARTFifteen'); +var ARTSVGMode = require('art/modes/svg'); +var ARTCurrentMode = require('art/modes/current'); + +function testDOMNodeStructure(domNode, expectedStructure) { + expect(domNode).toBeDefined(); + expect(domNode.nodeName).toBe(expectedStructure.nodeName); + for (var prop in expectedStructure) { + if (!expectedStructure.hasOwnProperty(prop)) continue; + if (prop != 'nodeName' && prop != 'children') { + if (expectedStructure[prop] === Missing) { + expect(domNode.hasAttribute(prop)).toBe(false); + } else { + expect(domNode.getAttribute(prop)).toBe(expectedStructure[prop]); + } + } + } + if (expectedStructure.children) { + expectedStructure.children.forEach(function(subTree, index) { + testDOMNodeStructure(domNode.childNodes[index], subTree); + }); + } +} + +describe('ReactART', function() { + + beforeEach(function() { + ARTCurrentMode.setCurrent(ARTSVGMode); + + Group = ReactART.Group; + Shape = ReactART.Shape; + Surface = ReactART.Surface; + + TestComponent = React.createClass({ + render: function() { + + var a = + ; + + var b = + + M64.564,38.583H54l0.008-5.834c0-3.035,0.293-4.666,4.657-4.666 + h5.833V16.429h-9.33c-11.213,0-15.159,5.654-15.159,15.16v6.994 + h-6.99v11.652h6.99v33.815H54V50.235h9.331L64.564,38.583z + ; + + var c = ; + + return ( + + + {this.props.flipped ? [b, a, c] : [a, b, c]} + + + ); + } + }); + }); + + it('should have the correct lifecycle state', function() { + var instance = ; + instance = ReactTestUtils.renderIntoDocument(instance); + var group = instance.refs.group; + // Duck type test for an ART group + expect(typeof group.indicate).toBe('function'); + }); + + it('should render a reasonable SVG structure in SVG mode', function() { + var instance = ; + instance = ReactTestUtils.renderIntoDocument(instance); + + var expectedStructure = { + nodeName: 'svg', + width: '150', + height: '200', + children: [ + { nodeName: 'defs' }, + { + nodeName: 'g', + children: [ + { + nodeName: 'defs', + children: [ + { nodeName: 'linearGradient' } + ] + }, + { nodeName: 'path' }, + { nodeName: 'path' }, + { nodeName: 'g' } + ] + } + ] + }; + + var realNode = ReactDOM.findDOMNode(instance); + testDOMNodeStructure(realNode, expectedStructure); + }); + + it('should be able to reorder components', function() { + var container = document.createElement('div'); + var instance = ReactDOM.render(, container); + + var expectedStructure = { + nodeName: 'svg', + children: [ + { nodeName: 'defs' }, + { + nodeName: 'g', + children: [ + { nodeName: 'defs' }, + { nodeName: 'path', opacity: '0.1' }, + { nodeName: 'path', opacity: Missing }, + { nodeName: 'g' } + ] + } + ] + }; + + var realNode = ReactDOM.findDOMNode(instance); + testDOMNodeStructure(realNode, expectedStructure); + + ReactDOM.render(, container); + + var expectedNewStructure = { + nodeName: 'svg', + children: [ + { nodeName: 'defs' }, + { + nodeName: 'g', + children: [ + { nodeName: 'defs' }, + { nodeName: 'path', opacity: Missing }, + { nodeName: 'path', opacity: '0.1' }, + { nodeName: 'g' } + ] + } + ] + }; + + testDOMNodeStructure(realNode, expectedNewStructure); + }); + + it('should be able to reorder many components', function() { + var container = document.createElement('div'); + + var Component = React.createClass({ + render: function() { + var chars = this.props.chars.split(''); + return ( + + {chars.map((text) => )} + + ); + }, + }); + + // Mini multi-child stress test: lots of reorders, some adds, some removes. + var before = 'abcdefghijklmnopqrst'; + var after = 'mxhpgwfralkeoivcstzy'; + + var instance = ReactDOM.render(, container); + var realNode = ReactDOM.findDOMNode(instance); + expect(realNode.textContent).toBe(before); + + instance = ReactDOM.render(, container); + expect(realNode.textContent).toBe(after); + + ReactDOM.unmountComponentAtNode(container); + }); + + it('renders composite with lifecycle inside group', function() { + var mounted = false; + var CustomShape = React.createClass({ + render: function() { + return ; + }, + componentDidMount: function() { + mounted = true; + } + }); + ReactTestUtils.renderIntoDocument( + + + + + + ); + expect(mounted).toBe(true); + }); + + it('resolves refs before componentDidMount', function() { + var CustomShape = React.createClass({ + render: function() { + return ; + } + }); + var ref = null; + var Outer = React.createClass({ + componentDidMount: function() { + ref = this.refs.test; + }, + render: function() { + return ( + + + + + + ); + } + }); + ReactTestUtils.renderIntoDocument(); + expect(ref.constructor).toBe(CustomShape); + }); + + it('resolves refs before componentDidUpdate', function() { + var CustomShape = React.createClass({ + render: function() { + return ; + } + }); + var ref = {}; + var Outer = React.createClass({ + componentDidMount: function() { + ref = this.refs.test; + }, + componentDidUpdate: function() { + ref = this.refs.test; + }, + render: function() { + return ( + + + {this.props.mountCustomShape && } + + + ); + } + }); + var container = document.createElement('div'); + ReactDOM.render(, container); + expect(ref).not.toBeDefined(); + ReactDOM.render(, container); + expect(ref.constructor).toBe(CustomShape); + }); + +});