This ends up re-rendering a <BreadcrumbList>
, two<ListingTitles>
, and a <SummaryIconRow>
when it updates. However, none of these have any differences, so we can make this operation significantly cheaper by using React.PureComponent
on these three components. This was about as straightforward as changing this:
export default class SummaryIconRow extends React.Component {
...
}Copy the code
into this:
export default class SummaryIconRow extends React.PureComponent {
...
}Copy the code
Up next, we can see that <BookIt>
also goes through a re-render on the initial pageload. According to the flame 🔥 chart, most of the time is spent rendering <GuestPickerTrigger>
and <GuestCountFilter>
.
The funny thing here is that these components aren’t even visible 👻 unless the guest input is focused.
The fix for this is to not render these components when they are not needed. This speeds up the initial render as well 🐎 If we go a little further and drop in some more PureComponents, we can make this area even faster.
Scrolling around
While doing some work to modernize a smooth scrolling animation we sometimes use on the listing page, I noticed the page felt very janky when scrolling. 📜 People usually get an uncomfortable and unsatisfying feeling when Animations aren’t hitting a smooth 60 FPS (Frames Per Second), Scrolling is a special kind of animation that is directly connected to your finger movements, so it is even more sensitive to bad performance than other animations.
After a little profiling, I discovered that we were doing a lot of unnecessary re-rendering of React components inside our scroll event handlers! This is what really bad jank looks like:
I was able to resolve most of this problem by converting three components in these trees to use React.PureComponent
: <Amenity>
, <BookItPriceHeader>
, and <StickyNavigationController>
. This dramatically reduced the cost of these re-renders. While we aren’t quite at 60 fps (Frames Per Second) yet, we are much closer:
However, there is still more opportunity to improve. Zooming 🚗 into the flame chart a little, we can see that we still spend a lot of time re-rendering <StickyNavigationController>
. And, if we look down component stack, we notice that there are four similar looking chunks of this:
The
is the part of the listing page that sticks to the top of the viewport. As you scroll between sections, It highlights the section that you are currently inside of. Each of the chunks in the flame 🚒 chart pile to one of the four links that we render in the sticky navigation. And, when we scroll between sections, we highlight a different link, so some of it needs to re-render. Here’s what it looks like in the browser.
Now, I noticed that we have four links here, but only two change appearance when transitioning between sections. But still, in our flame chart, we see that all four links re-render every time. This was happening because our <NavigationAnchors>
component was creating a new function in render and passing it down to <NavigationAnchor>
as a prop every time, which de-optimizes pure components.
const anchors = React.Children.map(children, (child, index) => { return React.cloneElement(child, { selected: activeAnchorIndex === index, onPress(event) { onAnchorPress(index, event); }}); });Copy the code
We can fix this by ensuring that the <NavigationAnchor>
always receives the same function every time it is rendered by <NavigationAnchors>
:
const anchors = React.Children.map(children, (child, index) => {
return React.cloneElement(child, {
selected: activeAnchorIndex === index,
index,
onPress: this.handlePress,
});
});Copy the code
And then in <NavigationAnchor>
:
class NavigationAnchor extends React.Component {
constructor(props) {
super(props);
this.handlePress = this.handlePress.bind(this);
}Copy the code
handlePress(event) {
this.props.onPress(this.props.index, event);
}Copy the code
render() { ... }}Copy the code
Profiling after this change, we see that only two links are re-rendered! That’s half 🌗 the work! And, if we use more than four links here, the amount of work that needs to be done won’t increase much anymore.
Dounan Shi at Flexport has been working on Reflective Bind, which uses a Babel plugin to perform this type of optimization for you. It’s still pretty early so it might not be ready for production just yet, but I’m pretty excited about the possibilities here.
Looking down at the Main panel in the Performance recording, I notice that we have a very suspicious-looking _handleScroll
block that eats up 19ms on every scroll event. Since we only have 16ms if we want to hit 60 fps, this is way too much. 🌯
The culprit seems to be somewhere inside of onLeaveWithTracking
. Through some code searching, I track this down to the <EngagementWrapper>
. And looking a little closer at these call stacks, I notice that most of the time spent is actually inside of React’s setState
, but the weird thing is that we aren’t actually seeing any re-renders happening here. Hmm…
Digging into <EngagementWrapper>
a little more, I notice that we are using React state 🗺 to track some information on the instance.
this.state = { inViewport: false };Copy the code
However, we never use this state in the render path at all and never need these state changes to cause re-renders, So we end up paying an extra cost. 💸 Converting all of these uses of React state to be simple instance variables really helps us speed up these scrolling animations.
this.inViewport = false;Copy the code
I also noticed that the <AboutThisListingContainer>
was re-rendering, which caused an expensive 💰 and unnecessary re-render of the <Amenities>
component.
This ended up being partly caused by our withExperiments higher-order component which we use to help us run experiments. This HOC was written in a way that it always passes down a newly created object as a prop to the component it Wraps — deoptimizing anything in its path.
render() {
...Copy the code
const finalExperiments = { ... experiments, ... this.state.experiments, };Copy the code
return ( <WrappedComponent {... otherProps} experiments={finalExperiments} /> ); }Copy the code
I fixed this by bringing in reselect for this work, which memoizes the previous result so that it will remain referentially equal between successive renders.
const getExperiments = createSelector( ({ experimentsFromProps }) => experimentsFromProps, ({ experimentsFromState }) => experimentsFromState, (experimentsFromProps, experimentsFromState) => ({ ... experimentsFromProps, ... experimentsFromState, }), );Copy the code
.Copy the code
render() {
...Copy the code
const finalExperiments = getExperiments({
experimentsFromProps: experiments,
experimentsFromState: this.state.experiments,
});Copy the code
return ( <WrappedComponent {... otherProps} experiments={finalExperiments} /> ); }Copy the code
The second part of the problem was similar. In this code path we were using a function called getFilteredAmenities which took an array as its first argument and returned a filtered version of that array, similar to:
function getFilteredAmenities(amenities) {
return amenities.filter(shouldDisplayAmenity);
}Copy the code
Although this looks innocent enough, this will create a new instance of the array every time it is run, even if it produces the same result, which will deoptimize any pure components receiving this array as a prop. I fixed this as well by bringing in reselect To memoize the filtering. I don’t have a flame chart for this one because the entire re-render completely lights up! 👻
There’s probably still some more opportunity here (e.g. CSS containment), but scrolling performance is already looking much better!
Clicking on things
Interacting with the page a little more, I felt some Noticeable lag ✈️ when editing on the “Helpful” button on a review.
My hunch was that clicking this button was causing all of the reviews on the page to be re-rendered. Looking at the flame chart, I wasn’t too far off: