fix(dom): auto-detect open popovers nested in shadow DOM#2321
Conversation
Open native popovers ([popover]:popover-open) are an unambiguous serialize-time state, but the only existing popover handling (markPopoverIfOpen, added in #2141) ran solely on configured pseudoClassEnabledElements resolved via top-document queries -- which require explicit config AND cannot pierce shadow DOM. A popover nested in an open shadow root (e.g. Q2/Tecton <q2-popover> Stencil components, consumer_wellness_web) was therefore never stamped, so the renderer hit the UA rule [popover]:not(:popover-open){display:none} and dropped it. Add markOpenPopovers(), called from markPseudoClassElements (the pre-clone marking pass). It walks the live document and every shadow root via a marker-independent walk (eachScopeIncludingShadow, descending through live el.shadowRoot / getShadowRoot) -- NOT walkShadowDOM, whose data-percy-shadow-host markers aren't stamped until cloning, after this pass runs. Every [popover]:popover-open is stamped data-percy-popover-open automatically, with zero config. Stamps are recorded on _liveMutations so cleanup unstamps the live DOM. Warns once and stops querying if :popover-open is unsupported. The renderer already re-opens stamped popovers across shadow boundaries, so no renderer change is needed. Adds a regression test for an open popover inside an open shadow root captured with no config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🤖 stack:pr-review — Automated review (BrowserStack AI harness)Auto-detects open native popovers nested inside shadow roots during the pre-clone marking pass, by walking the live document and every (open or CDP-closed) shadow root directly rather than via the not-yet-stamped
Findings (non-blocking)
Overall verdict: ✅ Pass — sound fix, correct shadow traversal and cleanup, adequate tests. No blocking issues; two minor non-blocking notes. Posted by the BrowserStack |
Address review on #2321: - markOpenPopovers: scope the try to the `:popover-open` querySelectorAll only (the one thing that can throw a SyntaxError on unsupported engines) and stamp outside it. Previously a throw from stampOnce mid-iteration would be misreported as "Browser does not support :popover-open" AND silently skip every remaining scope. - Add a regression test for an open popover inside a genuinely CLOSED shadow root, registered via the __percyClosedShadowRoots WeakMap (mirroring the CDP exposure path). Asserts host.shadowRoot is null so the test only passes via the getShadowRoot -> getClosedShadowRoot fallback, guarding the closed-root path from diverging from the open-root one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
aryanku-dev
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 2 inline finding(s). Full report in the PR comment below. Verdict: Passed.
| let matches; | ||
| try { | ||
| matches = scope.querySelectorAll('[popover]:popover-open'); | ||
| } catch (e) { |
There was a problem hiding this comment.
[Low] Narrow the catch to SyntaxError
This catch latches supported = false (skipping every remaining scope) and emits the "not supported" warning on any throw. In practice querySelectorAll only throws SyntaxError for an unparseable selector, so this is correct today — but narrowing makes the intent explicit and avoids a misleading warning if some other DOMException ever surfaces here.
Suggestion:
} catch (e) {
if (e instanceof SyntaxError) {
supported = false;
ctx.warnings.add(POPOVER_UNSUPPORTED_WARNING);
}
return;
}Reviewer: stack:code-reviewer (reconciled)
| matches = scope.querySelectorAll('[popover]:popover-open'); | ||
| } catch (e) { | ||
| supported = false; | ||
| ctx.warnings.add('Browser does not support :popover-open pseudo-class.'); |
There was a problem hiding this comment.
[Low] Duplicated warning string
This exact literal is also used in isPopoverOpen (line 196). Extract a module-level constant so both call sites stay in sync.
Suggestion: const POPOVER_UNSUPPORTED_WARNING = 'Browser does not support :popover-open pseudo-class.'; and reference it from both.
Reviewer: stack:code-reviewer (reconciled)
Claude Code PR ReviewPR: #2321 • Head: fd69344 • Reviewers: stack:code-reviewer (reconciled by orchestrator) SummaryAdds Review Table
FindingsAll findings below are Low severity / non-gating polish. None blocks merge. The reviewer's two "blocking" findings did not survive reconciliation (details below).
Reconciliation notes (reviewer findings adjusted)
Verdict: PASS — correct, well-tested fix for a real customer bug; remaining items are optional low-severity polish. |
Problem
A customer (
consumer_wellness_web, Q2 ebanking) opens a native popover and snapshots it, but it renders hidden in Percy. The popover is an inner<div popover="manual">inside a<q2-popover>Stencil web component — i.e. nested inside open shadow DOM (confirmed via the customer's DevTools:q2-dropdown(shadow) →q2-popover(shadow) →[popover]). No iframe is involved.The only existing popover handling (
markPopoverIfOpen, added in #2141) runs solely on configuredpseudoClassEnabledElements, resolved via top-documentgetElementById/querySelectorAll/etc. Those::q2-popover-openwas invalid CSS and matched nothing), and<q2-popover>host doesn't carry thepopoverattribute.So the open popover is never stamped
data-percy-popover-open, the renderer can't re-open it, and the UA rule[popover]:not(:popover-open){display:none}drops it from the snapshot.Fix
Add
markOpenPopovers(ctx), called frommarkPseudoClassElements(the pre-clone marking pass).[popover]:popover-openis an unambiguous serialize-time state (like:checked/:disabled), so it's auto-detected page-wide with zero config, including inside shadow roots.It walks the live document + every shadow root via a marker-independent walk,
eachScopeIncludingShadow(descends through liveel.shadowRoot/getShadowRoot) — deliberately notwalkShadowDOM, whosedata-percy-shadow-hostmarkers aren't stamped until cloning, which happens after this marking pass. Stamps are recorded onctx._liveMutationsso cleanup unstamps the live DOM; it warns once and stops querying if:popover-openis unsupported.No renderer change needed —
percy-renderer'spopover-element-helper.jsalready traverses shadow roots and re-opens[popover][data-percy-popover-open]viashowPopover().Tests
Adds a
popover auto-detection inside shadow DOMsuite (packages/dom/test/serialize-pseudo-classes.test.js):Existing
@percy/domsuite passes (Chrome).Verification
Headless serialize of a faithful repro (
q2-dropdown→q2-popover→[popover], opened viashowPopover()): patched → 1data-percy-popover-open="true"stamp on the[popover]element → renders; unfixed (1.31.14) → 0 stamps →display:none/ dropped. No live-DOM leak after serialize.🤖 Generated with Claude Code