Skip to content

Commit 6bd6ef2

Browse files
ReactCSSTransitionGroup timeouts
As discussed in issue 1326 (facebook#1326) transitionend events are unreliable; they may not fire because the element is no longer painted, the browser tab is no longer focused or for a range of other reasons. This is particularly harmful during leave transitions since the leaving element will be permanently stuck in the DOM (and possibly visible). The ReactCSSTransitionGroup now requires timeouts to be passed in explicitly for each type of animation. Omitting the timeout duration for a transition now triggers a PropTypes warning with a link to the updated documentation.
1 parent b88592a commit 6bd6ef2

File tree

4 files changed

+101
-47
lines changed

4 files changed

+101
-47
lines changed

docs/docs/10.1-animation.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ var TodoList = React.createClass({
4444
return (
4545
<div>
4646
<button onClick={this.handleAdd}>Add Item</button>
47-
<ReactCSSTransitionGroup transitionName="example">
47+
<ReactCSSTransitionGroup transitionName="example" transitionEnterTimeout={500} transitionLeaveTimeout={300} >
4848
{items}
4949
</ReactCSSTransitionGroup>
5050
</div>
@@ -67,31 +67,29 @@ You can use these classes to trigger a CSS animation or transition. For example,
6767

6868
.example-enter.example-enter-active {
6969
opacity: 1;
70-
transition: opacity .5s ease-in;
70+
transition: opacity 500ms ease-in;
7171
}
72-
```
73-
74-
You'll notice that when you try to remove an item `ReactCSSTransitionGroup` keeps it in the DOM. If you're using an unminified build of React with add-ons you'll see a warning that React was expecting an animation or transition to occur. That's because `ReactCSSTransitionGroup` keeps your DOM elements on the page until the animation completes. Try adding this CSS:
7572

76-
```css
7773
.example-leave {
7874
opacity: 1;
7975
}
8076

8177
.example-leave.example-leave-active {
8278
opacity: 0.01;
83-
transition: opacity .5s ease-in;
79+
transition: opacity 300ms ease-in;
8480
}
8581
```
8682

83+
You'll notice that animation durations need to be specified in both the CSS and the render method; this tells React when to remove the animation classes from the element and -- if it's leaving -- when to remove the element from the DOM.
84+
8785
### Animate Initial Mounting
8886

8987
`ReactCSSTransitionGroup` provides the optional prop `transitionAppear`, to add an extra transition phase at the initial mount of the component. There is generally no transition phase at the initial mount as the default value of `transitionAppear` is `false`. The following is an example which passes the prop `transitionAppear` with the value `true`.
9088

9189
```javascript{3-5}
9290
render: function() {
9391
return (
94-
<ReactCSSTransitionGroup transitionName="example" transitionAppear={true}>
92+
<ReactCSSTransitionGroup transitionName="example" transitionAppear={true} transitionAppearTimeout={500}>
9593
<h1>Fading at Initial Mount</h1>
9694
</ReactCSSTransitionGroup>
9795
);
@@ -184,7 +182,7 @@ var ImageCarousel = React.createClass({
184182
render: function() {
185183
return (
186184
<div>
187-
<ReactCSSTransitionGroup transitionName="carousel">
185+
<ReactCSSTransitionGroup transitionName="carousel" transitionEnterTimeout={300} transitionLeaveTimeout={300}>
188186
<img src={this.props.imageSrc} key={this.props.imageSrc} />
189187
</ReactCSSTransitionGroup>
190188
</div>

src/addons/transitions/ReactCSSTransitionGroup.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,31 @@ var ReactCSSTransitionGroupChild = React.createFactory(
2323
require('ReactCSSTransitionGroupChild')
2424
);
2525

26+
function createTransitionTimeoutPropValidator(transitionType) {
27+
var timeoutPropName = 'transition' + transitionType + 'Timeout';
28+
var enabledPropName = 'transition' + transitionType;
29+
30+
return function(props) {
31+
// If the transition is enabled
32+
if (props[enabledPropName]) {
33+
// If no timeout duration is provided
34+
if (!props[timeoutPropName]) {
35+
return new Error(
36+
timeoutPropName + ' wasn\'t supplied to ReactCSSTransitionGroup: ' +
37+
'this can cause unreliable animations and won\'t be supported in ' +
38+
'a future version of React. See ' +
39+
'https://fb.me/react-animation-transition-group-timeout for more ' +
40+
'information.'
41+
);
42+
43+
// If the duration isn't a number
44+
} else if (typeof props[timeoutPropName] !== 'number') {
45+
return new Error(timeoutPropName + ' must be a number (in milliseconds)');
46+
}
47+
}
48+
};
49+
}
50+
2651
var ReactCSSTransitionGroup = React.createClass({
2752
displayName: 'ReactCSSTransitionGroup',
2853

@@ -43,9 +68,13 @@ var ReactCSSTransitionGroup = React.createClass({
4368
appearActive: React.PropTypes.string,
4469
}),
4570
]).isRequired,
71+
4672
transitionAppear: React.PropTypes.bool,
4773
transitionEnter: React.PropTypes.bool,
4874
transitionLeave: React.PropTypes.bool,
75+
transitionAppearTimeout: createTransitionTimeoutPropValidator('Appear'),
76+
transitionEnterTimeout: createTransitionTimeoutPropValidator('Enter'),
77+
transitionLeaveTimeout: createTransitionTimeoutPropValidator('Leave'),
4978
},
5079

5180
getDefaultProps: function() {
@@ -66,6 +95,9 @@ var ReactCSSTransitionGroup = React.createClass({
6695
appear: this.props.transitionAppear,
6796
enter: this.props.transitionEnter,
6897
leave: this.props.transitionLeave,
98+
appearTimeout: this.props.transitionAppearTimeout,
99+
enterTimeout: this.props.transitionEnterTimeout,
100+
leaveTimeout: this.props.transitionLeaveTimeout,
69101
},
70102
child
71103
);

src/addons/transitions/ReactCSSTransitionGroupChild.js

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,35 +19,31 @@ var CSSCore = require('CSSCore');
1919
var ReactTransitionEvents = require('ReactTransitionEvents');
2020

2121
var onlyChild = require('onlyChild');
22-
var warning = require('warning');
2322

2423
// We don't remove the element from the DOM until we receive an animationend or
2524
// transitionend event. If the user screws up and forgets to add an animation
2625
// their node will be stuck in the DOM forever, so we detect if an animation
2726
// does not start and if it doesn't, we just call the end listener immediately.
2827
var TICK = 17;
29-
var NO_EVENT_TIMEOUT = 5000;
30-
31-
var noEventListener = null;
32-
33-
34-
if (__DEV__) {
35-
noEventListener = function() {
36-
warning(
37-
false,
38-
'transition(): tried to perform an animation without ' +
39-
'an animationend or transitionend event after timeout (' +
40-
'%sms). You should either disable this ' +
41-
'transition in JS or add a CSS animation/transition.',
42-
NO_EVENT_TIMEOUT
43-
);
44-
};
45-
}
4628

4729
var ReactCSSTransitionGroupChild = React.createClass({
4830
displayName: 'ReactCSSTransitionGroupChild',
4931

50-
transition: function(animationType, finishCallback) {
32+
propTypes: {
33+
name: React.PropTypes.string.isRequired,
34+
35+
// Once we require timeouts to be specified, we can remove the
36+
// boolean flags (appear etc.) and just accept a number
37+
// or a bool for the timeout flags (appearTimeout etc.)
38+
appear: React.PropTypes.bool,
39+
enter: React.PropTypes.bool,
40+
leave: React.PropTypes.bool,
41+
appearTimeout: React.PropTypes.number,
42+
enterTimeout: React.PropTypes.number,
43+
leaveTimeout: React.PropTypes.number,
44+
},
45+
46+
transition: function(animationType, finishCallback, userSpecifiedDelay) {
5147
var node = ReactDOM.findDOMNode(this);
5248

5349
if (!node) {
@@ -59,16 +55,14 @@ var ReactCSSTransitionGroupChild = React.createClass({
5955

6056
var className = this.props.name[animationType] || this.props.name + '-' + animationType;
6157
var activeClassName = this.props.name[animationType + 'Active'] || className + '-active';
62-
63-
var noEventTimeout = null;
58+
var timeout = null;
6459

6560
var endListener = function(e) {
6661
if (e && e.target !== node) {
6762
return;
6863
}
69-
if (__DEV__) {
70-
clearTimeout(noEventTimeout);
71-
}
64+
65+
clearTimeout(timeout);
7266

7367
CSSCore.removeClass(node, className);
7468
CSSCore.removeClass(node, activeClassName);
@@ -82,15 +76,18 @@ var ReactCSSTransitionGroupChild = React.createClass({
8276
}
8377
};
8478

85-
ReactTransitionEvents.addEndEventListener(node, endListener);
86-
8779
CSSCore.addClass(node, className);
8880

8981
// Need to do this to actually trigger a transition.
9082
this.queueClass(activeClassName);
9183

92-
if (__DEV__) {
93-
noEventTimeout = setTimeout(noEventListener, NO_EVENT_TIMEOUT);
84+
// If the user specified a timeout delay.
85+
if (userSpecifiedDelay) {
86+
// Clean-up the animation after the specified delay
87+
timeout = setTimeout(endListener, userSpecifiedDelay);
88+
} else {
89+
// DEPRECATED: this listener will be removed in a future version of react
90+
ReactTransitionEvents.addEndEventListener(node, endListener);
9491
}
9592
},
9693

@@ -124,23 +121,23 @@ var ReactCSSTransitionGroupChild = React.createClass({
124121

125122
componentWillAppear: function(done) {
126123
if (this.props.appear) {
127-
this.transition('appear', done);
124+
this.transition('appear', done, this.props.appearTimeout);
128125
} else {
129126
done();
130127
}
131128
},
132129

133130
componentWillEnter: function(done) {
134131
if (this.props.enter) {
135-
this.transition('enter', done);
132+
this.transition('enter', done, this.props.enterTimeout);
136133
} else {
137134
done();
138135
}
139136
},
140137

141138
componentWillLeave: function(done) {
142139
if (this.props.leave) {
143-
this.transition('leave', done);
140+
this.transition('leave', done, this.props.leaveTimeout);
144141
} else {
145142
done();
146143
}

src/addons/transitions/__tests__/ReactCSSTransitionGroup-test.js

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,29 @@ describe('ReactCSSTransitionGroup', function() {
2929
spyOn(console, 'error');
3030
});
3131

32-
it('should warn after time with no transitionend', function() {
32+
it('should warn if timeouts aren\'t specified', function() {
33+
ReactDOM.render(
34+
<ReactCSSTransitionGroup
35+
transitionName="yolo"
36+
transitionEnter={false}
37+
transitionLeave={true}
38+
>
39+
<span key="one" id="one" />
40+
</ReactCSSTransitionGroup>,
41+
container
42+
);
43+
44+
// Warning about the missing transitionLeaveTimeout prop
45+
expect(console.error.argsForCall.length).toBe(1);
46+
});
47+
48+
it('should clean-up silently after the timeout elapses', function() {
3349
var a = ReactDOM.render(
34-
<ReactCSSTransitionGroup transitionName="yolo">
50+
<ReactCSSTransitionGroup
51+
transitionName="yolo"
52+
transitionEnter={false}
53+
transitionLeaveTimeout={200}
54+
>
3555
<span key="one" id="one" />
3656
</ReactCSSTransitionGroup>,
3757
container
@@ -41,7 +61,11 @@ describe('ReactCSSTransitionGroup', function() {
4161
setTimeout.mock.calls.length = 0;
4262

4363
ReactDOM.render(
44-
<ReactCSSTransitionGroup transitionName="yolo">
64+
<ReactCSSTransitionGroup
65+
transitionName="yolo"
66+
transitionEnter={false}
67+
transitionLeaveTimeout={200}
68+
>
4569
<span key="two" id="two" />
4670
</ReactCSSTransitionGroup>,
4771
container
@@ -53,14 +77,18 @@ describe('ReactCSSTransitionGroup', function() {
5377
// For some reason jst is adding extra setTimeout()s and grunt test isn't,
5478
// so we need to do this disgusting hack.
5579
for (var i = 0; i < setTimeout.mock.calls.length; i++) {
56-
if (setTimeout.mock.calls[i][1] === 5000) {
80+
if (setTimeout.mock.calls[i][1] === 200) {
5781
setTimeout.mock.calls[i][0]();
5882
break;
5983
}
6084
}
6185

62-
expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(2);
63-
expect(console.error.argsForCall.length).toBe(1);
86+
// No warnings
87+
expect(console.error.argsForCall.length).toBe(0);
88+
89+
// The leaving child has been removed
90+
expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(1);
91+
expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('two');
6492
});
6593

6694
it('should keep both sets of DOM nodes around', function() {
@@ -170,5 +198,4 @@ describe('ReactCSSTransitionGroup', function() {
170198
expect(ReactDOM.findDOMNode(a).childNodes.length).toBe(1);
171199
expect(ReactDOM.findDOMNode(a).childNodes[0].id).toBe('one');
172200
});
173-
174201
});

0 commit comments

Comments
 (0)