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 array.push('oops')
. Since our app was not originally built with immutability in mind, these mutations are scattered throughout our codebase. With so many different ways of mutating objects, how can we possibly find all the mutations in half a million lines of JavaScript code?
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: defineProperty
, deleteProperty
, set
, and 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
— ifshouldIgnore(obj)
returns true, thenobj
will 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
makeSentinel
. - For the route we want to purify, configure the
mutationHandler
to 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
mutationHandler
to 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.
To fix the mutations, we used a combination of array spreading, object spreading, and the immutability-helper library.
Success! 🎉
Limitations
- Unfortunately it isn’t possible to polyfill the
Proxy
object. For browsers that do not supportProxy
,makeSentinel
simply 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.
Gotchas
- 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
File
that is wrapped by aProxy
toFormData
does not work properly (tested on Mac Chrome 60.0.3112.113). We got around this issue by adding a check inshouldIgnore
to ignoreFile
instances.
Conclusion
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
Interested in solving these types of problems? Follow us here or on Twitter to learn more about interesting problems in the world of freight, or if you’re ready to take the next step, we’re hiring (check out our current openings)!