Browser DGAF (that you use React)
Adventures in React Performance Debugging
Recently I read Benchling’s 2-part series in debugging performance issues in React, and it really echoed the issues and solutions that I’ve been working through on the Sift Science Console. So I was inspired to chime in with some of my own React performance debugging experiences in what may become a short series itself. The first theme that I’d like to touch on is that although we love using React for its one-way data flow, its easy architecture, and its ever-enlarging community, the browser really doesn’t give a &^@# what you use. You still need to play nicely within it, even if that means not doing things the ‘React way’ in your components. In this post, I’ll highlight how a previous version one of our components that was very clean and concise introduced some subtle performance issues, and how an uglier solution became, in fact, the better one. Because browsers DGAF.
Slidable
Our slidable component is responsible for taking content of unknown (auto) height and revealing it with a slide-down animation. As it gets new content, it slides to the new content’s measured, but still auto, height. Here’s a simplified version:
import _ from 'underscore'; import React from 'react'; const propTypes = { /** Function to be called after the slider finishes its animation */ onChangeHeight: React.PropTypes.func, /** * Since Slidable takes in children, it's really hard to tell when the * children have changed. This prop helps determine when to update the * component */ updateTriggerCondition: React.PropTypes.any, /** The duration the height change should take in ms. Default 200. */ transitionDuration: React.PropTypes.number }; export default class Slidable extends React.Component { constructor() { super(); this.state = { /** {number} - The pixel height of the container. Or `null` to set the height to auto. */ height: null, /** * {React.Children} - reference to the previous children so that we can show them as the next transition occurs. */ prevChildren: null, /** * {number} - The previous pixel height of the container, used to determine when to update. * To minimize forced layouts, we saved the current height as the previous height after * that height's animation. */ prevHeight: 0, /** {boolean} - Whether or not the component is in the process of changing height. */ transitioning: false }; } componentWillReceiveProps(nextProps) { if (this.props.updateTriggerCondition !== nextProps.updateTriggerCondition) { this.setState({ prevChildren: React.cloneWithProps(this.props.children), prevHeight: React.findDOMNode(this.refs.content).offsetHeight, transitioning: false }); } } componentDidUpdate() { var contentHeight = React.findDOMNode(this.refs.content).offsetHeight; // first step in new animation: set height from 'auto' back to a number // so that we have a starting height. if (contentHeight !== this.state.prevHeight && !this.state.transitioning) { this.setState({ height: this.state.prevHeight, transitioning: true }, () => { // we adjust height from old height (ie 0) to new height in next frame so that // the browser doesn't batch style changes--otherwise the animation wouldn't trigger window.requestAnimationFrame(() => { // use setTimeout because transitionend events fire inconsistently across browsers :( window.setTimeout(this.onTransitionEnd, this.props.transitionDuration); this.setState({height: contentHeight}) }); }); } } /** * Called when the height transition ends on the container. It removes the * content from the DOM when the container is done sliding out, and sets * the height to 'auto' after the container is done sliding in. */ onTransitionEnd() { this.setState({ height: null, prevChildren: null }, this.props.onChangeHeight); } render() { var contentStyle = { position: (this.state.height === null) ? 'relative' : 'absolute' }, containerStyle = { height: (this.state.height === null) ? 'auto' : this.state.height, transition: `height ${this.props.transitionDuration}ms` }; return ( <div className='Slidable'> <div ref='container' style={containerStyle} className='slidable-container'> <div ref='content' style={contentStyle} className='slidable-content'> {this.props.children} <div className='previous-children'> {this.state.prevChildren} </div> </div> </div> </div> ); } } Slidable.propTypes = propTypes; // use like: // <Slidable updateTriggerCondition={someCondition}> // <h1>Sift Science Slidables seem super slick!</h1> // </Slidable>
Okay, that’s not so bad! A couple notes:
- It’s really tough to determine when a component’s children have changed, so we use the
updateTriggerCondition
prop to explicitly tell our Slidable component that it should re-calculate the height of its new children. So every time a newupdateTriggerCondition
is changed, we set the current children as our Slidable state’sprevChildren
so that the previous children are still visible during the animation to the new children’s height. - The real magic then happens in
componentDidUpdate
: we calculate the new children’s height, set the height state back to the previous children’s height, wait a frame, and then set the new height state, triggering our sliding animation. - In our production component, Slidable optionally uses a CSS animation (default), a requestAnimationFrame javascript animation, or a FLIP-style animation, but for simplicity, we’ll just consider the CSS animation here.
And here it is in action as the core of our Accordion component, revealing a list of ML signals for a user:
Okay, it’s a gif, so…not the best quality and probably not at 60fps.
This feels pretty React-y—we’re cycling through new children as they are passed in and are updating our height accordingly as state. Previous children are also kept ephemerally as state while the height adjusts. It seems to perform pretty well…or does it?
Devtools gives it to you straight
A timeline of closing the accordion above looks like this:
Huh. That’s a lot of work going on after the initial click. For our users to have a good experience, we should be able to finish all our javascript within 100ms of the click (that’s the R in RAIL), and it looks like we’re not close to meeting that right now. Part of the issue is that this is in development, and we’re spending a lot of time validating propTypes – something we don’t do in production. But what about that animation frame stack trace after the click? And what’s with those forced layouts?
Well, the problem actually lies within our render method:
// ... <div className='Slidable'> <div ref='container' style={containerStyle} className='slidable-container'> <div ref='content' style={contentStyle} className='slidable-content'> {this.props.children} <div className='previous-children'> {this.state.prevChildren} </div> </div> </div> </div> // ...
What I didn’t realize was that by rendering this.props.children
and this.state.prevChildren
in different parts of the DOM, we actually unmount and then remount them as one moves to the other—even though prevChildren
is fleeting and is the exact same as the children
being unmounted. For simple children with no or light work in componentWillUnmount/WillMount/DidMount
, this isn’t very noticeable. But in our example above, each ML signal in the list can be drag-and-dropped and thus is absolutely positioned by running a series of .getBoundingClientRect()
s after mounting. And now that work is being unnecessarily repeated.
The most apparent fix is to keep this.props.children
in the same DOM position even as it becomes this.state.prevChildren
, and simply alter the class of their containers (to adjust z-index and positioning). But to do that, we have to keep two sets of children in state as well as keep track of which one is current or which one is previous:
// ... export default class Slidable extends React.Component { constructor(props) { // ... this.state = { height: null, /** * {React.Children} - We now cycle through two children, children0 and children1, and * update the classname of their container based on which one is currently the previous * children. Initially, children0 is current and children1 is previous, which is null. * After the transition, we set our previous children position to null. */ children0: props.children, children1: null, /** {number} - reference to the which children set are the previous children */ previousChildrenPosition: 1, prevHeight: 0, transitioning: false }; } componentWillReceiveProps(nextProps) { var newChildrenNumber = (this._arePreviousChildrenPositionOne()) ? 1 : 0, newPreviousChildrenPosition = (this._arePreviousChildrenPositionOne()) ? 0 : 1; if (this.props.updateTriggerCondition !== nextProps.updateTriggerCondition) { // if previous children is currently child number 2, replace it with new children // which would be `this.props` after update this.setState({ [`children${newChildrenNumber}`]: nextProps.children, previousChildrenPosition: newPreviousChildrenPosition, prevHeight: React.findDOMNode(this.refs.content).offsetHeight, transitioning: false }); } else { // we have to update children props every time this.setState({ [`children${newPreviousChildrenPosition}`]: nextProps.children }); } } // ...componentDidUpdate stays the same... onTransitionEnd() { var childrenNumberToRemove = (this._arePreviousChildrenPositionOne()) ? 1 : 0; this.setState({ [`children${childrenNumberToRemove}`]: null, height: null }, this.props.onChangeHeight); } _arePreviousChildrenPositionOne() { return this.state.previousChildrenPosition === 1; } render() { // ... return ( <div className='Slidable'> <div ref='container' style={containerStyle} className='slidable-container'> <div ref='content' style={contentStyle} className='slidable-content'> {/* treat each child set identically and only edit the classname for each */} <div className={!this._arePreviousChildrenPositionOne() ? 'previous-children' : ''}> {this.state.children0} </div> <div className={this._arePreviousChildrenPositionOne() ? 'previous-children' : ''}> {this.state.children1} </div> </div> </div> </div> ); } } // ...
Not only is this component tougher to read, it feels much further from the React philosophy of minimizing state, since we are never actually using props.children
as props—-we immediately set them as state. Additionally, and quite confusingly, children0
and children1
have no semantic notion about which is the current or previous set of children. But Browser DGAF that we want to use React best practices in our components.
Browser DGAF.
Here is our new timeline:
No animation frame following the click handler. Beautiful!
Those red triangles, tho
Okay, now how do we also get rid of those forced layouts (denoted by the blocks with the red triangles in the upper right)? This one is easier to debug, because we can see by zooming in that it’s coming directly from the Slidable component itself:
And that points to the first line in our original componentDidUpdate
:
var contentHeight = React.findDOMNode(this.refs.content).offsetHeight;
which queries the height of the new content div on every update, causing a reflow. This brings me to my second piece of DGAF advice:
Browser DGAF about the DOM manipulation you want to put in your reusable components’ componentDidMount/Update
methods.
So be careful! You’re never totally sure of all the ways these components will be used, and they can cause significant lags in performance. This one above was only 5ms, but there are two of them, and we actually have 10 of these ML-signal accordions per page. All of a sudden that’s an extra 100ms. Oops. What happens when the layout takes 20ms instead?
Wrapping that offsetHeight
call in a conditional that only executes when updateTriggerCondition
changes takes care of the second forced layout, and wrapping it in a requestAnimationFrame takes care of the first. Our very pyramid-y componentDidUpdate
now looks like this:
// ... componentDidUpdate(prevProps) { var contentHeight; if (this.props.updateTriggerCondition !== prevProps.updateTriggerCondition) { window.requestAnimationFrame(() => { contentHeight = React.findDOMNode(this.refs.content).offsetHeight; if (contentHeight !== this.state.prevHeight && !this.state.transitioning) { this.setState({ height: this.state.prevHeight, transitioning: true }, () => { window.requestAnimationFrame(() => { window.setTimeout(this.onTransitionEnd, this.props.transitionDuration); this.setState({height: contentHeight}) }); }); } }); } } // ...
Here’s our final timeline—in a production build, just to prove what a difference leaving out prop validation makes:
That click work takes less than 20ms!
By the way, astute readers may see that our frame rate during the animation is well under 60fps. This is partly because, as mentioned above, we’re animating height with CSS, which means heavy repainting (and apparently style updating?) of everything below the expanding div. While not the point of this post, FLIP-ing your height animations would easily make this run 60fps. FLIP-ing is also pretty messy in React, and would make a great follow-up post in this series, so stay tuned!
And remember: Browser DGAF.
Love React? Love frontend performance? We love you. Come fight fraud with us!