Notifio watches rental listing search pages from your own machine and alerts you when a new listing appears. That job only works if the app is running when you are not looking at it, so the window is not the app: the window is a view onto a process that lives in the tray.
main.ts is 396 lines and nine of them register a lifecycle handler. I sat down to read all nine together for the first time, and three of them are more interesting than I remembered.
The census
app.on('ready') Menu.setApplicationMenu(null)
mainWindow.once('ready-to-show') show the window
mainWindow.on('show') reload the renderer
mainWindow.on('close') cancel it and hide instead
mainWindow.on('closed') null the reference
app.on('second-instance') focus the window we already have
app.on('window-all-closed') (empty)
app.on('before-quit') set a flag, close browser contexts
app.on('activate') show or recreate
Plus six show() calls, two hide() calls and one restore() scattered across the tray menu, the tray click handler and the single-instance path.
The empty one is the feature
app.on('window-all-closed', () => {
// Keep app running in tray
});
A function with no body and a comment. Delete it and you have not simplified anything, you have changed the product: Electron's default behaviour when the last window closes is to quit the app on Windows and Linux. Registering a listener, any listener, suppresses that. The empty body is the implementation of "keeps running in the tray", and the only thing distinguishing it from dead code is the comment.
It pairs with the handler above it:
mainWindow.on('close', (event) => {
if (!(app as any).isQuitting) {
event.preventDefault();
mainWindow?.hide();
}
});
So the close button does not close. Which raises the question of how the app ever exits, and the answer is a property that does not exist on the type:
// tray menu, "Quit"
(app as any).isQuitting = true;
app.quit();
// before-quit
(app as any).isQuitting = true;
Three occurrences of isQuitting in the codebase: two writes, one read, every one of them through as any. It is a string key on a global object, and if any of those three were misspelled the failure would be that Quit stops working. Not an error, not a log line: the window hides, the process stays, and the only way out is the OS task manager. That is a nasty failure mode to leave guarded by nothing but spelling.
It is also unnecessary, which is the annoying part. The flag is module state, it only needs to be visible inside this file, and a local gets you the compiler back:
let isQuitting = false;
The as any version presumably happened because before-quit and the tray menu felt far apart in the file, and app was already in scope in both. They are 60 lines apart, in the same module.
Showing the window throws the UI away
// Reload the UI when the window is re-shown after being hidden to prevent
// black screen and stale state (e.g. searches disappearing)
mainWindow.on('show', () => {
mainWindow?.webContents.reload();
});
This is a real fix for a real bug and I am not removing it. But follow the events on a cold start:
-
createWindow()callsloadURL('http://localhost:3000')withshow: false. - The renderer loads, mounts, fires its bootstrap requests, connects its event stream, paints.
-
ready-to-showfires, and its handler callsshow(). -
show()emitsshow, and theshowhandler callsreload(). - The renderer loads, mounts, fires its bootstrap requests, connects its event stream, paints. Again.
Every launch builds the UI twice. The bootstrap is four requests in parallel plus an EventSource:
const [sitesRes, statusRes, findsRes, configRes] = await Promise.all([
getSites(), getMonitorStatus(), getFinds(), getConfig(),
]);
So ten round trips per launch instead of five. They are all to localhost in the same process, so the cost is milliseconds and this has never shown up as a symptom, which is exactly why it survived: nothing about it is slow enough to notice, and the one-line handler that causes it is documented as fixing something else.
The fix is to not treat the first show as a re-show, which is what once versus on is for:
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
// Only reload on subsequent shows, when the renderer may be stale.
mainWindow?.on('show', () => mainWindow?.webContents.reload());
});
Registering the show handler from inside ready-to-show is a little sneaky, so it needs the comment. The alternative is a let shown = false guard, which is more obvious and one line longer, and I will probably take that instead on the grounds that obvious beats clever in a lifecycle file.
Hiding and minimising are not the same thing, and nothing says so
The reload is bound to show, so it happens on the paths that call show(): the tray icon, the tray menu's Show Window, a second launch of the app, and the macOS dock activate. It does not happen when a minimised window is restored, because restore() emits restore, not show.
app.on('second-instance', () => {
if (mainWindow) {
if (!mainWindow.isVisible()) mainWindow.show();
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
That handler, the only place both are called, treats them as two different states it needs to recover from, correctly. Everywhere else there is an asymmetry a user cannot see: minimise the window and your UI keeps running with its scroll position and its open modal; close it to the tray and you get a brand new one. The activity log you had just cleared comes back, which is a bug I wrote up separately and which is really this handler meeting a renderer-local action.
Which is why none of the state lives in the window
The upside of all this is that it forced the architecture into the right shape. If the renderer can be destroyed and rebuilt at any moment by a tray click, then nothing may be kept there, and the main process has to be the single owner of every fact the UI displays. That is the module I wrote about here: per-search live state, owned by the monitor, in memory, streamed to whatever window happens to be open, with the same snapshot serving the initial fetch and the live stream.
The comment on it is the whole design in two sentences:
/**
* Anything the user missed while the window was hidden was simply lost, and the
* window reloads itself every time it is re-shown, so "lost" was the normal case.
*/
A disposable renderer is a constraint that makes you do the correct thing for a monitoring app anyway. I would rather have arrived at it deliberately than by way of a black-screen bug, but the destination is the same.
One handler that does nothing, and not on purpose
While I was in there:
function stopServer(): void {
console.log('[main] Server shutdown handled by process exit');
}
Called from before-quit. It is a log line with a function around it. Unlike window-all-closed, nothing depends on it existing. That one is just deletable.
Takeaways
- An empty handler can be load-bearing. If a no-op function is the implementation of a feature, the comment explaining that is the most important line in the file, because every tool you own will call it dead code.
-
(app as any).someFlagacross a module boundary is a spelling test with a hung process as the penalty. Module-localletand the compiler, every time. -
on('show')includes the first show. Reloading there costs you a double startup that is too fast to ever notice and too silly to leave in. - Writing the lifecycle handlers out as a flat list, all nine in one place, found three things that reading the file top to bottom had not in a year. The list is the tool, not the reading.
The app is on the download page, the help page covers the tray behaviour from the user's side, and the alerts hub lists the sites it watches while it is sitting there.
Top comments (0)