fix(packages/sui-critical-css): preserve at-rule context when rebuilding covered CSS - #1995
Merged
Merged
Conversation
tomasmax
requested review from
andresin87,
andresz1,
ferransimon,
kikoruiz and
sui-bot
as code owners
September 18, 2026 11:43
…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
force-pushed
the
fix/critical-css-preserve-at-rule-context
branch
from
September 18, 2026 11:59
94c65c5 to
cdb2ee3
Compare
…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
force-pushed
the
fix/critical-css-preserve-at-rule-context
branch
2 times, most recently
from
September 22, 2026 09:34
57990a2 to
b2e23d3
Compare
… 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
force-pushed
the
fix/critical-css-preserve-at-rule-context
branch
from
September 22, 2026 10:30
b2e23d3 to
ef7f1d2
Compare
kikoruiz
approved these changes
Sep 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
extractCSSFromUrlbuilds the critical CSS by slicing the raw bytes of every covered range out of the stylesheet source and concatenating them:Chrome's CSS coverage ranges cover the style rule only. The
@media,@layer,@supportsor@containerblock 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@layerand 0@mediain 53508 chars of inlined critical CSS, extracted from stylesheets that are heavily layered.Two things break:
@layer basereset extracted after a@layer utilitiesrule of the same specificity silently starts winning.This bit us for real. Our app inlines a Tailwind v4 preflight in
@layer baseand 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'sborder: 0 solidandpadding: 0beatborder-widthandpadding-inlineutilities 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-heightpins the height).It is invisible from any desktop check:
@s-ui/critical-css-middlewarereturns early unlessdeviceType === '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:
@layerstatements 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.@importand@charsetare not preserved. An inline@importwould fire a network request from the critical path and would be invalid after other rules anyway.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
@layerstatement naming more than one layer corrupts everything after it. The statement is dropped and so is every declaration of the rule that follows it: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 ourAppStyles.cssis 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}):@layer@mediaThe 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|propertyso 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: abackground-colorthat 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:
Tests
packages/sui-critical-csshad no test suite. This adds one (sui-test server, 17 specs) covering the@media/@layer/ nested@supports > @media > @layerchains, dropping uncovered siblings while keeping their wrapper, dropping at-rules with nothing covered,@layerstatement 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.They also had to be wired into CI.
test:serveris what the Tests step of.github/workflows/main.ymlruns, and its glob only listedsui-test-contractandsui-js-compiler, so these specs would never have executed there.sui-critical-cssis added to the brace list, taking the run from 18 to 35 specs.sui-js-compilerhas 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
@layerstatementHoisting 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
@supportsor@mediait only registers its layers while that condition holds, and inside another@layerit 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.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.31is added as a dependency (it was already resolvable at the monorepo root) andpackage-lock.jsonnow records it.npm citolerated 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-savein a Dockerfile. Publishing with--tag nextfirst 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
@mediawrappers can legitimately remove styling that a page was leaning on by accident.🤖 Generated with Claude Code