Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/main/WindowInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/main-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<void>((resolve) => setImmediate(resolve));
expect(bw.hide).not.toHaveBeenCalled();
});
});

// ---------------------------------------------------------------------------
Expand Down