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
38 changes: 35 additions & 3 deletions packages/dom/src/serialize-pseudo-classes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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,
Expand All @@ -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;
Expand Down
105 changes: 105 additions & 0 deletions packages/dom/test/serialize-pseudo-classes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<style>.cta-card { color: black; &:hover { background-color: blue; } }</style>' +
'<button class="cta-card" id="cta">x</button>',
{ 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(
'<style>.menu { a:hover { color: red; } }</style>' +
'<nav class="menu"><a href="#" id="lnk">x</a></nav>',
{ 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(
'<style>.btn { &:hover, &:focus { outline: 1px solid red; } }</style>' +
'<button class="btn" id="b1">x</button>',
{ 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(
'<style>.box { &:is(.a, .b)[data-role]:hover { color: green; } }</style>' +
'<div class="box a" data-role id="bx">x</div>',
{ 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]');
});
});
});
Loading