-
Notifications
You must be signed in to change notification settings - Fork 54
Description
The problem
After upgrading @percy/cli from 1.31.2 to 1.31.8, Percy snapshots produce broken CSS when the page contains CSS nesting (specifically nested @media rules inside a style rule, as used by Tailwind CSS v4).
The CSSOM serialization in @percy/dom relies on cssRule.cssText to serialize CSS rules. For rules that use CSS nesting, the serialized output is missing closing braces }, causing all subsequent CSS rules in the same <style> block to be parsed as nested inside the last unclosed @media query.
This worked in 1.31.2 because styleSheetsMatch() would fail/throw (due to lack of null checks), causing Percy to fall back to preserving the original source CSS. In 1.31.8, the hardened styleSheetsMatch() correctly detects differences and triggers CSSOM serialization — which then produces the malformed output.
Environment
- Node version: v22
@percy/cliversion: 1.31.8 (works correctly on 1.31.2)- Version of Percy SDK you're using:
@percy/ember5.0.0 - OS version: macOS
- Type of shell command-line interface: N/A (CI environment)
Details
The CSS rule that triggers the bug
Tailwind CSS v4 generates a .container utility using CSS nesting:
.container {
width: 100% !important;
@media (width >= 40rem) {
max-width: 40rem !important;
}
@media (width >= 48rem) {
max-width: 48rem !important;
}
@media (width >= 64rem) {
max-width: 64rem !important;
}
@media (width >= 80rem) {
max-width: 80rem !important;
}
@media (width >= 96rem) {
max-width: 96rem !important;
}
}
.m-0 {
margin: 0 !important;
}
/* ... hundreds more utility classes ... */What Percy serializes (broken)
When @percy/dom serializes this via CSSOM (cssRule.cssText), the output becomes:
.container {
width: 100% !important;
@media (width >= 40rem) {
max-width: 40rem !important;
@media (width >= 48rem) {
max-width: 48rem !important;
@media (width >= 64rem) {
max-width: 64rem !important;
@media (width >= 80rem) {
max-width: 80rem !important;
@media (width >= 96rem) {
max-width: 96rem !important;
.m-0 { margin: 0 !important; }
.m-100 { margin: 4px !important; }
.p-400 { padding: 16px !important; }
/* ... all subsequent rules ... */Note: no closing } braces for any of the 5 @media blocks or the .container block.
The impact
The browser parses all subsequent utility classes (.m-0, .p-400, .gap-200, etc.) as being nested inside @media (width >= 96rem). Since Percy renders at a standard viewport width (< 96rem / 1536px), all spacing utility classes are silently ignored, causing widespread visual regressions ("randomly missing padding/margin").
Root cause in @percy/dom
The issue is in serializeCSSOM() in @percy/dom/dist/bundle.js. It serializes stylesheets using:
Array.from(styleSheet.cssRules).map(cssRule => cssRule.cssText).join('\n')Chrome's cssRule.cssText for a CSSStyleRule containing nested CSSMediaRule children does not produce syntactically valid CSS — it omits the closing braces for nested rules. This is likely a Chrome CSSOM quirk/bug, but @percy/dom should not blindly trust cssText output to be valid.
Why this regressed in 1.31.8
In @percy/dom 1.31.2, styleSheetsMatch() lacked null safety:
function styleSheetsMatch(sheetA, sheetB) {
for (let i = 0; i < sheetA.cssRules.length; i++) {
let ruleA = sheetA.cssRules[i].cssText;
let ruleB = sheetB?.cssRules[i]?.cssText;
if (ruleA !== ruleB) return false;
}
return true;
}And styleSheetFromNode() used document.cloneNode() + innerHTML, which could produce null sheets. When sheetB was null, the function would throw, and Percy would fall back to preserving the original source CSS (correct behavior).
In @percy/dom 1.31.8, both functions were hardened:
styleSheetsMatch()now returnsfalse(instead of throwing) when sheets are null or have different lengthsstyleSheetFromNode()usesdocument.implementation.createHTMLDocument()+textContentwith try/catch
This means the comparison now properly completes, detects differences (because the cloned sheet re-parses CSS nesting differently), and triggers CSSOM serialization via cssRule.cssText — which produces the broken output.
Suggested fix
The serializeCSSOM function (and the DOM-walk style handler) should not rely solely on cssRule.cssText for rules that contain nested rules. Possible approaches:
- Recursive serialization: When a
CSSStyleRulehascssRules(nested rules), serialize them recursively with proper brace handling instead of usingcssText - Detect nesting and preserve source: If a style element's rules contain CSS nesting, skip CSSOM serialization and preserve the source CSS
- Validate output: After serialization, check that brace counts are balanced
Code to reproduce issue
Any page that includes Tailwind CSS v4's generated output (which uses CSS nesting for the .container utility) will trigger this bug when snapshotted with @percy/cli >= 1.31.3 (whenever the styleSheetsMatch hardening was introduced).
Minimal reproduction:
- Create an HTML page with a
<style>block containing CSS nesting:
<style>
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.spacing-class {
padding: 16px;
}
</style>
<div class="spacing-class">This should have padding</div>- Take a Percy snapshot with
@percy/cli@1.31.8 - Inspect the serialized DOM — the
.spacing-classrule will be trapped inside the unclosed@media (width >= 96rem)block - Compare with
@percy/cli@1.31.2where the source CSS is preserved correctly