I Can’t Believe It’s Not BrowserView
We host pages using an Electron feature called
webview. You can think of the webview as a specialized
iframe with concessions made for security: it runs out-of-process and lets you avoid polluting the guest page with Node. Although we (and others in the Electron community) have found it to be a spawn point for bugs, until recently it was the only secure way to embed content. Since it’s implemented in Chromium and imported wholesale into Electron, we can’t tinker with it as easily as other APIs. And since it’s used only by Chrome extensions — not the tabs themselves — issues filed against it can languish. Besides renderer crashes during drag & drop and a litany of focus issues, the worst problem we faced was that sometimes, after a webview was hidden, it would not render content the next time it was shown.
Unfortunately for Slack, the webview was the linchpin of the app. There’s a view for each workspace and switching between them is a visibility toggle.
Should be fine, right…?
In hindsight, we spent more time than we should have trying to work around the problem on our end. We considered trade-offs no responsible engineer should face: should users sometimes see a blank page or always have idle CPU usage?
While we were exploring the boundaries of our creativity, the folks at Figma had already abandoned ship and begun on a new strategy for embedding web content. Enter
BrowserView. Their post goes into more detail, but in a nutshell:
- It behaves more like a Chrome tab than the webview does
- It’s used more like a native window than a DOM element
What we mean by that is — unlike the webview — you can’t drop a BrowserView into the DOM and manipulate it with CSS. Similar to top-level windows, these views can only be created from the background Node process. Since our app was written as a set of React components that wrapped the webview, and — being React — those components lived in the DOM, this looked to be a full rewrite. But we needed to make haste, since users were encountering problems on a daily basis. So, how did we manage to pull the rug out from under our furniture without moving it first? Were there any design decisions that helped us out?
It turns out there were, or this would be a very short post. There are three parts of our client stack worth mentioning:
- How we manage Redux stores
- How we manage side-effects / async actions
- How we refactor code rapidly
🔄 Sync About It
Like every webapp written circa 2017, Slack uses Redux. But unlike most Redux apps, Slack sometimes has to synchronize data between stores. That’s because instead of one tidy little process, we’ve got oodles of them.
All Electron apps have a main process that runs Node, and some number of renderer processes that are old-fashioned, card-carrying web pages, complete with a
body, and stifling inconsistencies between Mac and Windows.
“How could you possibly need that many processes?” — every Slack customer, to us
Not only do we have one process per workspace, but we might also have a process for the modal dialog you’re interacting with, a process working quietly in the background, or a process to show you a notification when you’re on a platform that doesn’t support them (here’s to you, Windows 7). All these disparate processes often need access to the same state, so in a leap of faith, they each create a Redux store and set it up with a clever middleware called
electron-redux. It uses Electron’s IPC to bounce actions between processes, like so:
- If an action is dispatched in a renderer process, that renderer ignores it and forwards it to the main process
- If an action is dispatched in the main process, it is handled there first, then replayed in the renderers
This makes the main process’ store the One True Store, and ensures that the others are eventually consistent. With this strategy, there’s no need to shuttle state or get into the serialization game. The only things that cross a process boundary are your actions, and hopefully those are already FSA-compliant. For us this means that our Redux code is virtually process-agnostic: the actions can come from any process; the reducers can live in any process; the work gets done all the same.
🚌 It Was Super (Side-)Effective!
One oft-expressed critique of Redux is that asynchronous actions — and their side-effects — are a bit of an afterthought. There are dozens of solutions out there and since none of them are included in Redux, it’s up to you to choose what best fits your app. Slack’s desktop app preaches the gospel of
redux-observable was a natural fit for us. If you’re acquainted with Observables, you may have heard the mantra Everything is a Stream. And lo, what is a store but a stream of actions?
redux-observable, you’re given that stream of actions as an input, and you write “epics” (like a
saga but more Homeric) that manipulate it. It’s worth noting that the values emitted by this stream are the actions, not the reduced state. Here’s a contrived example, where we show a sign-in
BrowserWindow on startup, if we’re not signed into any workspaces:
This lets us compose sequences of actions, which is sometimes more valuable than looking at the byproduct of the actions (the state). Any objects returned from the stream are automatically dispatched as actions, but nothing says you have to emit an action. Oftentimes we just want to call an Electron API. Redux refers to this as a “side-effect,” but I refer to it as “doing my job.” It becomes really powerful when combined with a Redux store in each process, because now we can kickoff main process side-effects from a renderer and vice-versa, in a decoupled way. It’s similar to an event bus or pub-sub, but across Chromium processes.
How about a more involved example — what if we needed to keep a running total of time spent in different workspaces, to determine which ones were most and least used? This could grow into a mess of timeouts and booleans, but since the stream of actions is an
Observable, let’s leverage the suite of operators that come with it:
const is change.
rxjs-spy makes debugging (i.e. logging and visualizing) streams as simple as adding a
tag. A tagged stream can be monitored, paused, and replayed, right from the console. Testing Observables is a delight too, with the help of the utilities in
RxSandbox (by our own OJ Kwon):
What we’re doing here is creating a mock stream of actions, and providing it as the input to the epic. We define the stream with a marble string, which looks odd but is quite simple: any letter represents an action, and a
- represents 10ms of virtual time. We can make assertions about the action we expect from the epic, and there’s no need for
async or callbacks here —
flushing the scheduler runs the clock to completion.
🎯 Refactor feat. TypeScript (Club Mix)
Of course there’s an upfront cost — one that we had already paid — but that investment saw major returns throughout this project. Much of the work involved rearranging existing features, and a type-checker helped us avoid what would have typically been a long tail of bug fixes. It also makes working with Observables more natural. Never again will you ponder over the output of a
flatMap (do I get the array or just one item?), the argument order for a
reduce, or the name of that one operator that’s like
throttle but starts with a D… (it’s
💪 No Main (Process), No Gain
Sometimes, when you haven’t used a workspace in a long time, we take the same approach as Chrome and unload that “tab’s”
webContents to save memory. We still need to show notifications and badges for that workspace, so previously we would navigate to a slim-Slack page that responds to a handful of web-socket messages. Once selected, we stealthily disposed of the intermediate page and spun up the full webapp in its place.
Somewhere along the way, we had a realization: why not run all of the slim-Slacks in the main process, instead of each having their own page (and incurring the overhead of a renderer process)? This dovetailed nicely with our effort to make Redux actions process-agnostic: we could just as easily dispatch actions from the main process to update badges or show notifications. All we needed to do was connect to the web-socket from Node, something our colleagues down the hall knew a thing or two about.
With this change, customers signed in to a lot of workspaces will see a drop in both memory usage and number of processes:
So, to wrap it all up: we rewrote most of our Electron app to move from the janky
webview to the new-fangled
BrowserView. We managed to do it in a relatively short timeframe, thanks to a combination of elbow grease and reasonable choices in our client stack, like:
- Redux +
redux-electron: Means we don’t have to think about where reducers live or where actions are dispatched
- Rx +
redux-observable: Turns our
storeinto an interprocess event bus with functional superpowers
- TypeScript: Helps us refactor code quickly and correctly
While it can be tempting to scrap a codebase and go back to green(field) pastures, particularly when faced with a mountain of bugs, this rarely works out for customers. When all was said and done, we reused more than 70% of our code, fixed most, if not all, of the webview’s shortcomings, doubled our test coverage, and substantially reduced memory usage. We think it’ll show in the user experience, but you, dear reader, can be the judge of that. ✌
If any of this sounds interesting and/or terrifying to you, come work with us!