From de2e5163b5691277aeae5b729371f3aeaaea1e9f Mon Sep 17 00:00:00 2001 From: Arumulla Sri Ram Date: Mon, 29 Jun 2026 09:16:17 +0530 Subject: [PATCH 1/2] fix(dom): scope nested interactive-state selectors to their parent (PER-9775) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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() (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) --- packages/dom/src/serialize-pseudo-classes.js | 36 +++++- .../dom/test/serialize-pseudo-classes.test.js | 105 ++++++++++++++++++ 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/packages/dom/src/serialize-pseudo-classes.js b/packages/dom/src/serialize-pseudo-classes.js index a7ca35069..950e4a9d9 100644 --- a/packages/dom/src/serialize-pseudo-classes.js +++ b/packages/dom/src/serialize-pseudo-classes.js @@ -330,10 +330,26 @@ function stylesToCSSText(styles) { return decls.join(' '); } +// Resolve a nested rule's selector against its parent per CSS Nesting. +// 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 `&`. Replacing every `&` with :is(parent) +// therefore fully scopes the rule. Without this, a bare top-level `&` +// resolves to :root on engines that support nesting, so component-scoped +// interactive-state rules leak page-wide (PER-9775). :is(...) preserves the +// parent's grouping/specificity. +function resolveNestedSelector(selectorText, parentSelector) { + if (!parentSelector) return selectorText; + return selectorText.replace(/&/g, `:is(${parentSelector})`); +} + // Walk a CSSRule list yielding every reachable style rule. Nested rules // inside @media/@layer/@supports are emitted with the at-rule prelude // preserved as a wrapper string; flat-emitting would drop the guard. -function walkCSSRules(ruleList) { +// `parentSelector` carries the enclosing style rule's (already-resolved) +// selector down through native CSS nesting so `&`-relative children are +// resolved against it rather than leaking as a bare top-level `&`. +function walkCSSRules(ruleList, parentSelector = null) { const result = []; for (let i = 0; i < ruleList.length; i++) { const rule = ruleList[i]; @@ -343,7 +359,17 @@ function walkCSSRules(ruleList) { const atRulePrelude = conditionText && rule.cssText ? rule.cssText.split('{')[0].trim() : null; - for (const inner of walkCSSRules(rule.cssRules)) { + // An at-rule (@media/@layer/@supports) keeps the current parent scope + // and is re-applied as a wrapper. A style rule with nested children + // (native CSS nesting) becomes the scope for its children — resolve + // its own selector against any outer parent first so deeper nesting + // composes correctly. + const childParent = atRulePrelude + ? parentSelector + : (rule.selectorText + ? resolveNestedSelector(rule.selectorText, parentSelector) + : parentSelector); + for (const inner of walkCSSRules(rule.cssRules, childParent)) { if (atRulePrelude && inner.selectorText) { result.push({ selectorText: inner.selectorText, @@ -358,7 +384,11 @@ function walkCSSRules(ruleList) { // Rules without nested cssRules and without selectorText (@font-face, // @charset, @counter-style, etc.) are skipped — they can't contain // interactive pseudos. - result.push({ selectorText: rule.selectorText, style: rule.style, wrapper: null }); + result.push({ + selectorText: resolveNestedSelector(rule.selectorText, parentSelector), + style: rule.style, + wrapper: null + }); } } return result; diff --git a/packages/dom/test/serialize-pseudo-classes.test.js b/packages/dom/test/serialize-pseudo-classes.test.js index febc30d23..2249a7ef4 100644 --- a/packages/dom/test/serialize-pseudo-classes.test.js +++ b/packages/dom/test/serialize-pseudo-classes.test.js @@ -1793,4 +1793,109 @@ describe('serialize-pseudo-classes', () => { expect(s.size).toBe(0); }); }); + + // Regression: PER-9775. Native CSS nesting (emotion / CSS-in-JS) produces + // child rules with `&`-relative selectors. Before the fix, walkCSSRules + // emitted those children with the parent selector stripped, so an + // interactive-pseudo child like `&:hover` was injected as a bare + // `&[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)', () => { + function nestingCtx() { + let ctx = { + dom: document, + clone: document.implementation.createHTMLDocument('Clone'), + warnings: new Set(), + cache: new Map(), + resources: new Set(), + hints: new Set(), + shadowRootElements: [] + }; + // nosemgrep: javascript.browser.security.insecure-document-method.insecure-document-method + ctx.clone.body.innerHTML = document.body.innerHTML; + return ctx; + } + + function injectedRules() { + let style = ctx.clone.querySelector('style[data-percy-interactive-states]'); + return style ? style.textContent : ''; + } + + // Confirms the test browser actually parses nesting into nested cssRules; + // otherwise these assertions wouldn't exercise the nesting path. + function nestingSupported() { + for (let sheet of document.styleSheets) { + try { + for (let rule of sheet.cssRules) { + if (rule.cssRules && rule.cssRules.length) return true; + } + } catch (e) { /* cross-origin */ } + } + return false; + } + + it('scopes a nested &:hover to its parent instead of leaking to :root', () => { + withExample( + '' + + '', + { withShadow: false } + ); + expect(nestingSupported()).toBe(true); + + ctx = nestingCtx(); + serializePseudoClasses(ctx); + + let rules = injectedRules(); + expect(rules).toContain(':is(.cta-card)[data-percy-hover]'); + // No bare leading `&` (which would resolve to :root page-wide). + expect(rules).not.toMatch(/(^|[\s,{])&/); + }); + + it('treats an &-less nested selector as a descendant of the parent', () => { + withExample( + '' + + '', + { withShadow: false } + ); + expect(nestingSupported()).toBe(true); + + ctx = nestingCtx(); + serializePseudoClasses(ctx); + + expect(injectedRules()).toContain(':is(.menu) a[data-percy-hover]'); + }); + + it('resolves each part of a nested selector list (top-level comma)', () => { + withExample( + '' + + '', + { withShadow: false } + ); + expect(nestingSupported()).toBe(true); + + ctx = nestingCtx(); + serializePseudoClasses(ctx); + + let rules = injectedRules(); + expect(rules).toContain(':is(.btn)[data-percy-hover]'); + expect(rules).toContain(':is(.btn)[data-percy-focus]'); + }); + + it('scopes a complex nested selector, preserving :is() grouping and attributes', () => { + withExample( + '' + + '
x
', + { withShadow: false } + ); + expect(nestingSupported()).toBe(true); + + ctx = nestingCtx(); + serializePseudoClasses(ctx); + + // The :is(.a, .b) list and [data-role] survive intact, prefixed by the + // resolved parent — emitted as a single rule, not split on the inner comma. + expect(injectedRules()).toContain(':is(.box):is(.a, .b)[data-role][data-percy-hover]'); + }); + }); }); From ba16b31a7f66d24bf0b90082534ace250bed149c Mon Sep 17 00:00:00 2001 From: Arumulla Sri Ram Date: Mon, 29 Jun 2026 16:55:04 +0530 Subject: [PATCH 2/2] =?UTF-8?q?refactor(dom):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20nested=20ternary,=20remove=20ticket=20id=20f?= =?UTF-8?q?rom=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- packages/dom/src/serialize-pseudo-classes.js | 12 +++++++----- .../dom/test/serialize-pseudo-classes.test.js | 16 ++++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/dom/src/serialize-pseudo-classes.js b/packages/dom/src/serialize-pseudo-classes.js index 950e4a9d9..023804afa 100644 --- a/packages/dom/src/serialize-pseudo-classes.js +++ b/packages/dom/src/serialize-pseudo-classes.js @@ -364,11 +364,13 @@ function walkCSSRules(ruleList, parentSelector = null) { // (native CSS nesting) becomes the scope for its children — resolve // its own selector against any outer parent first so deeper nesting // composes correctly. - const childParent = atRulePrelude - ? parentSelector - : (rule.selectorText - ? resolveNestedSelector(rule.selectorText, parentSelector) - : parentSelector); + // Default to the current scope (also covers at-rules and nested-decl + // rules that have no selector of their own). A style rule with nested + // children resolves its own selector first and becomes the new scope. + let childParent = parentSelector; + if (!atRulePrelude && rule.selectorText) { + childParent = resolveNestedSelector(rule.selectorText, parentSelector); + } for (const inner of walkCSSRules(rule.cssRules, childParent)) { if (atRulePrelude && inner.selectorText) { result.push({ diff --git a/packages/dom/test/serialize-pseudo-classes.test.js b/packages/dom/test/serialize-pseudo-classes.test.js index 2249a7ef4..ad8cded93 100644 --- a/packages/dom/test/serialize-pseudo-classes.test.js +++ b/packages/dom/test/serialize-pseudo-classes.test.js @@ -1794,14 +1794,14 @@ describe('serialize-pseudo-classes', () => { }); }); - // Regression: PER-9775. Native CSS nesting (emotion / CSS-in-JS) produces - // child rules with `&`-relative selectors. Before the fix, walkCSSRules - // emitted those children with the parent selector stripped, so an - // interactive-pseudo child like `&:hover` was injected as a bare - // `&[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)', () => { + // Regression: native CSS nesting (emotion / CSS-in-JS) produces child rules + // with `&`-relative selectors. Before the fix, walkCSSRules emitted those + // children with the parent selector stripped, so an interactive-pseudo child + // like `&:hover` was injected as a bare `&[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', () => { function nestingCtx() { let ctx = { dom: document,