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. 😲

Console log from the example above

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.

— called whenever a mutation is detected. shouldIgnore — if shouldIgnore(obj) returns true, then obj 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 support Proxy , makeSentinel simply returns the original object and no mutation detection occurs.

object. For browsers that do not support , 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 a Proxy to FormData does not work properly (tested on Mac Chrome 60.0.3112.113). We got around this issue by adding a check in shouldIgnore to ignore File 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.