diff --git a/src/addons/transitions/ReactTransitionGroup.js b/src/addons/transitions/ReactTransitionGroup.js index 816edfacc57..882581f67b5 100644 --- a/src/addons/transitions/ReactTransitionGroup.js +++ b/src/addons/transitions/ReactTransitionGroup.js @@ -14,7 +14,6 @@ var React = require('React'); var ReactTransitionChildMapping = require('ReactTransitionChildMapping'); -var assign = require('Object.assign'); var emptyFunction = require('emptyFunction'); var ReactTransitionGroup = React.createClass({ @@ -33,138 +32,151 @@ var ReactTransitionGroup = React.createClass({ }, getInitialState: function() { + // children - the set of children that we are trying to acheive + // childrenToRender - expresses our current state and is what we actually render return { children: ReactTransitionChildMapping.getChildMapping(this.props.children), + childrenToRender: {}, }; }, componentWillMount: function() { - this.currentlyTransitioningKeys = {}; - this.keysToEnter = []; - this.keysToLeave = []; + this.actionsToPerform = {}; + this.setState({ + childrenToRender: this.updatechildrenToRender(this.state.children), + }); }, componentDidMount: function() { - var initialChildMapping = this.state.children; - for (var key in initialChildMapping) { - if (initialChildMapping[key]) { - this.performAppear(key); - } - } + this.performchildrenToRenderActions(true); }, componentWillReceiveProps: function(nextProps) { - var nextChildMapping = ReactTransitionChildMapping.getChildMapping( - nextProps.children - ); - var prevChildMapping = this.state.children; + var nextChildMapping = ReactTransitionChildMapping.getChildMapping(nextProps.children); + + var nextchildrenToRender = this.updatechildrenToRender(nextChildMapping); this.setState({ - children: ReactTransitionChildMapping.mergeChildMappings( - prevChildMapping, - nextChildMapping - ), + children: nextChildMapping, + childrenToRender: nextchildrenToRender, }); + }, - var key; + componentDidUpdate: function() { + this.performchildrenToRenderActions(); + }, - for (key in nextChildMapping) { - var hasPrev = prevChildMapping && prevChildMapping.hasOwnProperty(key); - if (nextChildMapping[key] && !hasPrev && - !this.currentlyTransitioningKeys[key]) { - this.keysToEnter.push(key); + updatechildrenToRender: function(newchildren) { + newchildren = newchildren || {}; + var childrenToRender = this.state.childrenToRender; + var nextActionsToPerform = {}; + + // Find new children and add + for (var key in newchildren) { + + if (childrenToRender[key]) { + // Already exists + + // Exists but was on it's way out. Let's interrupt + if (!childrenToRender[key].shouldBeInDOM) { + childrenToRender[key].shouldBeInDOM = true; + // Queue action to be performed during componentDidUpdate + nextActionsToPerform[key] = childrenToRender[key]; + } + } else { + // Is new + childrenToRender[key] = { + child: newchildren[key], + shouldBeInDOM: true, + }; + // Queue action to be performed during componentDidUpdate + nextActionsToPerform[key] = childrenToRender[key]; } } - for (key in prevChildMapping) { - var hasNext = nextChildMapping && nextChildMapping.hasOwnProperty(key); - if (prevChildMapping[key] && !hasNext && - !this.currentlyTransitioningKeys[key]) { - this.keysToLeave.push(key); - } - } + // Find nodes that should longer exist, mark for removal + var childrenKeys = Object.keys(newchildren); + var keysForRemoval = Object.keys(childrenToRender).filter(function(k) { + return childrenKeys.indexOf(k) < 0; + }); + keysForRemoval.forEach(function(keyToRemove) { + childrenToRender[keyToRemove].shouldBeInDOM = false; + // Queue action to be performed during componentDidUpdate + nextActionsToPerform[keyToRemove] = childrenToRender[keyToRemove]; + }); + + this.actionsToPerform = nextActionsToPerform; // If we want to someday check for reordering, we could do it here. + + return childrenToRender; }, - - componentDidUpdate: function() { - var keysToEnter = this.keysToEnter; - this.keysToEnter = []; - keysToEnter.forEach(this.performEnter); - - var keysToLeave = this.keysToLeave; - this.keysToLeave = []; - keysToLeave.forEach(this.performLeave); + + performchildrenToRenderActions: function(isInitialMount) { + for (var key in this.actionsToPerform) { + if (this.actionsToPerform[key].shouldBeInDOM) { + if (isInitialMount) { + this.performAppear(key); + } else { + this.performEnter(key); + } + } else { + this.performLeave(key); + } + } + // Reset actions since we've performed all of them. + this.actionsToPerform = {}; }, performAppear: function(key) { - this.currentlyTransitioningKeys[key] = true; - var component = this.refs[key]; if (component.componentWillAppear) { - component.componentWillAppear( - this._handleDoneAppearing.bind(this, key) - ); + component.componentWillAppear(this._handleDoneAppearing.bind(this, key)); } else { this._handleDoneAppearing(key); } }, _handleDoneAppearing: function(key) { + if (!this.state.childrenToRender[key].shouldBeInDOM) { + // Ignore this callback if the component should now be in the DOM + return; + } + var component = this.refs[key]; + if (component.componentDidAppear) { component.componentDidAppear(); } - - delete this.currentlyTransitioningKeys[key]; - - var currentChildMapping = ReactTransitionChildMapping.getChildMapping( - this.props.children - ); - - if (!currentChildMapping || !currentChildMapping.hasOwnProperty(key)) { - // This was removed before it had fully appeared. Remove it. - this.performLeave(key); - } }, performEnter: function(key) { - this.currentlyTransitioningKeys[key] = true; - var component = this.refs[key]; if (component.componentWillEnter) { - component.componentWillEnter( - this._handleDoneEntering.bind(this, key) - ); + component.componentWillEnter(this._handleDoneEntering.bind(this, key)); } else { this._handleDoneEntering(key); } }, _handleDoneEntering: function(key) { - var component = this.refs[key]; - if (component.componentDidEnter) { - component.componentDidEnter(); + if (!this.state.childrenToRender[key].shouldBeInDOM) { + // Ignore this callback if the component should no longer be in the DOM + return; } - delete this.currentlyTransitioningKeys[key]; - - var currentChildMapping = ReactTransitionChildMapping.getChildMapping( - this.props.children - ); + var component = this.refs[key]; - if (!currentChildMapping || !currentChildMapping.hasOwnProperty(key)) { - // This was removed before it had fully entered. Remove it. - this.performLeave(key); + if (component.componentDidEnter) { + component.componentDidEnter(); } }, performLeave: function(key) { - this.currentlyTransitioningKeys[key] = true; - var component = this.refs[key]; + if (component.componentWillLeave) { component.componentWillLeave(this._handleDoneLeaving.bind(this, key)); } else { @@ -176,36 +188,29 @@ var ReactTransitionGroup = React.createClass({ }, _handleDoneLeaving: function(key) { + if (this.state.childrenToRender[key].shouldBeInDOM) { + return; + } + var component = this.refs[key]; if (component.componentDidLeave) { component.componentDidLeave(); } - delete this.currentlyTransitioningKeys[key]; - - var currentChildMapping = ReactTransitionChildMapping.getChildMapping( - this.props.children - ); - - if (currentChildMapping && currentChildMapping.hasOwnProperty(key)) { - // This entered again before it fully left. Add it again. - this.performEnter(key); - } else { - this.setState(function(state) { - var newChildren = assign({}, state.children); - delete newChildren[key]; - return {children: newChildren}; - }); - } + var newChildrenToRender = this.state.childrenToRender; + delete newChildrenToRender[key]; + this.setState({ + childrenToRender: newChildrenToRender, + }); }, render: function() { // TODO: we could get rid of the need for the wrapper node // by cloning a single child var childrenToRender = []; - for (var key in this.state.children) { - var child = this.state.children[key]; + for (var key in this.state.childrenToRender) { + var child = this.state.childrenToRender[key].child; if (child) { // You may need to apply reactive updates to a child as it is leaving. // The normal React way to do it won't work since the child will have @@ -213,15 +218,16 @@ var ReactTransitionGroup = React.createClass({ // a childFactory function to wrap every child, even the ones that are // leaving. childrenToRender.push(React.cloneElement( - this.props.childFactory(child), - {ref: key, key: key} + this.props.childFactory(child), + { ref: key, key: key } )); } } + return React.createElement( this.props.component, this.props, - childrenToRender + childrenToRender, ); }, }); diff --git a/src/addons/transitions/__tests__/ReactCSSTransitionGroup-test.js b/src/addons/transitions/__tests__/ReactCSSTransitionGroup-test.js index 0bf8c6bb2ff..bea7cb2409b 100644 --- a/src/addons/transitions/__tests__/ReactCSSTransitionGroup-test.js +++ b/src/addons/transitions/__tests__/ReactCSSTransitionGroup-test.js @@ -23,7 +23,7 @@ describe('ReactCSSTransitionGroup', function() { var container; beforeEach(function() { - jest.resetModuleRegistry(); + require('mock-modules').dumpCache(); React = require('React'); ReactDOM = require('ReactDOM'); ReactCSSTransitionGroup = require('ReactCSSTransitionGroup'); @@ -90,8 +90,8 @@ describe('ReactCSSTransitionGroup', function() { container ); expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(2); - expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('two'); - expect(ReactDOM.findDOMNode(a).childNodes[1].id).toBe('one'); + expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('one'); + expect(ReactDOM.findDOMNode(a).childNodes[1].id).toBe('two'); // For some reason jst is adding extra setTimeout()s and grunt test isn't, // so we need to do this disgusting hack. @@ -125,8 +125,8 @@ describe('ReactCSSTransitionGroup', function() { container ); expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(2); - expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('two'); - expect(ReactDOM.findDOMNode(a).childNodes[1].id).toBe('one'); + expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('one'); + expect(ReactDOM.findDOMNode(a).childNodes[1].id).toBe('two'); }); it('should switch transitionLeave from false to true', function() { @@ -160,8 +160,8 @@ describe('ReactCSSTransitionGroup', function() { container ); expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(2); - expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('three'); - expect(ReactDOM.findDOMNode(a).childNodes[1].id).toBe('two'); + expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('two'); + expect(ReactDOM.findDOMNode(a).childNodes[1].id).toBe('three'); }); it('should work with no children', function() { diff --git a/src/addons/transitions/__tests__/ReactTransitionGroup-test.js b/src/addons/transitions/__tests__/ReactTransitionGroup-test.js index f98302be10c..e8911d06436 100644 --- a/src/addons/transitions/__tests__/ReactTransitionGroup-test.js +++ b/src/addons/transitions/__tests__/ReactTransitionGroup-test.js @@ -92,9 +92,8 @@ describe('ReactTransitionGroup', function() { }); }); - it('should handle enter/leave/enter/leave correctly', function() { + it('should handle enter/leave/enter/leave correctly when able to complete transitions', function() { var log = []; - var willEnterCb; var Child = React.createClass({ componentDidMount: function() { @@ -102,7 +101,7 @@ describe('ReactTransitionGroup', function() { }, componentWillEnter: function(cb) { log.push('willEnter'); - willEnterCb = cb; + cb(); }, componentDidEnter: function() { log.push('didEnter'); @@ -137,24 +136,34 @@ describe('ReactTransitionGroup', function() { var instance = ReactDOM.render(, container); expect(log).toEqual(['didMount']); + instance.setState({count: 2}); - expect(log).toEqual(['didMount', 'didMount', 'willEnter']); - for (var k = 0; k < 5; k++) { - instance.setState({count: 2}); - expect(log).toEqual(['didMount', 'didMount', 'willEnter']); - instance.setState({count: 1}); - } - // other animations are blocked until willEnterCb is called - willEnterCb(); + expect(log).toEqual(['didMount', 'didMount', 'willEnter', 'didEnter']); + + instance.setState({count: 1}); + expect(log).toEqual([ + 'didMount', 'didMount', 'willEnter', 'didEnter', + 'willLeave', 'didLeave', 'willUnmount', + ]); + + instance.setState({count: 2}); + expect(log).toEqual([ + 'didMount', 'didMount', 'willEnter', 'didEnter', + 'willLeave', 'didLeave', 'willUnmount', + 'didMount', 'willEnter', 'didEnter', + ]); + + instance.setState({count: 1}); expect(log).toEqual([ - 'didMount', 'didMount', 'willEnter', - 'didEnter', 'willLeave', 'didLeave', 'willUnmount', + 'didMount', 'didMount', 'willEnter', 'didEnter', + 'willLeave', 'didLeave', 'willUnmount', + 'didMount', 'willEnter', 'didEnter', + 'willLeave', 'didLeave', 'willUnmount', ]); }); - it('should handle enter/leave/enter correctly', function() { + it('should handle enter/leave/enter/leave correctly when unable to complete enter transition', function() { var log = []; - var willEnterCb; var Child = React.createClass({ componentDidMount: function() { @@ -162,7 +171,7 @@ describe('ReactTransitionGroup', function() { }, componentWillEnter: function(cb) { log.push('willEnter'); - willEnterCb = cb; + // no callback (this animation didn't have time to complete) }, componentDidEnter: function() { log.push('didEnter'); @@ -197,16 +206,110 @@ describe('ReactTransitionGroup', function() { var instance = ReactDOM.render(, container); expect(log).toEqual(['didMount']); + instance.setState({count: 2}); expect(log).toEqual(['didMount', 'didMount', 'willEnter']); - for (var k = 0; k < 5; k++) { - instance.setState({count: 1}); - expect(log).toEqual(['didMount', 'didMount', 'willEnter']); - instance.setState({count: 2}); - } - willEnterCb(); + + instance.setState({count: 1}); + expect(log).toEqual([ + 'didMount', 'didMount', 'willEnter', + 'willLeave', 'didLeave', 'willUnmount', + ]); + + instance.setState({count: 2}); + expect(log).toEqual([ + 'didMount', 'didMount', 'willEnter', + 'willLeave', 'didLeave', 'willUnmount', + 'didMount', 'willEnter', + ]); + + instance.setState({count: 1}); + expect(log).toEqual([ + 'didMount', 'didMount', 'willEnter', + 'willLeave', 'didLeave', 'willUnmount', + 'didMount', 'willEnter', + 'willLeave', 'didLeave', 'willUnmount', + ]); + }); + + it('should handle enter/leave/enter/leave correctly when unable to complete leave transition', function() { + var log = []; + var leaveCB; + + var Child = React.createClass({ + componentDidMount: function() { + log.push('didMount'); + }, + componentWillEnter: function(cb) { + log.push('willEnter'); + cb(); + }, + componentDidEnter: function() { + log.push('didEnter'); + }, + componentWillLeave: function(cb) { + log.push('willLeave'); + // no callback (this animation didn't have time to complete) + leaveCB = cb; + }, + componentDidLeave: function() { + log.push('didLeave'); + }, + componentWillUnmount: function() { + log.push('willUnmount'); + }, + render: function() { + return ; + }, + }); + + var Component = React.createClass({ + getInitialState: function() { + return {count: 1}; + }, + render: function() { + var children = []; + for (var i = 0; i < this.state.count; i++) { + children.push(); + } + return {children}; + }, + }); + + var instance = ReactDOM.render(, container); + expect(log).toEqual(['didMount']); + instance.setState({count: 2}); + expect(log).toEqual(['didMount', 'didMount', 'willEnter', 'didEnter']); + + instance.setState({count: 1}); + expect(log).toEqual([ + 'didMount', 'didMount', 'willEnter', 'didEnter', + 'willLeave', + ]); + + instance.setState({count: 2}); + expect(log).toEqual([ + 'didMount', 'didMount', 'willEnter', 'didEnter', + 'willLeave', + 'willEnter', 'didEnter', + ]); + + instance.setState({count: 1}); + expect(log).toEqual([ + 'didMount', 'didMount', 'willEnter', 'didEnter', + 'willLeave', + 'willEnter', 'didEnter', + 'willLeave', + ]); + + leaveCB(); // Leave given enough time to complete + expect(log).toEqual([ 'didMount', 'didMount', 'willEnter', 'didEnter', + 'willLeave', + 'willEnter', 'didEnter', + 'willLeave', + 'didLeave', 'willUnmount', ]); });