Skip to content

fix(packages/sui-critical-css): preserve at-rule context when rebuilding covered CSS - #1995

Merged
tomasmax merged 4 commits into
masterfrom
fix/critical-css-preserve-at-rule-context
Sep 23, 2026
Merged

tomasmax merged 4 commits into
masterfrom
fix/critical-css-preserve-at-rule-context

Conversation

@tomasmax

@tomasmax tomasmax commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

The problem

extractCSSFromUrl builds the critical CSS by slicing the raw bytes of every covered range out of the stylesheet source and concatenating them:

for (const entry of coverage) {
  for (const range of entry.ranges) {
    coveredCSS += entry.text.slice(range.start, range.end)
  }
}

Chrome's CSS coverage ranges cover the style rule only. The @media, @layer, @supports or @container block the rule lives in is never part of the range, so the concatenation loses every at-rule wrapper. The resulting critical CSS is completely flat.

Measured on a production page of ours (coches.net, mobile): 0 @layer and 0 @media in 53508 chars of inlined critical CSS, extracted from stylesheets that are heavily layered.

Two things break:

  1. Nothing is layered any more, so ties that the cascade layer order used to decide now fall back to source order. A @layer base reset extracted after a @layer utilities rule of the same specificity silently starts winning.
  2. Media queries are flattened, so rules only meant for the extraction viewport (360px in our config) apply at every width.

This bit us for real. Our app inlines a Tailwind v4 preflight in @layer base and consumes a design system that ships its utilities in @layer utilities. In the layered stylesheet the utilities win. In the flattened critical CSS the preflight's border: 0 solid and padding: 0 beat border-width and padding-inline utilities of identical specificity, so on mobile an outlined button lost both its ring and its horizontal padding, and a circular icon button rendered 28x32 instead of 32x32 (it sets --button-border-width: 2px, so losing the border costs it 4px of width while --button-height pins the height).

It is invisible from any desktop check: @s-ui/critical-css-middleware returns early unless deviceType === 'mobile', and nothing ever removes the injected <style id="critical">, so it is the permanent mobile state rather than a flash of unstyled content.

The fix

Parse the stylesheet with postcss instead of slicing bytes, then keep every declaration-holding node whose source range intersects a covered range, together with its whole chain of ancestor at-rules.

Three deliberate decisions:

  • @layer statements are kept unconditionally and hoisted to the top. They are statements, not style rules, so coverage never marks them as used, yet they are the only thing that fixes the order of the layers. They are hoisted because coverage entries are not guaranteed to be in document order, so a statement could otherwise land after a rule that already registered the layers in the wrong order.
  • @import and @charset are not preserved. An inline @import would fire a network request from the critical path and would be invalid after other rules anyway.
  • A stylesheet postcss cannot parse falls back to the old byte-slicing behaviour, so one bad stylesheet cannot fail a whole extraction. The fallback loses at-rule context, so it logs loudly with the URL.

A clean-css bug that this fix has to work around

The second commit exists because restoring the at-rule context exposed a parser bug in clean-css@5.3.3, which this package runs at level 2.

A bodyless @layer statement naming more than one layer corrupts everything after it. The statement is dropped and so is every declaration of the rule that follows it:

