Skip to content

Commit e20c23a

Browse files
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>
1 parent 851d3a8 commit e20c23a

2 files changed

Lines changed: 65 additions & 0 deletions

File tree

frontend/src/main/browser-view-host.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,66 @@ describe("browser:clear", () => {
105105
});
106106
});
107107

108+
describe("dispose after the window is destroyed", () => {
109+
it("does not touch contentView/views once the window reports destroyed", async () => {
110+
const handlers = new Map<string, InvokeHandler>();
111+
const view = {
112+
webContents: {
113+
canGoBack: () => false,
114+
canGoForward: () => false,
115+
getTitle: () => "",
116+
getURL: () => "",
117+
goBack: () => undefined,
118+
goForward: () => undefined,
119+
isLoading: () => false,
120+
loadURL: async () => undefined,
121+
on: () => undefined,
122+
reload: () => undefined,
123+
send: () => undefined,
124+
setWindowOpenHandler: () => undefined,
125+
stop: () => undefined,
126+
// Real Electron throws "Object has been destroyed" here after close.
127+
close: vi.fn(() => {
128+
throw new Error("Object has been destroyed");
129+
}),
130+
},
131+
setBounds: () => undefined,
132+
setVisible: () => undefined,
133+
};
134+
let destroyed = false;
135+
const removeChildView = vi.fn(() => {
136+
throw new Error("Object has been destroyed");
137+
});
138+
const host = createBrowserViewHost({
139+
mainWindow: {
140+
contentView: { addChildView: () => undefined, removeChildView },
141+
getContentBounds: () => ({ x: 0, y: 0, width: 800, height: 600 }),
142+
webContents: { id: 1, send: () => undefined },
143+
isDestroyed: () => destroyed,
144+
} as never,
145+
ipcMain: {
146+
handle: (channel: string, fn: InvokeHandler) => handlers.set(channel, fn),
147+
on: () => undefined,
148+
removeHandler: () => undefined,
149+
off: () => undefined,
150+
} as never,
151+
shell: { openExternal: async () => undefined },
152+
WebContentsView: function () {
153+
return view;
154+
} as never,
155+
annotatePreloadPath: "/preload.js",
156+
rendererOrigin: "http://localhost:5173",
157+
});
158+
await (handlers.get("browser:ensure")!({ sender: { id: 1 } }, "sess-1") as Promise<unknown>);
159+
160+
destroyed = true; // window "closed" fired
161+
162+
expect(() => host.dispose()).not.toThrow();
163+
expect(removeChildView).not.toHaveBeenCalled();
164+
expect(view.webContents.close).not.toHaveBeenCalled();
165+
});
166+
});
167+
108168
describe("clampBoundsToWindow", () => {
109169
it("rounds and clamps bounds to the window content area", () => {
110170
expect(

frontend/src/main/browser-view-host.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ type BrowserWindowLike = {
5555
};
5656
getContentBounds: () => BrowserRect;
5757
webContents: Pick<WebContents, "id" | "send">;
58+
isDestroyed?: () => boolean;
5859
};
5960

6061
type ShellLike = {
@@ -197,6 +198,10 @@ export function createBrowserViewHost(options: BrowserViewHostOptions): BrowserV
197198
const entry = entries.get(viewId);
198199
if (!entry) return;
199200
entries.delete(viewId);
201+
// When the window is already gone (dispose fired from mainWindow "closed"),
202+
// Electron has torn down contentView and the child WebContentsViews. Touching
203+
// them throws "Object has been destroyed", so just drop our reference.
204+
if (options.mainWindow.isDestroyed?.()) return;
200205
entry.view.setVisible?.(false);
201206
entry.view.setBounds(OFFSCREEN_BOUNDS);
202207
options.mainWindow.contentView.removeChildView?.(entry.view);

0 commit comments

Comments
 (0)