Repository navigation
Description
Version
1.58.2
The same undisposed temporary handles are present in the source of main at af74c938e45f3e759dc2521993f201389eb16cb6. I have inspected that source, but have not run this reproduction against the latest release (1.63.0).
Steps to reproduce
This is a standalone reproduction: no application framework, external website, console logging of DOM objects, or explicit ElementHandle created by the caller.
- Use Node.js with Microsoft Edge installed.
- In an empty directory, run
npm install [email protected]. - Save the following as
repro.cjsand runnode repro.cjs.
const { chromium } = require("playwright-core");
async function isRetained(cdp) {
await cdp.send("HeapProfiler.collectGarbage");
await cdp.send("HeapProfiler.collectGarbage");
const { result } = await cdp.send("Runtime.evaluate", {
expression: "Boolean(globalThis.iframeRef.deref())",
returnByValue: true,
});
return result.value;
}
async function run(browser, mode, tracing) {
const context = await browser.newContext();
try {
if (tracing)
await context.tracing.start({ snapshots: true, screenshots: true });
const page = await context.newPage();
await page.setContent('<iframe srcdoc="<button>Click</button>"></iframe>');
const button = page.frameLocator("iframe").getByRole("button");
await button.waitFor();
await page.evaluate(() => {
globalThis.iframeRef = new WeakRef(document.querySelector("iframe"));
});
if (mode !== "no-click")
await button.click({ force: mode === "force-click" });
await page.evaluate(() => document.querySelector("iframe").remove());
// Exclude Chromium's separate last-hovered-node retention.
await page.mouse.move(500, 500);
const cdp = await context.newCDPSession(page);
const retained = await isRetained(cdp);
await cdp.send("Runtime.discardConsoleEntries");
const retainedAfterDiscard = await isRetained(cdp);
console.log({ mode, tracing, retained, retainedAfterDiscard });
await cdp.detach();
if (tracing)
await context.tracing.stop();
} finally {
await context.close();
}
}
(async () => {
const browser = await chromium.launch({ channel: "msedge", headless: true });
try {
console.log({
playwright: require("playwright-core/package.json").version,
browser: browser.version(),
});
for (const tracing of [false, true]) {
for (const mode of ["no-click", "normal-click", "force-click"])
await run(browser, mode, tracing);
}
} finally {
await browser.close();
}
})().catch(error => {
console.error(error);
process.exitCode = 1;
});The pointer movement is a diagnostic control, not a proposed workaround. It separates native last-hovered-node retention from the inspector-handle issue. Similarly, the forced-click case is a control, not a recommendation to disable actionability checks in tests.
Expected behavior
After the iframe is removed, temporary handles created internally by Playwright should not prevent its collection while the page/context remains open. All six cases should print retained: false and retainedAfterDiscard: false.
Actual behavior
For both tracing settings:
| Action | retained | retainedAfterDiscard |
|---|---|---|
| No click | false | false |
| Normal click | true | true |
| Forced click | false | false |
The detached iframe's inspector retention can appear under DevTools console in a heap snapshot even though no DOM object was logged. Discarding console entries does not release it.
Additional context
Instrumenting the owning Chromium protocol session identifies an unreleased HTMLIFrameElement remote object from DOM.resolveNode, acquired through:
ElementHandle._checkFrameIsHitTarget
-> frame.frameElement
-> CRPage.getFrameElement
-> FrameSession._adoptBackendNodeId
-> DOM.resolveNode
Explicitly releasing that exact object ID on its owning session allows collection in the controlled reproduction.
There are two missing-disposal sites:
ElementHandle._checkFrameIsHitTarget:
temporary ancestor iframe handles are not disposed, including early-return and exception paths. Record each handle immediately after acquisition and dispose them infinallyafter the hit-target checks.FrameSession._framePosition:
the temporary frame element used forboundingBox()is not disposed. Dispose it infinally. This second site is identified by source inspection; the same-processsrcdocreproduction above directly demonstrates the first site, not independent coverage of the out-of-process frame path.
Applying these disposal changes to 1.58.2 makes all six cases print false/false without changing the click behavior. I have a small fix ready and would like to contribute it, with regression coverage, if maintainers approve/assign the issue as requested by CONTRIBUTING.md.
Environment
- Windows
- Node.js 24.15.0
- Playwright core 1.58.2
- Microsoft Edge 152.0.4191.66, headless
- Tracing both enabled and disabled
Activity
fix(server): dispose temporary iframe element handles after hit-targe…