in : @layer a,b;.x{color:red}.y{color:blue}
out: .y{color:#00f}

in : @layer a;.x{color:red}.y{color:blue}
out: @layer a;.x{color:red}.y{color:#00f}

It reproduces at every optimization level, including level: 0, and one name is enough to avoid it. That matters here because @layer theme,base,components; is exactly how a Tailwind sheet opens, and the rule right after it in our AppStyles.css is the :root,[data-theme=default] design token block. The first version of this PR therefore shipped a critical CSS that was 8-17% smaller than the old one, having silently dropped 320 custom properties.

So each layer name is now lifted as its own statement. @layer a;@layer b; establishes the same order as @layer a,b;, and parses correctly. A spec minifies the rebuilt output with clean-css to keep the regression locked down.

Verified on a full application

Extracted against a local production build of our app, one coverage pass per route rebuilt both ways from the same coverage data, each minified with new CleanCSS({level: 2}):

route old new delta @layer @media
Home 52752 53583 +1.6% 0 → 12 0 → 6
AdsList 63007 63772 +1.2% 0 → 12 0 → 7
Detail 68347 69159 +1.2% 0 → 12 0 → 7
News 46585 47105 +1.1% 0 → 11 0 → 5
TechnicalDetailsHome 34809 35329 +1.5% 0 → 11 0 → 5

The output grows, since the wrappers that were being dropped are real bytes and the critical CSS is inlined in every mobile HTML response. 1.1-1.6% on a full page, because a wrapper amortises across every rule that shares it.

Comparing the two outputs declaration by declaration, keyed on selector|property so a rule surviving with fewer declarations is caught too, nothing present in the old output is missing from the new one on any of the 5 routes. One pair is added on each: a background-color that clean-css used to fold into a shorthand and can no longer fold across a layer boundary.

Against the real production critical CSS for the same route, the 29 declarations it has and the local one does not all belong to two components the local page does not render at all. They are identical in the old and the new output, so they are page content, not a regression.

Finally, the end-to-end check. Local production server, iPhone UA, critical CSS installed, stylesheets loaded, computed styles of the two topbar CTAs:

old critical CSS  borderTopWidth: 0px    <- reproduces production
new critical CSS  borderTopWidth: 1px    <- the ring is back

Tests

packages/sui-critical-css had no test suite. This adds one (sui-test server, 17 specs) covering the @media / @layer / nested @supports > @media > @layer chains, dropping uncovered siblings while keeping their wrapper, dropping at-rules with nothing covered, @layer statement preservation, hoisting, splitting and the two cases where a statement must not be hoisted, at-rules that hold declarations directly such as @font-face, partial range overlap, and both branches of the parse fallback.

17 passing (11ms)

They also had to be wired into CI. test:server is what the Tests step of .github/workflows/main.yml runs, and its glob only listed sui-test-contract and sui-js-compiler, so these specs would never have executed there. sui-critical-css is added to the brace list, taking the run from 18 to 35 specs.

sui-js-compiler has a spec that fails intermittently in this suite (should exclude all the files matching the passed patterns when the "ignore" option exists). It is unrelated and pre-existing: on the branch point, without any of these changes, it failed in 2 of 4 consecutive runs.

Not hoisting a guarded @layer statement

Hoisting statements to the top is what makes the rebuild independent of the order Chrome reports coverage entries in, but not every statement can be moved. Inside @supports or @media it only registers its layers while that condition holds, and inside another @layer it names sublayers of it. Hoisting those dropped the guard, which is the same loss of at-rule context this PR exists to prevent, one level up.

in    @supports (color:lab(0 0 0)){@layer a,b;}@layer a{.used{color:red}}
before  @layer a;@layer b;@layer a{.used{color:red}}          <- condition gone
after   @supports (color:lab(0 0 0)){@layer a;@layer b;}@layer a{.used{color:red}}

Only top-level statements are hoisted now; a nested one stays in place and is merely split. No stylesheet we have measured emits one, so this is a correctness gap rather than an observed bug, but it is cheap to close and both cases are pinned by specs.

Rollout

postcss@8.4.31 is added as a dependency (it was already resolvable at the monorepo root) and package-lock.json now records it. npm ci tolerated the omission, which is why CI was green without it, but every other dependency change in this repo keeps the lockfile in step. Only that one entry is touched: regenerating the whole file drags in nine unrelated version bumps that the [skip ci] release commits left behind.

Consumers that install this package unpinned get it on the next build with no lockfile change, ours does via npm install @s-ui/critical-css --no-save in a Dockerfile. Publishing with --tag next first is still the safer path, though the size question the first version of this PR left open is now answered.

One thing consumers should re-check after upgrading: routes between the extraction viewport and the next breakpoint. Today's flattening accidentally applies rules at widths they were never extracted for, so restoring the @media wrappers can legitimately remove styling that a page was leaning on by accident.

🤖 Generated with Claude Code

…ing covered CSS

Chrome's CSS coverage reports ranges that cover the style rule only, never the
`@media`, `@layer`, `@supports` or `@container` block it lives in. Concatenating
the raw byte slices therefore produced completely flat CSS: measured on a
production page, 0 `@layer` and 0 `@media` in 51841 chars of critical CSS.

Two consequences. Nothing is layered any more, so ties that the layer order used
to decide fall back to source order and a `@layer base` reset extracted after a
`@layer utilities` rule of the same specificity silently starts winning. And
media queries are flattened, so rules extracted at the 360px extraction viewport
apply at every width.

Parse the stylesheet with postcss instead of slicing bytes, and keep every
declaration-holding node whose source range intersects a covered range together
with its whole chain of ancestor at-rules. `@layer a, b, c;` statements are never
covered (they are not style rules) yet they are the only thing that fixes the
layer order, so they are kept unconditionally and hoisted to the top: coverage
entries are not guaranteed to be in document order. A stylesheet postcss cannot
parse falls back to the old behaviour and logs why.

Adds the package's first test suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tomasmax
tomasmax force-pushed the fix/critical-css-preserve-at-rule-context branch from 94c65c5 to cdb2ee3 Compare September 18, 2026 11:59
tomasmax and others added 2 commits September 22, 2026 10:59
…r clean-css

A bodyless `@layer` naming more than one layer is mis-parsed by clean-css 5.3.3:
it loses the statement AND every declaration of the rule that follows it, so
`@layer a,b;.x{color:red}` minifies to nothing at all.

That is exactly how a Tailwind sheet opens, and measured against a real app it
silently dropped the 320 custom properties of the `:root,[data-theme=default]`
token block sitting right after the statement. Emitting one statement per layer
name establishes the same order and parses correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the at-rule that guards them

Hoisting every `@layer` statement to the top makes the rebuild independent of the
order Chrome reports coverage entries in, but a statement is not always safe to
move: inside `@supports` or `@media` it only registers its layers while that
condition holds, and inside another `@layer` it names sublayers of it. Hoisting
those dropped the guard, which is the same loss of at-rule context this file
exists to prevent, just for statements instead of style rules.

Only top-level statements are hoisted now. A nested one stays where it is and is
merely split, so `@supports (color:lab(0 0 0)){@layer a,b;}` rebuilds as
`@supports (color:lab(0 0 0)){@layer a;@layer b;}` instead of registering the
order unconditionally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tomasmax
tomasmax force-pushed the fix/critical-css-preserve-at-rule-context branch 2 times, most recently from 57990a2 to b2e23d3 Compare September 22, 2026 09:34
@tomasmax tomasmax closed this Sep 22, 2026
@tomasmax tomasmax reopened this Sep 22, 2026
… lockfile

`test:server` is what the Tests step of `.github/workflows/main.yml` runs, and its
glob only listed `sui-test-contract` and `sui-js-compiler`, so the 17 specs added
with the at-rule-context fix never executed in CI. A regression in `covered-css.js`
would have gone unnoticed. Adding the package to the brace list takes the run from
18 to 35 specs.

`package-lock.json` also never got the `postcss` dependency the same fix added to
`packages/sui-critical-css/package.json`. `npm ci` tolerates it because postcss
8.4.31 already resolves at the root, but every other dependency change in this repo
keeps the lockfile in step. Only that one entry is touched: regenerating the whole
file would drag in nine unrelated version bumps that the release commits left
behind, since they bypass CI and never sync the lockfile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tomasmax
tomasmax force-pushed the fix/critical-css-preserve-at-rule-context branch from b2e23d3 to ef7f1d2 Compare September 22, 2026 10:30
@tomasmax
tomasmax merged commit 3a97093 into master Sep 23, 2026
2 checks passed
@tomasmax
tomasmax deleted the fix/critical-css-preserve-at-rule-context branch September 23, 2026 07:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants