Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions packages/dom/src/serialize-pseudo-classes.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,49 @@ function markInteractiveStates(ctx) {
});
}

// Walk the LIVE document and every shadow root (open, or closed via the CDP
// WeakMap) WITHOUT relying on data-percy-shadow-host markers. walkShadowDOM
// descends through those markers, but they are only stamped later during
// cloning — so during this pre-clone marking pass it cannot reach shadow
// content. We descend via the live shadowRoot directly instead.
function eachScopeIncludingShadow(root, visit) {
if (!root || typeof root.querySelectorAll !== 'function') return;
visit(root);
for (const el of root.querySelectorAll('*')) {
const shadow = getShadowRoot(el);
if (shadow) eachScopeIncludingShadow(shadow, visit);
}
}

// Auto-detect open native popovers page-wide, INCLUDING inside shadow roots.
// `:popover-open` is an unambiguous serialize-time state (like :checked /
// :disabled), so it is stamped automatically rather than only on
// pseudoClassEnabledElements. The renderer's popover-element-helper already
// re-opens any [popover][data-percy-popover-open] across shadow boundaries;
// without this stamp a popover open at snapshot time renders hidden via the
// UA `[popover]:not(:popover-open){display:none}` rule. If `:popover-open`
// is unsupported the selector throws — we stop querying and warn once.
function markOpenPopovers(ctx) {
let supported = true;
eachScopeIncludingShadow(ctx.dom, scope => {
if (!supported) return;
// Only the `:popover-open` SELECTOR can legitimately throw a SyntaxError
// (engines without popover support). Scope the try to the query and
// materialize the matches; stamp OUTSIDE the try. Otherwise a throw from
// stampOnce mid-iteration would be misreported as "unsupported" AND would
// silently skip every remaining scope.
let matches;
try {
matches = scope.querySelectorAll('[popover]:popover-open');
Comment thread
amandeepsingh333 marked this conversation as resolved.
} catch (e) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)

supported = false;
ctx.warnings.add('Browser does not support :popover-open pseudo-class.');

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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)

return;
}
for (const el of matches) stampOnce(ctx, el, POPOVER_OPEN_ATTR, 'true');
});
}

function isPopoverOpen(ctx, element) {
try {
return element.matches(':popover-open');
Expand Down Expand Up @@ -258,6 +301,7 @@ export function getElementsToProcess(ctx, config, markWithId = false) {
export function markPseudoClassElements(ctx, config) {
ctx._liveMutations = [];
markInteractiveStates(ctx);
markOpenPopovers(ctx);
if (config) getElementsToProcess(ctx, config, true);
}

Expand Down
100 changes: 100 additions & 0 deletions packages/dom/test/serialize-pseudo-classes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,106 @@ describe('serialize-pseudo-classes', () => {
});
});

// Regression guard for the customer scenario: a native popover opened via
// showPopover() and living inside an open shadow root (e.g. Tecton q2-popover).
// The open/top-layer state is lost on serialization unless the pre-clone pass
// pierces shadow DOM and stamps data-percy-popover-open — and it must do so
// automatically, with no pseudoClassEnabledElements config.
describe('popover auto-detection inside shadow DOM', () => {
let host;
// For the closed-root case: keep a handle to the (otherwise unreachable)
// closed root, and remember the previous WeakMap so we can restore it.
let closedRoot;
let origClosedMap;

function popoverSupported() {
return typeof document.createElement('div').showPopover === 'function';
}

function openShadowPopover({ mode = 'open', type = 'manual', open = true } = {}) {
host = document.createElement('div');
host.id = 'shadow-popover-host';
const root = host.attachShadow({ mode });
// nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method
root.innerHTML = `<div id="sp" popover="${type}">menu</div>`;
document.body.appendChild(host);
if (mode === 'closed') {
// host.shadowRoot is null for closed roots. Mimic the CDP closed-shadow
// exposure path: register host -> root in the WeakMap that
// getShadowRoot()/getClosedShadowRoot() reads, so the marking walk can
// reach inside.
closedRoot = root;
origClosedMap = window.__percyClosedShadowRoots;
const map = new WeakMap();
map.set(host, root);
window.__percyClosedShadowRoots = map;
}
const popover = root.getElementById('sp');
if (open) popover.showPopover();
return popover;
}

afterEach(() => {
if (!host) return;
try {
const root = host.shadowRoot || closedRoot;
const p = root && root.querySelector('[popover]');
if (p && typeof p.hidePopover === 'function' && p.matches(':popover-open')) p.hidePopover();
} catch (e) { /* ignore */ }
host.remove();
host = null;
if (closedRoot) {
window.__percyClosedShadowRoots = origClosedMap;
closedRoot = null;
origClosedMap = undefined;
}
});

it('auto-stamps an open popover inside an open shadow root with NO config', () => {
if (!popoverSupported()) { pending('Popover API not supported in this environment'); return; }
const popover = openShadowPopover();

// No pseudoClassEnabledElements passed — auto-detection must still reach it.
markPseudoClassElements(ctx);

expect(popover.getAttribute('data-percy-popover-open')).toBe('true');
});

it('removes the shadow-DOM popover marker from the live DOM on cleanup', () => {
if (!popoverSupported()) { pending('Popover API not supported in this environment'); return; }
const popover = openShadowPopover();

markPseudoClassElements(ctx);
expect(popover.hasAttribute('data-percy-popover-open')).toBe(true);

cleanupInteractiveStateMarkers(ctx);
expect(popover.hasAttribute('data-percy-popover-open')).toBe(false);
});

it('does NOT stamp a closed popover inside a shadow root', () => {
if (!popoverSupported()) { pending('Popover API not supported in this environment'); return; }
const popover = openShadowPopover({ open: false });

markPseudoClassElements(ctx);

expect(popover.hasAttribute('data-percy-popover-open')).toBe(false);
});

it('auto-stamps an open popover inside a CLOSED shadow root (via the CDP WeakMap)', () => {
if (!popoverSupported()) { pending('Popover API not supported in this environment'); return; }
const popover = openShadowPopover({ mode: 'closed' });

// host.shadowRoot is null for closed roots — reaching the popover relies
// ENTIRELY on getShadowRoot() falling back to __percyClosedShadowRoots.
// This guards the closed-root path from diverging from the open-root one.
expect(host.shadowRoot).toBe(null);

markPseudoClassElements(ctx);

expect(popover.getAttribute('data-percy-popover-open')).toBe('true');
});
});

describe('rewriteCustomStateCSS', () => {
it('rewrites :state() selectors in style elements', () => {
withExample('<style>my-el:state(open) { color: green; }</style><my-el id="myel"></my-el>');
Expand Down
Loading