Declarative Layout Debugging in React Native
At NUMI, we love React Native. It’s allowed us to build product quickly using a much better model for describing views than existing mobile paradigms. We’re hardly the first team to notice the benefits of React Native. At the same time, we are realistic about its limitations, especially as an emerging technology.
Layout debugging is a frequent pain that plague engineers building React Native apps. We’ll discuss challenges associated with debugging layouts in React Native and some lightweight tooling we’ve developed to tackle them.
Even though we’re over a decade into mobile, it’s still surprisingly hard to debug your layouts. The react-devtools library helps, as does the inspector. The inspector is great for dissecting individual views, But it’s hard to understand how multiple views interact with each other.
Take for example, an issue we ran into recently trying to style our departments screen. Here’s the layout from the mocks:
And here’s the layout we saw on our app:
Notice how the bottom left borders are being cut off. What’s causing this? Some possible culprits:
- faulty border styling on the container
- text overflow
- image overflow
Normally most developers deal with this problem using inline styles for each view. Here’s a contrived example of what debug styling typically looks like:
After months of debugging our UIs with hardcoded styles, we identified 3 recurring pain points:
1. Writing boilerplate styling to debug layouts.
2. Selecting colors for each view.
3. Removing boilerplate styling afterwards.
We also have to clean up our code after layout debugging to make sure we don’t ship these styles to production. This includes searching for debug styling. This is a tedious search because not all instances of borderColor or borderWidth correspond to debugging styles. And don’t forget to restore the styles we commented out!
Debugging UI Middleware
To ease the tedium of layout debugging, we built a lightweight middleware called withLayoutDebugging.
It wraps around any component class or functional component and enhances its styles with a unique outline color.
The debugLayoutID allows you to declaratively assign an ID to each view that will
Here’s what our view looks like with the debug middleware applied and activated:
We can quickly see that the image was overflowing a problem with overflow on the container being caused by the image (fuscia) rather than the text (green) or the styling container (red) border. Just by adding overflow: hidden to the container, we get the desired styling:
Ripping out the layout debugger code when we’re done is easy. Just search your code for “debugLayout”.
withLayoutDebugging automatically assigns a color to each view, keeping a global map of all the colors it has assigned to debugged views of the course of script execution. At first it may seem that you have as much boilerplate to write with this middleware as you would hard-coding debugger styling for each view as you need it.
But this pattern becomes powerful when coupled with a centralized UI library, where each component can be enhanced at the point of export, allowing it to support layout debugging out of the box.
We strongly recommend building a UI library in collaboration with your designers. Not only will it standardize the look and feel of screens across your app, but it becomes easy to add this kind of middleware to all your components.
A withLayoutDebugging improves on existing React Native layout debugging libraries in 2 dimensions. First, it’s a middleware rather than a simple convenience function. This allows us to make it a “good citizen” in that it will only render its styles in development by checking __DEV__. If you accidentally ship code with debug layouts into production, you can be confident your users won’t see any garish boundary boxes.
Second is its color selection algorithm. Color selection plays a large role in the developer experience of layout debugging. Most layout debugging tools offer random color selection. Not only does this do an unreliable job of maximizing contrast, it creates a jerky developer experience where multiple harsh colors on the screen change with every reload. Tracking subtle layout changes across reloads becomes difficult and developer flow breaks as your eyes adjust to the new colors. We need to ensure:
- Colors are stable for debugLayoutID, including across reloads and hot loads
- Colors contrast between views with different debugLayoutIDs as much as possible
For color stability, withLayoutDebugging keeps a global map of all the views which have requested debug styling across the app. It associates each debug-styled view to a unique color. How is that color selected? We use the hue spectrum from range 0–300, out of 360 degrees (360’s value is effectively the same as 0’s, red) as a spectrum which we slice using a kind of binary tree traversal.
The first view gets hue 0, the second view hue 300, and the third view hue 150. Each subsequent view gets the odd numerators of the next “layer” of the “slice tree” (eg where denominator is power of two), going from the outside in. This ensures that for every additional view we add, the total selection of colors is maximally different. This has the added benefit of optimizing contrast between the most recently received set of views. So if you have views throughout multiple screens, the debug colors of views on the same screen are more likely to contrast (e.g. component types mounted for the first time with withLayoutDebugging).
This withLayoutDebugging could become a toehold into a more general UI middleware, not only to debug layout problems but other common issues when working in React Native such as monitoring wasted renders, render frequencies and so on. react-devtools and the React Profiler do a stellar job analyzing this kind of information in narrow contexts, such as within a single component or a short period of time. But it’s still difficult to track the behavior of selective components in the tree or across a long stretch of time. By providing a middleware that keeps a global map of Views, we can associate all kinds of data with either instances or classes over the course of the execution of an app.