Browser DGAF (that you use React)

SiftMarch 16, 2016

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:

  1. 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 new updateTriggerCondition is changed, we set the current children as our Slidable state’s prevChildren so that the previous children are still visible during the animation to the new children’s height.
  2. 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.
  3. 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.
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:

pre-slidable-update-dev

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:

post-slidable-update

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:

layout-closeup

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:

post-slidable-update-prod

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!

Author