fix(main): schedule the relaunch exit pair once per process

Each app.relaunch() registration starts another replacement instance after
exit, and renderer-side click guards cannot see each other across surfaces
(error-boundary Restart, hydration toast, settings panes). Gate the
schedule in the app:relaunch handler so concurrent or repeated invokes
commit exactly one relaunch+exit.
This commit is contained in:
Brennan Benson
2026-08-16 18:32:02 -07:00
parent d79669b6c9
commit eed2c14899
2 changed files with 27 additions and 0 deletions
+17
View File
@@ -228,6 +228,23 @@ describe('registerAppHandlers', () => {
expect(appExitMock).toHaveBeenCalledWith(0)
})
it('schedules a single relaunch exit no matter how many surfaces invoke it', async () => {
registerAppHandlers({} as never, {})
// Two surfaces (error-boundary Restart, hydration toast) can race a relaunch;
// each app.relaunch() registration would start another replacement instance.
const first = Promise.resolve(handlers.get('app:relaunch')?.(null))
const second = Promise.resolve(handlers.get('app:relaunch')?.(null))
await first
await second
const third = Promise.resolve(handlers.get('app:relaunch')?.(null))
await third
await vi.advanceTimersByTimeAsync(150)
expect(relaunchAppMock).toHaveBeenCalledTimes(1)
expect(appExitMock).toHaveBeenCalledTimes(1)
})
it('marks restart as expected shutdown before quitting through the normal pipeline', async () => {
const onBeforeRelaunch = vi.fn()
registerAppHandlers({} as never, { onBeforeRelaunch })
+10
View File
@@ -288,9 +288,19 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
ipcMain.handle('app:getKeyboardLayoutSnapshot', () => readMacKeyboardLayoutSnapshot())
// Every app.relaunch() registration starts another replacement instance after
// exit, and renderer-side click guards cannot see each other across surfaces
// (error-boundary Restart, hydration toast, settings panes). Once the exit
// pair is scheduled the process is committed, so later invokes must not
// schedule a second one.
let relaunchExitScheduled = false
ipcMain.handle('app:relaunch', async () => {
// Why: brief delay lets the renderer paint "Restarting…" before the window tears down.
await runBeforeRelaunchCleanup(options.onBeforeRelaunch)
if (relaunchExitScheduled) {
return
}
relaunchExitScheduled = true
setTimeout(() => {
// Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon.
destroySystemTray()