Learning to get along: How React & CodeMirror can communicate and even perhaps become friends
The new Firefox Debugger UI is a React + Redux App. The architecture lends itself really well. For instance, when the debugger pauses the redux state receives the call stack, which is immediately rendered by the Frames component.
The one-way data flow pattern is well established and works really well in every case, but one, CodeMirror. This in not a trivial detail either, the debugger is in many ways an editor on steroids. The debugger asks the editor to do a lot: show breakpoints, inline previews, outline search matches, and much more. When React and CodeMirror don’t play nicely, it is like inviting your divorced parents who you individually love to a party. It’s not going to end well!
Unfortunately, I think the reason why CodeMirror and React don’t natively get along is the reason why CodeMirror is so great. It is easy to dismiss CodeMirror as a wrapper for syntax highlighting, but it is much more than that. CodeMirror provides a simple API for setting text, but doesn’t ask you to worry about how it will buffer large documents. CodeMirror makes it easy to add gutter markers and inline bookmarks which will appear when a line is shown, but doesn’t ask you to worry about the lifecycle. It has document management so that you can keep many editors in memory without having to re-parse the text or lose the scroll state. It handles hundreds of edge cases, while keeping the outer API approachable. It’s great!
The one catch is that it keeps a lot of state and has its own render cycle. These two points are the source of conflict between React and CodeMirror. They both want to keep the scroll location, the highlighted line data, gutter markers, etc. When our team initially built the conditional breakpoint panel, we wanted the React editor to be able to render the panel, but quickly learned that for good reasons CodeMirror would only accept a pre-rendered panel. Why? Well, only CodeMirror knew when to show the panel when the line was visible. Also, how wide the editor was and therefore how wide the panel should be. Not to mention, when to resize the panel when the editor would be resized!
The conditional breakpoint panel, was a relatively minor scuffle. Showing the debugger Preview Popup was basically an armed standoff. Why, well CodeMirror is happy to show a line widget relative to a token, but insists that it receives a DOM element. We had already written an object Preview component that knows how to format any type of variable (objects, arrays, …) and how to expand properties. We did not want to change this!
Perhaps the most interesting challenge has been showing breakpoints in the gutter. Showing a breakpoint is a two step process: first inserting a breakpoint element in the gutter and second adding a couple of classes to the gutter and line. The CodeMirror approach is to loop through the list of breakpoints and add them consecutively.
editor.setGutterMarker(line, "breakpoints", makeMarker(bp.disabled));
editor.addLineClass(line, "line", "new-breakpoint");
How do you update the breakpoints, when a breakpoint is removed or the editor shows a new source? Well write more manual update code. The manual updates don’t take advantage of the React renderer and are very easy to fall out of sync. It’s this kind of code that makes you wonder why you ever re-wrote the Debugger in React! If it’s the same stateful / imperative code as before, what was the point?
Fortunately, there is a really elegant solution that helps React and CodeMirror get along. The solution lets CodeMirror be CodeMirror and have incredibly simple APIs. The solution also plays to React’s strengths and lets the renderer coordinate the updates. Simply put, it lets you treat CodeMirror like the DOM: a beautiful, but complex stateful render target!
How does it work? In the case of breakpoints, we wrote an Editor function renderBreakpoints
that received a list of breakpoints and rendered each breakpoint. The brilliance of this approach is that the React Reconciler notices when there is a new breakpoint or when a breakpoint is removed and calls the appropriate Breakpoint lifecycle method. Within the Breakpoint component, we have a componentMount
and componentWillUnMount
function that handles the imperative CodeMirror commands.
renderBreakpoints(breakpoints, editor) {
return breakpoints.map(bp => Breakpoint({
key: bp.location,
breakpoint: bp,
editor
}));
}
componentDidMount() {
editor.setGutterMarker(line, "breakpoints", makeMarker(bp.disabled));
editor.addLineClass(line, "line", "new-breakpoint");
}
We’ve applied the CodeMirror component pattern in several other cases and found that it has worked well. The React renderer is happy to create and destroy components. CodeMirror is happy to receive create and destroy commands from the component. The two are talking again, who knows what will come next, maybe they’ll dance!
Here’s a list of the Debugger’s many React + CodeMirror features.