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 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-22 20:55:01 +05:30
parent 851d3a88e6
commit e20c23a89e
No known key found for this signature in database
2 changed files with 65 additions and 0 deletions

View File

@ -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<string, InvokeHandler>();
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<unknown>);
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(

View File

@ -55,6 +55,7 @@ type BrowserWindowLike = {
};
getContentBounds: () => BrowserRect;
webContents: Pick<WebContents, "id" | "send">;
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);