Skip to content

Page.content() omits shadow DOM, but locators pierce it — selectors that look unique silently aren't (revives #30816) #42794

Description

Problem

Page.content() only serializes light DOM — shadow root contents are invisible to it, which is expected per the DOM serialization spec. But Playwright's own CSS-based locators pierce open shadow roots by default when matching at runtime.

That mismatch is a real footgun for anything that builds selectors or automation from page.content()'s output (scrapers, selector generators, AI browser agents, test recorders): a CSS path that looks unique in the light-DOM-only HTML can still match a second element inside an open shadow root — silently, since matching succeeds and no error is raised. The result is a click (or any locator action) landing on the wrong element, indistinguishable from a correct one.

Minimal repro

html = """<html><body>
  <div id="host"><button onclick="mark('light')">Light</button></div>
  <script>
    window.hits = [];
    function mark(v) { window.hits.push(v) }
    host.attachShadow({mode: 'open'}).innerHTML =
      "<button onclick=\"window.hits.push('shadow')\">Shadow</button>";
  </script>
</body></html>"""

page.set_content(html)

# Looks unique in page.content() — the shadow button is invisible to it.
assert page.content().count("<button") == 1

# But Playwright's own locator pierces the shadow root and finds BOTH:
assert page.locator("#host > button").count() == 2  # not 1

A tool that parses page.content(), sees one <button>, and hands back #host > button as "the unique selector" has just produced one that silently matches two live elements.

This was already proposed, and closed for inactivity — not rejected on merit

#30816 asked for exactly this (page.content({ serializableShadowRoots: true }) via Element.getHTML({ serializableShadowRoots: true })) and was closed after ~16 months with:

"If you have additional information not present in this issue that you think will help prioritizing it, please open a new issue and reference this one. More support or clarity on its necessity may prompt a review."

This is that — a concrete failure mode instead of a general "let the crawler grab everything," plus proof it's buildable.

Already built and validated, independently

Hit this building zerodom (an MCP server generating verified-unique CSS selectors for AI agents), and implemented exactly what #30816 proposed — walking the DOM for open shadow roots, serializing them via Element.getHTML({ serializableShadowRoots: true, shadowRoots }), and feeding the result back into selector generation so it can see (and correctly disambiguate against) shadow content it would otherwise be blind to.

Validated across 111 real sites — 1,334 of 10,756 audited selectors had to be scoped against open shadow roots (121 of 129 on shoelace.style alone), every one resolving to exactly one live element once shadow content was actually visible.

Ask

Not asking for that exact implementation — just that Page.content() (or a documented alternative) makes shadow-root content visible, so tools built on it stop silently producing selectors that look correct and aren't. Happy to share the full implementation/tests, or help however's useful given how content() is actually implemented across Chromium/Firefox/WebKit.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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