From 871ae6066a1085d395f00b7c631b3977a0d5a0d2 Mon Sep 17 00:00:00 2001 From: Maxwell Leiter <8675906+MaxLeiter@users.noreply.github.com> Date: Thu, 21 May 2026 02:21:35 +0000 Subject: [PATCH] fix(main): defer hide() in hideOnClose handler past windowShouldClose: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hideOnClose close handler does event.preventDefault() then browserWindow.hide() synchronously. On macOS, the red traffic-light button fires the JS close event from inside AppKit's windowShouldClose: delegate; calling hide() (orderOut:) synchronously within that callback trips app termination — the consumer's whole app quits, not just the pooled window. Cmd+W and BrowserWindow.close() route through NativeWindowMac::Close() (no delegate) and never hit this, but the one-tick defer is harmless there too, so it's not Darwin-gated. Reported and verified in anthropics/apps#43100. Co-Authored-By: Claude Opus 4.7 --- src/main/WindowInstance.ts | 15 ++++++++-- tests/unit/main-process.test.ts | 49 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/main/WindowInstance.ts b/src/main/WindowInstance.ts index ff6ea24..ff152a4 100644 --- a/src/main/WindowInstance.ts +++ b/src/main/WindowInstance.ts @@ -232,9 +232,18 @@ export class WindowInstance { if (this.hideOnClose && !this._forceClosing) { try { event.preventDefault(); - if (this.browserWindow && !this.browserWindow.isDestroyed()) { - this.browserWindow.hide(); - } + // Defer hide() one tick. The macOS red traffic-light fires `close` + // from inside the AppKit `windowShouldClose:` delegate; calling + // `orderOut:` (which `hide()` does) synchronously within that + // callback trips app termination — the consumer's whole app quits. + // Cmd+W and `BrowserWindow.close()` route through + // `NativeWindowMac::Close()` (no delegate) and never hit this, but + // the defer is harmless there too, so it's not Darwin-gated. + setImmediate(() => { + if (this.browserWindow && !this.browserWindow.isDestroyed()) { + this.browserWindow.hide(); + } + }); this.onEvent({ type: WindowEventType.UserCloseRequested, id: this.id, diff --git a/tests/unit/main-process.test.ts b/tests/unit/main-process.test.ts index 09e9960..fd47433 100644 --- a/tests/unit/main-process.test.ts +++ b/tests/unit/main-process.test.ts @@ -790,6 +790,55 @@ describe("WindowInstance event emission", () => { expect.objectContaining({ type: "userCloseRequested" }), ); }); + + // Regression: macOS red traffic-light fires `close` from inside AppKit's + // windowShouldClose: delegate. Calling hide() (orderOut:) synchronously + // within that callback trips app termination. The hideOnClose handler must + // preventDefault() synchronously but defer hide() past the delegate. + it("hideOnClose: defers hide() past the close event, preventDefault stays synchronous", async () => { + const onEvent = vi.fn(); + const bw = createMockBrowserWindow(); + new WindowInstance({ + id: "pool-win", + browserWindow: bw as unknown as import("electron").BrowserWindow, + props: {}, + onEvent, + hideOnClose: true, + }); + + const preventDefault = vi.fn(); + bw.emit("close", { preventDefault }); + + // Synchronous: close is vetoed and the renderer is notified immediately. + expect(preventDefault).toHaveBeenCalled(); + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: "userCloseRequested", id: "pool-win" }), + ); + // hide() must NOT run inside the close dispatch — that's the macOS bug. + expect(bw.hide).not.toHaveBeenCalled(); + + // Next event-loop tick: hide() runs (windowShouldClose: has returned NO). + await new Promise((resolve) => setImmediate(resolve)); + expect(bw.hide).toHaveBeenCalledTimes(1); + }); + + it("hideOnClose: deferred hide() is a no-op if the window was destroyed in between", async () => { + const onEvent = vi.fn(); + const bw = createMockBrowserWindow(); + new WindowInstance({ + id: "pool-win", + browserWindow: bw as unknown as import("electron").BrowserWindow, + props: {}, + onEvent, + hideOnClose: true, + }); + + bw.emit("close", { preventDefault: vi.fn() }); + bw.isDestroyed.mockReturnValue(true); + + await new Promise((resolve) => setImmediate(resolve)); + expect(bw.hide).not.toHaveBeenCalled(); + }); }); // ---------------------------------------------------------------------------