fix(dom): scope nested interactive-state selectors to their parent (PER-9775) - #2324
Conversation
…ER-9775) walkCSSRules recursed into native CSS nesting but only preserved at-rule preludes (@media/@layer/@supports), dropping the parent style-rule selector. A nested interactive-pseudo rule like `.card { &:hover { … } }` was therefore flattened to a bare `&:hover`, rewritten to `&[data-percy-hover]`, and injected into the document <head> interactive-states block. On engines that support CSS nesting (Chrome/Edge/Firefox and Safari 17.3+), a top-level `&` resolves to `:root`, so component-scoped styles applied to the whole page — blue page background, black SVG backgrounds, wrong button colors. Thread a parentSelector through walkCSSRules and resolve each nested selector's `&` against it via :is(<parent>) (the CSSOM serializes nested selectors with an explicit `&`, so this scopes selector lists too). At-rule prelude handling is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| // `&[data-percy-hover]`. A top-level `&` resolves to :root on engines that | ||
| // support nesting, leaking component styles to the whole page (blue | ||
| // background, black SVG backgrounds, wrong button colors). | ||
| describe('native CSS nesting — parent selector resolution (PER-9775)', () => { |
There was a problem hiding this comment.
remove the ticket id
There was a problem hiding this comment.
Done in ba16b31 — removed the ticket id from the describe name and the leading comment.
| const childParent = atRulePrelude | ||
| ? parentSelector | ||
| : (rule.selectorText | ||
| ? resolveNestedSelector(rule.selectorText, parentSelector) | ||
| : parentSelector); |
There was a problem hiding this comment.
nested ternary operator : difficult to understand
please make this better either by having a helper fxn or changing code
There was a problem hiding this comment.
Done in ba16b31 — replaced the nested ternary with a default assignment + a single guarded if (let childParent = parentSelector; if (!atRulePrelude && rule.selectorText) childParent = resolveNestedSelector(...)). Same behavior, and the branch stays 100% covered.
… from test - replace the nested ternary in walkCSSRules with a default + guarded assignment (clearer, same behavior, fully covered) - remove the PER ticket id from the regression describe name and comment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude Code PR ReviewPR: #2324 • Head: ba16b31 • Reviewers: stack:code-reviewer SummaryFixes PER-9775: native CSS nesting (emotion / CSS-in-JS) emits child rules with Review Table
FindingsNo blocking issues. All findings are Low / informational.
Raised by other reviewers (not independently confirmed)Both human review threads from @amandeepsingh333 are resolved — confirmed against current HEAD (
Verdict: PASS — correct, focused fix for the nesting leak with solid regression coverage; only Low/informational nits remain, and prior human review comments are addressed. |
|
@AkashBrowserStack — the |
…PER-10588) The interactive-state auto-detect path has shipped four regressions in three months, all of the same shape: it copies customer CSS, rewrites a pseudo-class to a data-percy-* attribute, and re-injects the copy, which reorders the cascade for rules nobody asked it to touch. PER-9775 (#2324) nested selectors leaked page-wide via a bare `&` PER-9836 pseudoClassEnabledElements froze layout/transitions PER-10077 (#2342) :checked/:disabled copies recolored Angular Material buttons across 70+ snapshots PER-10588 one :hover member of an 82-selector rule repainted every colored-card heading on an AEM site Adoption does not justify that risk. On the PER-10588 customer's captured DOM the injected block was 289KB with 629 [data-percy-hover] selectors and ZERO data-percy-* attributes stamped on any element — nothing it emitted could match. The feature contributed no correct behavior and all of the breakage. Gate both paths behind a new snapshot option, enablePseudoClassSerialization (default false; snake_case enable_pseudo_class_serialization also accepted). Configuring pseudoClassEnabledElements is itself an opt-in and turns the feature on without the flag, so no existing opt-in user changes behavior. The predicate is computed once in serializeDOM and carried as ctx.pseudoClassSerialization — one auditable switch, not a check per call site. No pseudo-class logic is removed. Deliberately NOT gated, both verified against percy-renderer: - Open-popover stamping. src/script/popover-element-helper.js reopens [popover][data-percy-popover-open]; without the stamp an open popover renders hidden behind the UA [popover]:not(:popover-open) rule. - Custom element :state() rewriting. serialize-custom-states rewrites <style> text in place, so it cannot reorder the cascade. The renderer consumes only shadow-host, injected, popover-open, dialog-modal, scrolltop/left and freeze-animation-logs attributes — none of the gated focus/hover/active stamps — so nothing downstream regresses. Verified end to end on the PER-10588 customer's real page and CSS: master 714,774 bytes, injected block, headings rgb(0,75,111) gated (default) 429,635 bytes, no block, headings white gated + flag 629,882 bytes, injected block, headings white @percy/dom is regression-clean against baseline (466 pass vs 459, same 40 pre-existing focus failures on a headless browser without window focus), plus 7 new specs covering default-off, both opt-in routes, the snake_case alias, and the two carve-outs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…PER-10588) The interactive-state auto-detect path has shipped four regressions in three months, all of the same shape: it copies customer CSS, rewrites a pseudo-class to a data-percy-* attribute, and re-injects the copy, which reorders the cascade for rules nobody asked it to touch. PER-9775 (#2324) nested selectors leaked page-wide via a bare `&` PER-9836 pseudoClassEnabledElements froze layout/transitions PER-10077 (#2342) :checked/:disabled copies recolored Angular Material buttons across 70+ snapshots PER-10588 one :hover member of an 82-selector rule repainted every colored-card heading on an AEM site Adoption does not justify that risk. On the PER-10588 customer's captured DOM the injected block was 289KB with 629 [data-percy-hover] selectors and ZERO data-percy-* attributes stamped on any element — nothing it emitted could match. The feature contributed no correct behavior and all of the breakage. Gate both paths behind a new snapshot option, enablePseudoClassSerialization (default false; snake_case enable_pseudo_class_serialization also accepted). Configuring pseudoClassEnabledElements is itself an opt-in and turns the feature on without the flag, so no existing opt-in user changes behavior. The predicate is computed once in serializeDOM and carried as ctx.pseudoClassSerialization — one auditable switch, not a check per call site. No pseudo-class logic is removed. Deliberately NOT gated, both verified against percy-renderer: - Open-popover stamping. src/script/popover-element-helper.js reopens [popover][data-percy-popover-open]; without the stamp an open popover renders hidden behind the UA [popover]:not(:popover-open) rule. - Custom element :state() rewriting. serialize-custom-states rewrites <style> text in place, so it cannot reorder the cascade. The renderer consumes only shadow-host, injected, popover-open, dialog-modal, scrolltop/left and freeze-animation-logs attributes — none of the gated focus/hover/active stamps — so nothing downstream regresses. Verified end to end on the PER-10588 customer's real page and CSS: master 714,774 bytes, injected block, headings rgb(0,75,111) gated (default) 429,635 bytes, no block, headings white gated + flag 629,882 bytes, injected block, headings white @percy/dom is regression-clean against baseline (466 pass vs 459, same 40 pre-existing focus failures on a headless browser without window focus), plus 7 new specs covering default-off, both opt-in routes, the snake_case alias, and the two carve-outs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes PER-9775.
Symptom
From
@percy/cli1.31.14 onward, snapshots of pages built with CSS-in-JS (emotion) render with a blue page background, black backgrounds behind SVG icons, and wrong button colors — across all four browsers, including Safari whose engine version did not change. The triggering customer commit only bumped dependencies (@percy/cli1.31.13 → 1.31.14).Root cause
The interactive-states feature (#2177) walks every CSS rule, rewrites interactive pseudos (
:hover/:focus/:focus-within/…) to[data-percy-*]attribute selectors, and injects them into a<style data-percy-interactive-states>block in the cloned<head>.walkCSSRulesrecursed into nested rules but only preserved at-rule preludes (@media/@layer/@supports). For native CSS nesting — a style rule with nested style-rule children, which emotion emits — it dropped the parent selector and emitted the child with its&-relative selector intact:So
.cta-card { &:hover { background: var(--blue) } }was flattened to a bare&:hover, rewritten to&[data-percy-hover], and injected at document<head>. On every current engine that supports CSS nesting (Chrome/Edge/Firefox and Safari 17.3+), a top-level&resolves to:root, so the component-scoped rule applied to the whole document. Combined with:focus-withinmarkers being stamped up the ancestor chain onto<html>/<body>,:root-matching rules paint page-wide.This explains the puzzle that pointed people away from the CLI: the regression is baked into the captured DOM, so it's browser-agnostic — Safari breaks too even though its version was unchanged.
Fix
Thread a
parentSelectorthroughwalkCSSRulesand resolve each nested selector's&against it via:is(<parent>):.cta-card { &:hover {…} }→:is(.cta-card):hover→:is(.cta-card)[data-percy-hover](component-scoped, no:rootleak).The CSSOM serializes nested selectors with the implicit nesting selector made explicit, so every nested selector — including each item of a selector list — carries its own
&; a global&→:is(parent)replace therefore scopes lists correctly too. At-rule prelude handling (@media/@layer/@supports) is unchanged.Testing
serialize-pseudo-classes.test.js:&:hoverscoping,&-less descendant (& a:hover), nested selector lists (&:hover, &:focus), and a complex:is()+attribute selector. Verified red→green (they fail onmaster, pass with this change).@percy/domsuite passes (902/902); lint clean; coverage of changed code is 100%.🤖 Generated with Claude Code