Skip to content

CSSOM serialization breaks CSS nesting (missing closing braces) — regression in 1.31.8 #2133

@ArnaudWeyts

Description

@ArnaudWeyts

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/cli version: 1.31.8 (works correctly on 1.31.2)
  • Version of Percy SDK you're using: @percy/ember 5.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 returns false (instead of throwing) when sheets are null or have different lengths
  • styleSheetFromNode() uses document.implementation.createHTMLDocument() + textContent with 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:

  1. Recursive serialization: When a CSSStyleRule has cssRules (nested rules), serialize them recursively with proper brace handling instead of using cssText
  2. Detect nesting and preserve source: If a style element's rules contain CSS nesting, skip CSSOM serialization and preserve the source CSS
  3. 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:

  1. 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>
  1. Take a Percy snapshot with @percy/cli@1.31.8
  2. Inspect the serialized DOM — the .spacing-class rule will be trapped inside the unclosed @media (width >= 96rem) block
  3. Compare with @percy/cli@1.31.2 where the source CSS is preserved correctly

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions