From e20c23a89eeddfb07a8d491b1f9ccafdea89b0f8 Mon Sep 17 00:00:00 2001 From: Harshit Singh Bhandari Date: Mon, 22 Jun 2026 20:55:01 +0530 Subject: [PATCH] fix(desktop): don't tear down browser views after window destroyed mainWindow.on('closed') -> browserViewHost.dispose() ran destroy(), which touched contentView/child WebContentsViews already torn down by Electron, crashing the main process with 'Object has been destroyed'. Skip the window ops when the window reports destroyed. Co-Authored-By: Claude Opus 4.8 --- frontend/src/main/browser-view-host.test.ts | 60 +++++++++++++++++++++ frontend/src/main/browser-view-host.ts | 5 ++ 2 files changed, 65 insertions(+) diff --git a/frontend/src/main/browser-view-host.test.ts b/frontend/src/main/browser-view-host.test.ts index d1e70db20..e6b2a4227 100644 --- a/frontend/src/main/browser-view-host.test.ts +++ b/frontend/src/main/browser-view-host.test.ts @@ -105,6 +105,66 @@ describe("browser:clear", () => { }); }); +describe("dispose after the window is destroyed", () => { + it("does not touch contentView/views once the window reports destroyed", async () => { + const handlers = new Map(); + const view = { + webContents: { + canGoBack: () => false, + canGoForward: () => false, + getTitle: () => "", + getURL: () => "", + goBack: () => undefined, + goForward: () => undefined, + isLoading: () => false, + loadURL: async () => undefined, + on: () => undefined, + reload: () => undefined, + send: () => undefined, + setWindowOpenHandler: () => undefined, + stop: () => undefined, + // Real Electron throws "Object has been destroyed" here after close. + close: vi.fn(() => { + throw new Error("Object has been destroyed"); + }), + }, + setBounds: () => undefined, + setVisible: () => undefined, + }; + let destroyed = false; + const removeChildView = vi.fn(() => { + throw new Error("Object has been destroyed"); + }); + const host = createBrowserViewHost({ + mainWindow: { + contentView: { addChildView: () => undefined, removeChildView }, + getContentBounds: () => ({ x: 0, y: 0, width: 800, height: 600 }), + webContents: { id: 1, send: () => undefined }, + isDestroyed: () => destroyed, + } as never, + ipcMain: { + handle: (channel: string, fn: InvokeHandler) => handlers.set(channel, fn), + on: () => undefined, + removeHandler: () => undefined, + off: () => undefined, + } as never, + shell: { openExternal: async () => undefined }, + WebContentsView: function () { + return view; + } as never, + annotatePreloadPath: "/preload.js", + rendererOrigin: "http://localhost:5173", + }); + await (handlers.get("browser:ensure")!({ sender: { id: 1 } }, "sess-1") as Promise); + + destroyed = true; // window "closed" fired + + expect(() => host.dispose()).not.toThrow(); + expect(removeChildView).not.toHaveBeenCalled(); + expect(view.webContents.close).not.toHaveBeenCalled(); + }); +}); + describe("clampBoundsToWindow", () => { it("rounds and clamps bounds to the window content area", () => { expect( diff --git a/frontend/src/main/browser-view-host.ts b/frontend/src/main/browser-view-host.ts index e6c373f7d..cbc9220c8 100644 --- a/frontend/src/main/browser-view-host.ts +++ b/frontend/src/main/browser-view-host.ts @@ -55,6 +55,7 @@ type BrowserWindowLike = { }; getContentBounds: () => BrowserRect; webContents: Pick; + isDestroyed?: () => boolean; }; type ShellLike = { @@ -197,6 +198,10 @@ export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserV const entry = entries.get(viewId); if (!entry) return; entries.delete(viewId); + // When the window is already gone (dispose fired from mainWindow "closed"), + // Electron has torn down contentView and the child WebContentsViews. Touching + // them throws "Object has been destroyed", so just drop our reference. + if (options.mainWindow.isDestroyed?.()) return; entry.view.setVisible?.(false); entry.view.setBounds(OFFSCREEN_BOUNDS); options.mainWindow.contentView.removeChildView?.(entry.view);