Optimizing React Rendering (Part 2)
Fall of the Mutations — How we used our new mutation-sentinel library to rid our codebase of mutations.
In part 1 we discovered that optimizing Flexport’s React app with PureComponents was not as easy as it seemed. Since then, we’ve been able to purify our most complex page, bringing the wasted rendering time from 6.5 seconds down to 2 (and eventually 0). The first major hurdle we had to overcome was object mutation, which can cause stale rendering bugs when mixed with PureComponents.
The search for mutations
Mutations occur in various forms, from simple assignments
obj.value = 'oops' to destructive methods
Fortunately for us, ES6 introduced a powerful new feature: the
Proxy object. A
Proxy transparently wraps your object and allows you to “define custom behavior for fundamental operations”. The operations that we are interested in are the ones that mutate an object:
setPrototypeOf. By intercepting these operations, we can detect and report mutations at runtime. Here’s a simplified implementation:
The wrapped object will behave exactly the same as the original object, except it has the power to detect mutations. This also works for arrays:
The best part about this approach is that the stack trace leads us to the exact line in our code where the mutation occurs. 😲
Dawn of the Mutation Sentinel
We productionized the simple code above, added a few bells and whistles, and open sourced it here: mutation-sentinel.
The library provides a
makeSentinel function that you use to wrap an object with a sentinel (similar to the
wrap function above). The returned sentinel has the ability to detect when it, or any of its nested objects, are mutated.
The library also provides you the ability to globally configure:
mutationHandler— called whenever a mutation is detected.
shouldIgnore(obj)returns true, then
objwill not be wrapped with a sentinel.
Using mutation-sentinel in practice
Due to the size of Flexport’s app, we decided to purify our components incrementally. Since the sentinels can be reconfigured dynamically, we enabled and disabled the
mutationHandler on a route by route basis.
Here is the general approach that we took:
- Wrap all of our flux store records with
- For the route we want to purify, configure the
mutationHandlerto log mutations to the console in development, and Sentry (our error reporting service) in production.
- Deploy sentinels to production and fix the mutations as they are detected.
- Once all the mutations are fixed, change
mutationHandlerto throw in development and no-op in production.
Our configuration looked something like:
Sentry has been extremely useful for us, especially in this use case. With their support for source maps, we were able to use the stack trace to pinpoint exactly where the mutation was occurring.
- Unfortunately it isn’t possible to polyfill the
Proxyobject. For browsers that do not support
makeSentinelsimply returns the original object and no mutation detection occurs.
- Since the detection happens at runtime, sentinels can’t find mutations in code that isn’t executed. We left the detection on in production for about a month to catch as many mutations as possible.
- It wasn’t feasible to wrap every object in our app with a sentinel, which means the unwrapped objects are still susceptible to undetected mutations.
- While a wrapped object behaves the same as the original object, it is not equal to it
makeSentinel(myObj) !== myObj.
- Shallow copies of sentinels are not themselves sentinels…but the nested objects of the shallow copy are sentinels.
- Appending a
Filethat is wrapped by a
FormDatadoes not work properly (tested on Mac Chrome 60.0.3112.113). We got around this issue by adding a check in
Equipped with our army of sentinels, we were able to remove a large majority of mutations from our app very quickly. However, the limitations above show that there are some mutations that sentinels cannot detect. We’ll cover how to catch the remaining mutations in a future post.
PS: static analysis
We also explored using static analysis to detect mutations via a custom eslint rule similar to eslint-plugin-immutable. However, this approach didn’t allow us to easily remove mutations route by route, and trying to fix all the mutations in our app at once was impractical.
More from this series
We migrated our web app to React almost 3 years ago, and since React performs extremely well out of the box, optimizing…flexport.engineering