diff --git a/packages/dom/src/serialize-pseudo-classes.js b/packages/dom/src/serialize-pseudo-classes.js index a7ca35069..023804afa 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,19 @@ 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. + // 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({ selectorText: inner.selectorText, @@ -358,7 +386,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..ad8cded93 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: 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, + 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]'); + }); + }); });