Skip to content

fix(sui-critical-css): carry @property, @font-face and referenced @keyframes into the rebuilt CSS - #1997

Merged
tomasmax merged 1 commit into
masterfrom
fix/critical-css-definition-at-rules
Sep 29, 2026
Merged

tomasmax merged 1 commit into
masterfrom
fix/critical-css-definition-at-rules

Conversation

@tomasmax

Copy link
Copy Markdown
Collaborator

Problem

Chrome's CSS coverage reports ranges that cover style rules only. 1.34.0 used that fact to restore the at-rules a covered rule is nested in, which fixed the flattening of @media / @layer / @supports. It does nothing for an at-rule that defines something instead of styling something: @property, @font-face and @keyframes match no element, so coverage never reports a range for them and no amount of nesting context brings them back.

Measured on a 425 KB Tailwind v4 production sheet, rebuilding it with the coverage of 221 rules:

source sheet rebuilt on master
@property 29 0
@font-face 5 0
@keyframes 30 0

The @property gap changes what the browser paints. Tailwind v4 emits border-style: var(--tw-border-style) in its preflight and relies on @property --tw-border-style for the initial value. Without the registration the var() resolves to the empty token, which is invalid at computed-value time, so border-style computes to its initial none — and with border-style: none the computed border-width is 0px even though the utility's 1px applied and won the cascade. Every button styled that way renders with no border until the full stylesheet arrives. Because consumers make the real sheets async (media="print" + onload) as soon as the critical CSS is non-empty, that is a visible jump, not a progressive paint. Injecting nothing but @property --tw-border-style into the served critical CSS restores the 1px and the button's height.

Three --tw-* custom properties were referenced by the critical CSS and defined nowhere in it: --tw-border-style, --tw-ease, --tw-outline-style.

What this does

  • @property and @font-face are always kept, which is the same exception @layer statements already have and for the same reason: the coverage cannot mark them, and they are the only thing that makes the rules depending on them behave. Both are cheap and safe to keep when unused — a registration only sets an initial value, and a @font-face never triggers a download unless a matched rule asks for the family.
  • @keyframes is only kept when a declaration that survived the rebuild names it (animation or animation-name, vendor prefixes included). Unlike the other two it is not bounded in size, so keeping all of them unconditionally would be real bloat. Only the direct declarations of the kept nodes are scanned, so a kept @media does not drag in the keyframes named by the children that were discarded from it.

Cost

Same sheet and same coverage as the table above:

master this branch
bytes (unminified) 33 279 36 913
@property 0 29 / 29
@font-face 0 5 / 5
@keyframes 0 1 / 30

+3.6 KB unminified, and 29 of the 30 @keyframes are still dropped.

Tests

8 new specs in test/server/covered-cssSpec.js (38 passing): a @property kept with nothing else covering it, a @property nested in @layer properties, an uncovered @font-face, @keyframes reached through animation / animation-name / a vendor prefix, a @keyframes nobody names, and a @keyframes named only by a rule that was discarded.

Known limitation, out of scope here

Tailwind's fallback for browsers without @property support is a @layer properties { @supports (…) { *, ::before, … { --tw-*: initial } } } block whose @supports condition is false in Chromium. A style rule inside a false @supports can never be covered, so that block still cannot be carried over and Safari < 16.4 stays exposed. Keeping it would mean keeping rules on a "this at-rule is a polyfill" heuristic rather than on coverage, which is a different discussion.

🤖 Generated with Claude Code

…e rebuilt CSS

Chrome's CSS coverage reports ranges that cover style rules only. 1.34.0 used that to restore the
at-rules a covered rule is NESTED IN, but an at-rule that defines something instead of styling
something matches no element, so it is never attributable to a range and no amount of nesting
context brings it back. Measured on a Tailwind v4 app: the rebuilt CSS had 0 `@property`,
0 `@font-face` and 0 `@keyframes` against 29, 5 and 30 in the source sheet.

`@property` is the one that changes what the browser paints. With the registration missing,
`border-style: var(--tw-border-style)` resolves to the empty token, which is invalid at
computed-value time, so `border-style` computes to its initial `none` — and with `border-style:
none` the computed `border-width` is `0px` even though the utility's `1px` applied and won the
cascade. Every button styled that way renders with no border until the full stylesheet arrives,
and since consumers make the real sheets async whenever the critical CSS is non-empty, that is a
visible jump rather than a progressive paint.

So:

- `@property` and `@font-face` are always kept, the same exception `@layer` statements already
  have. Both are cheap and safe when unused: a registration only sets an initial value, and a
  `@font-face` never triggers a download unless a matched rule asks for the family.
- `@keyframes` is not bounded in size, so it is only kept when a declaration that survived the
  rebuild names it (`animation` or `animation-name`, vendor prefixes included). Scanning only the
  DIRECT declarations of the kept nodes, so a kept `@media` does not drag in the keyframes named
  by the children that were discarded from it.

Measured on the same 425 KB production sheet with the coverage of 221 rules: 33279 -> 36913 bytes
unminified (+3.6 KB), 29 of 29 `@property`, 5 of 5 `@font-face`, and 1 of 30 `@keyframes` kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tomasmax
tomasmax merged commit 8f0f5cc into master Sep 29, 2026
2 checks passed
@tomasmax
tomasmax deleted the fix/critical-css-definition-at-rules branch September 29, 2026 13:44
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.

1 participant