Skip to content

[Bug]: Temporary iframe handles retained after frameLocator clicks #42653

Closed

Description

@dolezel

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.

  1. Use Node.js with Microsoft Edge installed.
  2. In an empty directory, run npm install [email protected].
  3. Save the following as repro.cjs and run node 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 in finally after the hit-target checks.
  • FrameSession._framePosition:
    the temporary frame element used for boundingBox() is not disposed. Dispose it in finally. This second site is identified by source inspection; the same-process srcdoc reproduction 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions