diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 2161cec308..8ce4484700 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -47,7 +47,7 @@ export class FrameExecutionContext extends js.ExecutionContext { readonly world: types.World | null; constructor(delegate: js.ExecutionContextDelegate, frame: frames.Frame, world: types.World|null) { - super(frame, delegate); + super(frame, delegate, world || 'content-script'); this.frame = frame; this.world = world; } @@ -114,7 +114,7 @@ export class FrameExecutionContext extends js.ExecutionContext { ); })(); `; - this._injectedScriptPromise = this.rawEvaluateHandle(source).then(objectId => new js.JSHandle(this, 'object', undefined, objectId)); + this._injectedScriptPromise = this.rawEvaluateHandle(source).then(objectId => new js.JSHandle(this, 'object', 'InjectedScript', objectId)); } return this._injectedScriptPromise; } @@ -455,6 +455,7 @@ export class ElementHandle extends js.JSHandle { // Do not await here, just in case the renderer is stuck (e.g. on alert) // and we won't be able to cleanup. hitTargetInterceptionHandle!.evaluate(h => h.stop()).catch(e => {}); + hitTargetInterceptionHandle!.dispose(); }); } @@ -470,7 +471,9 @@ export class ElementHandle extends js.JSHandle { if (restoreModifiers) await this._page.keyboard._ensureModifiers(restoreModifiers); if (hitTargetInterceptionHandle) { - const stopHitTargetInterception = hitTargetInterceptionHandle.evaluate(h => h.stop()).catch(e => 'done' as const); + const stopHitTargetInterception = hitTargetInterceptionHandle.evaluate(h => h.stop()).catch(e => 'done' as const).finally(() => { + hitTargetInterceptionHandle?.dispose(); + }); if (!options.noWaitAfter) { // When noWaitAfter is passed, we do not want to accidentally stall on // non-committed navigation blocking the evaluate. diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index 384f346111..39339c4fbb 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -70,7 +70,7 @@ export class ElectronApplication extends SdkObject { this._nodeElectronHandlePromise = new Promise(f => { this._nodeSession.on('Runtime.executionContextCreated', async (event: any) => { if (event.context.auxData && event.context.auxData.isDefault) { - this._nodeExecutionContext = new js.ExecutionContext(this, new CRExecutionContext(this._nodeSession, event.context)); + this._nodeExecutionContext = new js.ExecutionContext(this, new CRExecutionContext(this._nodeSession, event.context), 'electron'); f(await js.evaluate(this._nodeExecutionContext, false /* returnByValue */, `process.mainModule.require('electron')`)); } }); diff --git a/packages/playwright-core/src/server/javascript.ts b/packages/playwright-core/src/server/javascript.ts index 0ceac99f2b..5554e4e7fd 100644 --- a/packages/playwright-core/src/server/javascript.ts +++ b/packages/playwright-core/src/server/javascript.ts @@ -64,9 +64,11 @@ export class ExecutionContext extends SdkObject { private _delegate: ExecutionContextDelegate; private _utilityScriptPromise: Promise | undefined; private _contextDestroyedRace = new ScopedRace(); + readonly worldNameForTest: string; - constructor(parent: SdkObject, delegate: ExecutionContextDelegate) { + constructor(parent: SdkObject, delegate: ExecutionContextDelegate, worldNameForTest: string) { super(parent, 'execution-context'); + this.worldNameForTest = worldNameForTest; this._delegate = delegate; } @@ -122,7 +124,7 @@ export class ExecutionContext extends SdkObject { ${utilityScriptSource.source} return new (module.exports.UtilityScript())(); })();`; - this._utilityScriptPromise = this._raceAgainstContextDestroyed(this._delegate.rawEvaluateHandle(source).then(objectId => new JSHandle(this, 'object', undefined, objectId))); + this._utilityScriptPromise = this._raceAgainstContextDestroyed(this._delegate.rawEvaluateHandle(source).then(objectId => new JSHandle(this, 'object', 'UtilityScript', objectId))); } return this._utilityScriptPromise; } @@ -153,6 +155,8 @@ export class JSHandle extends SdkObject { this._value = value; this._objectType = type; this._preview = this._objectId ? preview || `JSHandle@${this._objectType}` : String(value); + if (this._objectId && (globalThis as any).leakedJSHandles) + (globalThis as any).leakedJSHandles.set(this, new Error('Leaked JSHandle')); } callFunctionNoReply(func: Function, arg: any) { @@ -211,8 +215,11 @@ export class JSHandle extends SdkObject { if (this._disposed) return; this._disposed = true; - if (this._objectId) + if (this._objectId) { this._context.releaseHandle(this._objectId).catch(e => {}); + if ((globalThis as any).leakedJSHandles) + (globalThis as any).leakedJSHandles.delete(this); + } } override toString(): string { @@ -227,13 +234,16 @@ export class JSHandle extends SdkObject { return this._preview; } + worldNameForTest(): string { + return this._context.worldNameForTest; + } + _setPreview(preview: string) { this._preview = preview; if (this._previewCallback) this._previewCallback(preview); } - async objectCount(): Promise { if (!this._objectId) throw new Error('Can only count objects for a handle that points to the constructor prototype'); diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index 0344d73962..11c577dfa3 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -723,7 +723,7 @@ export class Worker extends SdkObject { } _createExecutionContext(delegate: js.ExecutionContextDelegate) { - this._existingExecutionContext = new js.ExecutionContext(this, delegate); + this._existingExecutionContext = new js.ExecutionContext(this, delegate, 'worker'); this._executionContextCallback(this._existingExecutionContext); } diff --git a/tests/page/page-leaks.spec.ts b/tests/page/page-leaks.spec.ts new file mode 100644 index 0000000000..436df82184 --- /dev/null +++ b/tests/page/page-leaks.spec.ts @@ -0,0 +1,151 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MultiMap } from '../../packages/playwright-core/lib/utils/multimap'; +import { test, expect } from './pageTest'; + +function leakedJSHandles(): string { + const map = new MultiMap(); + for (const [h, e] of (globalThis as any).leakedJSHandles) { + const name = `[${h.worldNameForTest()}] ${h.preview()}`; + if (name === '[main] UtilityScript' || name === '[utility] UtilityScript' || name === '[main] InjectedScript' || name === '[utility] InjectedScript') + continue; + map.set(e.stack, name); + } + + if (!map.size) + return ''; + + const lines: string[] = []; + lines.push('============================='); + lines.push('Leaked JSHandles:'); + for (const key of map.keys()) { + lines.push('============================='); + for (const value of map.get(key)) + lines.push(value); + lines.push('in ' + key); + } + return lines.join('\n'); +} + +async function objectCounts(pageImpl, constructorName: string): Promise<{ main: number, utility: number }> { + const result = { main: 0, utility: 0 }; + for (const world of ['main', 'utility']) { + const context = await pageImpl.mainFrame()._context(world); + const prototype = await context.evaluateHandle(name => (window as any)[name].prototype, constructorName); + result[world] = await prototype.objectCount(); + } + return result; +} + +test.beforeEach(() => { + (globalThis as any).leakedJSHandles = new Map(); +}); + +test.afterEach(() => { + (globalThis as any).leakedJSHandles = null; +}); + +test('click should not leak', async ({ page, browserName, toImpl }) => { + await page.setContent(` + + +
+ `); + // Create JS wrappers for static elements. + await page.evaluate(() => document.querySelectorAll('button')); + + for (let i = 0; i < 25; ++i) { + await page.evaluate(i => { + const element = document.createElement('button'); + element.textContent = 'dynamic ' + i; + document.getElementById('buttons').appendChild(element); + }, i); + await page.locator('#buttons > button').click(); + await page.evaluate(() => { + document.getElementById('buttons').textContent = ''; + }); + } + + expect(leakedJSHandles()).toBeFalsy(); + + if (browserName === 'chromium') { + const counts = await objectCounts(toImpl(page), 'HTMLButtonElement'); + expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2); + expect(counts.main + counts.utility).toBeLessThan(25); + } +}); + +test('fill should not leak', async ({ page, mode, browserName, toImpl }) => { + test.skip(mode !== 'default'); + + await page.setContent(` + + +
+ `); + // Create JS wrappers for static elements. + await page.evaluate(() => document.querySelectorAll('input')); + + for (let i = 0; i < 25; ++i) { + await page.evaluate(i => { + const element = document.createElement('input'); + document.getElementById('inputs').appendChild(element); + }, i); + await page.locator('#inputs > input').fill('input ' + i); + await page.evaluate(() => { + document.getElementById('inputs').textContent = ''; + }); + } + + expect(leakedJSHandles()).toBeFalsy(); + + if (browserName === 'chromium') { + const counts = await objectCounts(toImpl(page), 'HTMLInputElement'); + expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2); + expect(counts.main + counts.utility).toBeLessThan(25); + } +}); + +test('expect should not leak', async ({ page, mode, browserName, toImpl }) => { + test.skip(mode !== 'default'); + + await page.setContent(` + + +
+ `); + + for (let i = 0; i < 25; ++i) { + await page.evaluate(i => { + const element = document.createElement('button'); + element.textContent = 'dynamic ' + i; + document.getElementById('buttons').appendChild(element); + }, i); + await expect(page.locator('#buttons > button')).toBeVisible(); + await page.evaluate(() => { + document.getElementById('buttons').textContent = ''; + }); + } + + expect(leakedJSHandles()).toBeFalsy(); + + if (browserName === 'chromium') { + const counts = await objectCounts(toImpl(page), 'HTMLButtonElement'); + expect(counts.main + counts.utility).toBeGreaterThanOrEqual(2); + expect(counts.main + counts.utility).toBeLessThan(25); + } +});