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