Skip to content

Tree builder does not traverse open shadow DOM roots #182

@qubert-quatico

Description

@qubert-quatico

Issue

Setup:

  • Node version: v22.x / Chrome 131+
  • Virtual Screen Reader version: 0.32.1
  • Environment: Playwright (browser build via index.browser.js)

Details

The accessibility tree builder in createAccessibilityTree.ts does not enter open shadow roots. When used on web components with shadow DOM, the virtual screen reader sees only the host element — none of the shadow DOM content (landmarks, buttons, links, headings) is included in the spoken phrase log.

Reproduction

Given a page with a custom element using open shadow DOM:

<my-nav>
  #shadow-root (open)
    <nav aria-label="Main">
      <button>Menu</button>
      <a href="/about">About</a>
    </nav>
</my-nav>
import { virtual } from "@guidepup/virtual-screen-reader";

await virtual.start({ container: document.body });
while ((await virtual.lastSpokenPhrase()) !== "end of document") {
  await virtual.next();
}
console.log(await virtual.spokenPhraseLog());
// Actual:   ["document", "document", "document", ...] (never reaches content)
// Expected: ["document", "navigation, Main", "button, Menu", "link, About", ...]

Root cause

growTree() at line 163 of src/createAccessibilityTree.ts iterates node.childNodes, which returns only light DOM children. Shadow DOM content lives in node.shadowRoot.childNodes and is never visited:

// Current: only light DOM
node.childNodes.forEach((childNode) => { ... });

// Missing: shadow DOM traversal
if (node instanceof HTMLElement && node.shadowRoot) {
  node.shadowRoot.childNodes.forEach((childNode) => { ... });
}

The same issue affects:

  • querySelectorAll("[aria-flowto]") (line 76) — does not search inside shadow trees
  • querySelectorAll("[aria-owns]") (line 111) — same
  • getNodeByIdRef.ts:14container.querySelector() cannot find IDs inside shadow trees
  • observeDOM.ts — MutationObserver with subtree: true does not cross shadow boundaries

Real-world impact

This affects any project using web components with shadow DOM — which is increasingly common (Lit, Stencil, Shoelace, Spectrum Web Components, and frameworks like Angular that optionally use shadow DOM). We discovered this while trying to add screen reader tests for MeteoSwiss which uses nested custom elements with 3 levels of shadow DOM (host → button component → inner button).

Expected behavior per spec

The WAI-ARIA 1.2 spec defines the accessibility tree as the flattened (composed) tree. Shadow DOM is an encapsulation mechanism invisible to assistive technology. Real screen readers (VoiceOver, NVDA, JAWS) traverse the composed/flattened tree and correctly announce content inside shadow roots.

Since virtual-screen-reader targets ACCNAME 1.2, CORE-AAM 1.2, and WAI-ARIA 1.2 compliance, it should also traverse the flattened tree — entering open shadow roots and resolving <slot> elements via assignedNodes({ flatten: true }).

Proposed fix

The fix is scoped to a few locations:

  1. growTree() — when a node has an open .shadowRoot, traverse its children (the shadow tree) instead of (or in addition to) the light DOM children. For <slot> elements, use slot.assignedNodes({ flatten: true }) to get the projected content.

  2. querySelectorAll calls — use a recursive shadow-piercing query helper for aria-flowto and aria-owns resolution.

  3. getNodeByIdRef — shadow-aware ID lookup.

  4. observeDOM — observe each encountered shadow root separately.

I am happy to submit a PR with this fix if you are open to it.

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