Screen Studio is a desktop app that is heavily based on multi-windows architecture:
Those windows have multiple purposes; some are rendered always on top of the other apps, some use macOS background effects, etc.
The app is created with Electron, so I needed an effective way to manage that.
The default way of doing it
The common way of dealing with multi-window Electron apps is simply creating multiple windows by calling a
new BrowserWindowand then loading proper parts of the app there and managing those windows on the Electron side.
While I believe this is by far the most popular way, I also think it is extremely hard to scale the codebase without exploding the complexity this way.
There are many issues with this approach:
- There is no easy way to communicate between the windows. You likely will need the IPC channels you use to send data and messages between windows.
- you’ll not be able to send things like callback functions or custom class instances easily
- you’ll need boilerplate code for every single kind of message you want to send
- The code will be imperative as you write imperative handlers that open and manage several windows. I create the apps using React, which is declarative, and it is way more natural to write the app code this way. I want to express I need a new window and some content inside of it, the same way I create Modal or Popover, except the content of it is displayed in another window.
Crazy idea, React portals
When working at around.co some years ago, some of my co-workers had this idea, and I fell in love with it since then.
The idea is simple:
- In React, you can use Portal to render a given React node into an arbitral DOM node
- I’m pretty sure React authors did mean it mostly to allow devs to render things like modals into the root of the DOM structure.
- But what if you could get a reference to a new window, using
window.open(), and then render stuff into
To implement this concept in Electron, there are several pieces needed
On the React side, we need
ChildWindowcomponent, which will:
- actually, open a new window with all the options Electron will understand
- if options are changed, it will send a proper message to Electron to update the window settings
- if the window is closed “not by react” (by user closing it using native close button), we need to call
onClosedprop so React can stop rendering the window component.
- There is a convention here - “after
onClosedis called, you must stop rendering the component. Otherwise, you’ll be rendering into non-existing window”
This is the pseudo-code of it.
For the sake of this article, it is greatly simplified only to show the main idea.
There are some aspects you might notice:
- I used React context using
WindowContext, which will allow me to get the reference to the parent window out of React hierarchy using
- I created
StyleSheetManager. In Screen Studio, I use
styled-componentswhich is a CSS-in-JS library. By default, it adds proper CSS rules to the
<head>element of the window running the code. It would not work in this case, as we render into another window, and CSS is not shared between parent and child windows.
On the Electron side, we need to do 3 things:
window.openrequests and parse the options and pass them to the
BrowserWindowsoptions object so it has the correct settings instantly. We do that using
- Then we need to start listening for
updateWindowOptionsmessages and apply new settings to the window.
- Then we need to listen to window close requests and send them to React, so it calls
This code is also greatly simplified, as it skips many aspects such as cleaning up, setting up IPC channels, etc which is not the subject of this article.
Here is an example of the screen.studio window used to show the new recording picker:
It uses macOS background effect, is always on top, has no frame, has a shadow, etc:
There are a lot of advantages to using this architecture:
You can simply copy-paste one of
<ChildWindow />components, and boom, you have 2 fully independent windows. You can change the props of one of them, and they now look totally different. If done correctly, it will work with hot-reloading, so you instantly see changes without having to restart the app (note: some
BrowserWindowoptions are not changeable after the window was created)
You can pass any prop, callback function, custom object, etc., into the window content react components. Under the hood, it works exactly like any other React code; it’s just you happen to render some of your React trees into another window
You can escape the imperative nature of creating windows on the Electron side and enjoy the declarative nature of working with React. It means you express what you want, aka. “I now need a window with those props,” and you don’t worry about “what has to happen for this window with those props to be there”
Caveats and quirks
There are MANY caveats and quirks related to this architecture. Those are sometimes very annoying, but I believe they are totally worth it.
As we have multiple windows, we also have multiple Chromium dev inspectors, which we can open. All of those are relevant, but the contents of each are quite unintuitive for a day-to-day web developer:
This is the DOM inspector of the main window. In our case, the main window is actually an invisible window and everything that is visible, is rendered into child windows. As a result, the DOM inspector of the main window is quite empty.
But the console tab of the main window is full of logs related to those child windows.
It is a bit tricky if I render the actual DOM node into the console, eg
console.log(elementRef.current). You will see its preview, but you’ll not be able to click it to inspect it in the DOM inspector, as the inspector will look for this node in the window you’re inspecting, and it is not there.
On the other hand, the inspector of the child window will show all the DOM nodes but no console logs.
Another very unintuitive thing. We, web devs are very used to doing things like
window.addEventListener, which should just work. Except, it will not be in the case of multi-window apps. Some examples:
- You might have global keyboard shortcuts handler attached to keyboard events. Now you need to make sure every window you have is listening to them, and the best would be to have only currently focused window to listen to them
- You might be using external libraries that are “naively” attaching events to windows such as
window.addEventListener("resize"). It is, of course, understandable for library devs to do something like this, but it would simply not work in our case. The code above would be listening for resizing events of the wrong window, as it is not the one given thing rendered into. This is especially painful with DOM-heavy libraries such as rich text editors (I was not able to make most of them work at all)
- Tip: a good practice is to use
domNode.ownerDocument.defaultViewto get the
windowto which a given DOM node belongs.
CSS is not shared between Windows. It is also quite unintuitive for web devs to work in this architecture. We’re used to CSS being ‘singleton’ - you just add it, and it’s there. It’s not the case here. You need to make sure all relevant CSS is included in every window.
If you use regular CSS files, you’ll probably just need to add those to the
headof every window. (I’m not sure if hot reloading will work just fine with it)
If you use CSS-in-JS libraries, you depend on whether or not the authors of the library allow you to set the scope of collecting the styles and setting the target where
<style>rules will be added.
In screen.studio, I use amazing
styled-components, which have
StyleSheetManagerallowing me to do exactly that.
It is very common to use
requestAnimationFrame(or, I should say
window.requestAnimationFrame) to animate in an effective way. The callback passed to RAFs (request animation frame) is precisely timed in a moment which is perfect for a given window to do the painting and similar things.
Except this moment is different for every window, and 99% of libraries obviously use
animatedNode.ownerDocument.defaultView.requestAnimationFrame, which totally ruins all its benefits.
It had a terrible impact on the performance of our WebGL rendering engine, which is using ‘ticker’, which ticks every frame and is the callback where you actually render things.
After many attempts, I ended up monkey-patching this function so it is using
requestAnimationFrameof the currently focused window:
We’re monkey-patching the original RAF, trying to get the currently active window and using RAF from it instead.
There are many other things, and I think I don’t even remember all of those now:
- Preventing the window from being closed (e.g. “You have unsaved changes”) and keeping this in sync with React tree
- Avoiding empty window flicker on both opening it and closing it, e.g. React elements (and DOM nodes) are unmounted, but the window is not closed yet.
When creating larger apps, I believe you should be very careful about keeping the code complexity horizontally scalable.
If you don’t do that, you might semi-unconsciously avoid given patterns, knowing how complex it will be to implement them. E.g., you’ll create a “Settings view” in the same window as the app main window, even though it is not the default UX pattern e.g., on macOS where Settings are almost always in the new window.
ChildWindowarchitecture was a complex task in itself. Implementing it was quite painful. There are also many day-to-day quirks involved, and I’m not able to use some 3rd party libraries.
Even with all of that, I believe it unlocked many possibilities and is keeping developing screen.studio as simple as it was when it had 3 times fewer windows to manage.
I was thinking several times about open-sourcing the architecture I’m using, but it is currently tightly coupled with the app and as a solo dev of screen.studio, I constantly keep prioritizing other things.
I hope you enjoyed. If you want to follow my #buildinpublic journey, follow me on Twitter.