From f17a4705d125bce3ce2ce1b2e292bde050eaf04c Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 9 Jul 2026 15:43:13 -0700 Subject: [PATCH 1/4] fix(electron): close pages from the main process to avoid Target.closeTarget hang page.close() sends CDP Target.closeTarget and waits for the browser-level Target.detachedFromTarget event to resolve. On Electron, when the close races a navigation that is being committed, Electron acks Target.closeTarget with success but silently drops the actual close, so the target is never destroyed and page.close() hangs forever (Electron has no unload watchdog to recover, unlike Chrome). Close the page from the main process instead via a per-page custom close handler that calls webContents.close(). This is not subject to the CDP/ navigation race, and webContents.close() (without waitForBeforeUnload) closes without running beforeunload, matching page.close(runBeforeUnload: false) semantics. --- .../src/server/electron/electron.ts | 18 ++++++++++++++++-- packages/playwright-core/src/server/page.ts | 8 +++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index 89485c74f33b9..508c899d6fcc5 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -25,7 +25,7 @@ import { eventsHelper } from '@utils/eventsHelper'; import { envArrayToObject, launchProcess } from '@utils/processLauncher'; import { ManualPromise } from '@isomorphic/manualPromise'; import { libPath } from '../../package'; -import { validateBrowserContextOptions } from '../browserContext'; +import { BrowserContext, validateBrowserContextOptions } from '../browserContext'; import { CRBrowser } from '../chromium/crBrowser'; import { CRConnection } from '../chromium/crConnection'; import { createHandle, CRExecutionContext } from '../chromium/crExecutionContext'; @@ -38,7 +38,6 @@ import { WebSocketTransport } from '../transport'; import { nullProgress } from '../progress'; import type { BrowserOptions, BrowserProcess } from '../browser'; -import type { BrowserContext } from '../browserContext'; import type { CRBrowserContext } from '../chromium/crBrowser'; import type { CRSession } from '../chromium/crConnection'; import type { CRPage } from '../chromium/crPage'; @@ -86,6 +85,7 @@ export class ElectronApplication extends SdkObject { this._nodeElectronHandlePromise.resolve(new js.JSHandle(this._nodeExecutionContext!, 'object', 'ElectronModule', remoteObject.objectId!)); }); this._nodeSession.on('Runtime.consoleAPICalled', event => this._onConsoleAPI(event)); + this._browserContext.on(BrowserContext.Events.Page, page => this._onPage(page)); const appClosePromise = new Promise(f => this.once(ElectronApplication.Events.Close, f)); this._browserContext.setCustomCloseHandler(async () => { const electronHandle = await this._nodeElectronHandlePromise; @@ -138,6 +138,20 @@ export class ElectronApplication extends SdkObject { await this._browserContext.close(progress, { reason: 'Application exited' }); } + private _onPage(page: Page) { + // Target.closeTarget can hang on Electron when the close races a committing + // navigation. Close from the main process instead; webContents.close() does + // not run beforeunload, matching page.close() semantics. + page.setCustomCloseHandler(async () => { + const electronHandle = await this._nodeElectronHandlePromise; + await electronHandle.evaluate(({ webContents }, targetId) => { + const wc = webContents.fromDevToolsTargetId(targetId); + if (wc && !wc.isDestroyed()) + wc.close(); + }, (page.delegate as CRPage)._targetId).catch(() => {}); + }); + } + async browserWindow(progress: Progress, page: Page): Promise> { // Assume CRPage as Electron is always Chromium. const targetId = (page.delegate as CRPage)._targetId; diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 92193c60f128e..4a850f7ccd999 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -202,6 +202,7 @@ export class Page extends SdkObject { readonly overlay: Overlay; readonly screencast: Screencast; _closeReason: string | undefined; + private _customCloseHandler?: () => Promise; constructor(delegate: PageDelegate, browserContext: BrowserContext) { super(browserContext, 'page'); @@ -833,11 +834,16 @@ export class Page extends SdkObject { this._lifecycle = 'closing'; // This might throw if the browser context containing the page closes // while we are trying to close the page. - await this.delegate.closePage(false).catch(e => debugLogger.log('error', e)); + const closePage = this._customCloseHandler ?? (() => this.delegate.closePage(false)); + await closePage().catch(e => debugLogger.log('error', e)); } await this.closedPromise; } + setCustomCloseHandler(handler: (() => Promise) | undefined) { + this._customCloseHandler = handler; + } + async runBeforeUnload(progress: Progress) { await progress.race(this._runBeforeUnload()); } From 1d40b35ad8dd435560a859e76b1507a9e3cb7580 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 10 Jul 2026 13:39:36 -0700 Subject: [PATCH 2/4] chore(electron): address review - opt-out env, fallback close - PLAYWRIGHT_ELECTRON_LEGACY_PAGE_CLOSE opts back into Target.closeTarget. - Fall back to the default close when the webContents cannot be found. - Clarify why we close the webContents rather than the BrowserWindow. --- .../src/server/electron/electron.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index 508c899d6fcc5..9dd3182f13f75 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -139,16 +139,24 @@ export class ElectronApplication extends SdkObject { } private _onPage(page: Page) { + if (process.env.PLAYWRIGHT_ELECTRON_LEGACY_PAGE_CLOSE) + return; // Target.closeTarget can hang on Electron when the close races a committing - // navigation. Close from the main process instead; webContents.close() does - // not run beforeunload, matching page.close() semantics. + // navigation. Close from the main process instead. We close the webContents + // rather than the BrowserWindow because BrowserWindow.close() runs the + // beforeunload handler (and can hang on it), while page.close() should not. page.setCustomCloseHandler(async () => { const electronHandle = await this._nodeElectronHandlePromise; - await electronHandle.evaluate(({ webContents }, targetId) => { + const closed = await electronHandle.evaluate(({ webContents }, targetId) => { const wc = webContents.fromDevToolsTargetId(targetId); - if (wc && !wc.isDestroyed()) - wc.close(); - }, (page.delegate as CRPage)._targetId).catch(() => {}); + if (!wc || wc.isDestroyed()) + return false; + wc.close(); + return true; + }, (page.delegate as CRPage)._targetId).catch(() => false); + // Fall back to the default close if the webContents could not be found. + if (!closed) + await page.delegate.closePage(false); }); } From efd39f43241ed0f9e1e0562d3954ca14367af64e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 10 Jul 2026 14:10:05 -0700 Subject: [PATCH 3/4] chore(electron): pass waitForBeforeUnload:false to webContents.close explicitly --- packages/playwright-core/src/server/electron/electron.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index 9dd3182f13f75..eff306d0306de 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -151,7 +151,7 @@ export class ElectronApplication extends SdkObject { const wc = webContents.fromDevToolsTargetId(targetId); if (!wc || wc.isDestroyed()) return false; - wc.close(); + wc.close({ waitForBeforeUnload: false }); return true; }, (page.delegate as CRPage)._targetId).catch(() => false); // Fall back to the default close if the webContents could not be found. From 6b2c6f93ab7ebfd67ef39b73b415c426e4e46a09 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 10 Jul 2026 14:34:49 -0700 Subject: [PATCH 4/4] fix(electron): route runBeforeUnload close through the main process too page.close({ runBeforeUnload: true }) went through the CDP Page.close path, which has the same problem as Target.closeTarget on Electron: it leaves a window with a pending navigation open. Pass runBeforeUnload to the custom close handler and map it to webContents.close({ waitForBeforeUnload }), so both close variants close from the main process. --- .../src/server/electron/electron.ts | 14 +++++++------- packages/playwright-core/src/server/page.ts | 11 ++++++----- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index eff306d0306de..7e3d3b01b15bf 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -143,20 +143,20 @@ export class ElectronApplication extends SdkObject { return; // Target.closeTarget can hang on Electron when the close races a committing // navigation. Close from the main process instead. We close the webContents - // rather than the BrowserWindow because BrowserWindow.close() runs the - // beforeunload handler (and can hang on it), while page.close() should not. - page.setCustomCloseHandler(async () => { + // rather than the BrowserWindow because BrowserWindow.close() always runs the + // beforeunload handler, while webContents.close() lets us opt in or out of it. + page.setCustomCloseHandler(async runBeforeUnload => { const electronHandle = await this._nodeElectronHandlePromise; - const closed = await electronHandle.evaluate(({ webContents }, targetId) => { + const closed = await electronHandle.evaluate(({ webContents }, { targetId, runBeforeUnload }) => { const wc = webContents.fromDevToolsTargetId(targetId); if (!wc || wc.isDestroyed()) return false; - wc.close({ waitForBeforeUnload: false }); + wc.close({ waitForBeforeUnload: runBeforeUnload }); return true; - }, (page.delegate as CRPage)._targetId).catch(() => false); + }, { targetId: (page.delegate as CRPage)._targetId, runBeforeUnload }).catch(() => false); // Fall back to the default close if the webContents could not be found. if (!closed) - await page.delegate.closePage(false); + await page.delegate.closePage(runBeforeUnload); }); } diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 4a850f7ccd999..a0a7c5ceea28c 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -202,7 +202,7 @@ export class Page extends SdkObject { readonly overlay: Overlay; readonly screencast: Screencast; _closeReason: string | undefined; - private _customCloseHandler?: () => Promise; + private _customCloseHandler?: (runBeforeUnload: boolean) => Promise; constructor(delegate: PageDelegate, browserContext: BrowserContext) { super(browserContext, 'page'); @@ -834,13 +834,13 @@ export class Page extends SdkObject { this._lifecycle = 'closing'; // This might throw if the browser context containing the page closes // while we are trying to close the page. - const closePage = this._customCloseHandler ?? (() => this.delegate.closePage(false)); - await closePage().catch(e => debugLogger.log('error', e)); + const closePage = this._customCloseHandler ?? (runBeforeUnload => this.delegate.closePage(runBeforeUnload)); + await closePage(false).catch(e => debugLogger.log('error', e)); } await this.closedPromise; } - setCustomCloseHandler(handler: (() => Promise) | undefined) { + setCustomCloseHandler(handler: ((runBeforeUnload: boolean) => Promise) | undefined) { this._customCloseHandler = handler; } @@ -851,7 +851,8 @@ export class Page extends SdkObject { private async _runBeforeUnload() { // This might throw if the browser context containing the page closes // while we are trying to close the page. - await this.delegate.closePage(true).catch(e => debugLogger.log('error', e)); + const closePage = this._customCloseHandler ?? (runBeforeUnload => this.delegate.closePage(runBeforeUnload)); + await closePage(true).catch(e => debugLogger.log('error', e)); } isClosed(): boolean {