Repository navigation
Conversation
`@media screen and (min-width:0\0){}` is an IE 9 and 10 hack. The block
is no feature this parser knows, and the build failed with `Invalid
media query`. Media Queries 4 calls such a block `<general-enclosed>`:
it evaluates to unknown and is not a parse error.
A closed `( .. )` or `name( .. )` block that is no known condition and
holds no error token is now `MediaCondition::Unknown` or
`ContainerCondition::Unknown`. The variant holds the source text and
prints it as written. A group that starts with a function keeps its
parentheses, so `(layer(x))` after an `@import` URL stays a media query.
The scan that reads such a block is `Parser::expect_no_error_token`. It
returned after the first nested block, so `@supports` took
`(a: b(c) d)` for an error. It now reads to the end of the block.
|
Status
|
|
Updated 11:53 AM PT - Oct 24th, 2026
❌ @robobun, your commit 8537115 has 1 failures in
The baseline build crashed or failed tests under 🧪 To try this PR locally: bunx bun-pr 46756That installs a local version of the PR into your bun-46756 --bun |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
General-enclosed condition representation src/css/media_query.rs, src/css/rules/container.rs |
QueryCondition and the media and container condition types now support storing, serializing, and cloning preserved general-enclosed source text. |
Condition parsing and validation src/css/media_query.rs, src/css/css_parser.rs |
The parser handles function and parenthesized conditions, preserves eligible general-enclosed content, and tracks parsed blocks for warnings. It also scans nested blocks for error tokens and reports malformed operations. |
Parser and bundler regression coverage test/js/bun/css/css.test.ts, test/js/bun/css/nested-function-backtracking.test.ts, test/bundler/esbuild/css.test.ts |
Tests cover preserved media and container conditions, emitted CSS, warnings, malformed inputs, and nesting limits. The bundler tests also check external imports and URL-like condition text. |
Suggested reviewers: jarred-sumner
Priority:
Severity of issue fixed: Medium
Merge Risk: 
Minimal · up to 85371
-
No merge-blocking issue was established. The change appears ready for normal validation and maintainer approval.
Pre-merge checks: 
4
-
Check name Status Explanation Title check The title clearly and concisely describes the main change: preserving unknown general-enclosed media and container conditions as written. Linked Issues check Directly linked issue #45237requires that@container scroll-state(stuck: top)is parsed and emitted, not rejected withUnexpected token: scroll-state(. Whole-PR diff (merge base 179664e to head …Out of Scope Changes check All changes support one shared <general-enclosed>path for media, container, import, and supports conditions. Changes insrc/css/media_query.rsaddMediaCondition::Unknown,parse_function, `pa…Description check The description is complete and relevant. It explains the problem, implementation, behavior changes, error handling, warnings, verification, performance impact, tests, and known follow-up concerns. It… Full details: Linked Issues check
-
Explanation
Directly linked issue
#45237requires that@container scroll-state(stuck: top)is parsed and emitted, not rejected withUnexpected token: scroll-state(. Whole-PR diff (merge base 179664e to head 8537115):parse_functioninsrc/css/media_query.rsroutes any non-style()function token toparse_general_enclosed. A closed block with no error token becomesContainerCondition::Unknown(&'static [u8])(src/css/rules/container.rs).to_csswrites the kept source bytes, so the rule is emitted as written. Errors remain for unclosed blocks, error tokens, and an operand missing afterand(operand_error). Test coverage is intest/js/bun/css/css.test.tsandtest/bundler/esbuild/css.test.ts. The issue suggests porting typed scroll-state parsing (stuck,snapped,scrollable). That is a suggestion, and the issue's stated expected behavior is only parse-and-emit. The typed port is therefore not a requirement here. Limits: I did not execute the test suite. The result rests on source reading of the diff and the PR's reported test results.
Full details: Out of Scope Changes check
-
Explanation
All changes support one shared
<general-enclosed>path for media, container, import, and supports conditions. Changes insrc/css/media_query.rsaddMediaCondition::Unknown,parse_function,parse_general_enclosed,starts_with_function, andoperand_error. Changes insrc/css/rules/container.rsaddContainerCondition::Unknown. Changes insrc/css/css_parser.rsupdateexpect_no_error_tokenso it scans past nested blocks, which the shared scan needs. The@supportschange follows from the same function. Tests cover the kept forms and the errors that remain. One point differs from the PR description. The description says unknown conditions build with no warning. The code addswarn_general_enclosed, which warns once per query that holds an unknown condition. The warning belongs to the same feature, so it does not count as unrelated scope. The author should reconcile the description with the code. Other notes in the PR, such as theoperand_errorguard and theMediaList::parsedefect, are related to this feature and are not unrelated changes.
Full details: Description check
-
Explanation
The description is complete and relevant. It explains the problem, implementation, behavior changes, error handling, warnings, verification, performance impact, tests, and known follow-up concerns. It does not use the template headings exactly, but it provides the required information in equivalent sections.
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands.
Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
-
- Commit to this branch
- Create a new PR
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the &'static [u8] stored in the new Unknown variants: it comes from the pre-existing slice_from_cloned and is held the same way as the @ supports unknown-condition, @ property initial-value and @ font-face unicode-range slices already are, so it introduces no new lifetime story. Given the author's own note that this is a behaviour change (unknown blocks go from error to silently kept), a maintainer should weigh that trade-off.
Extended reasoning...
The diff adds a Media Queries 4 general-enclosed fallback to the CSS media/container condition parser (src/css/media_query.rs, src/css/rules/container.rs), removes an early return in Parser::expect_no_error_token in src/css/css_parser.rs that also changes @ supports output, and adds tests. It touches no auth, crypto or injection surface; it is a parser for untrusted stylesheet bytes, and the detached-borrow storage reuses an existing helper with the same ownership model as other at-rule preludes. Five confirmed findings are posted inline, and the change is an intentional behaviour change that the PR itself says needs a maintainer decision, so it is not approvable here.
| @@ -3308,7 +3308,6 @@ impl<'a> Parser<'a> { | |||
| match tok { | |||
| Token::Function(_) | Token::OpenParen | Token::OpenSquare | Token::OpenCurly => { | |||
| self.parse_nested_block(|i| i.expect_no_error_token())?; | |||
There was a problem hiding this comment.
🟡 (optional) Stylesheets that built on the base branch now print different @ supports selector(..) and @ import .. supports(..) text, with trailing whitespace and comments kept even under --minify. Removing the early return at src/css/css_parser.rs:3310 makes expect_no_error_token scan to the closing ), so the slice taken at supports.rs:339 and supports.rs:320 now ends at ) instead of right after the first nested block. Fix: trim trailing whitespace/comments from the slice at both slice_from_cloned sites (or stop the slice at the last non-whitespace token) so the Selector and Declaration text matches base for every input that already built.
Why this was flagged
Input @ supports selector(:is(a) ) {} or @ import "a.css" supports(transform: rotate(1deg) ); reaches expect_no_error_token through supports.rs:339 (Selector) and supports.rs:319 (Declaration via css_parser.rs:1194). On base the loop returned Ok right after the first nested block (:is( / rotate(), so slice_from_cloned at supports.rs:340 / supports.rs:320 ended at that block's ) and the trailing or /* c */ was dropped by parse_entirely's expect_exhausted. With the diff at src/css/css_parser.rs:3310 the loop continues until the stop_before ), so the slice now includes the trailing whitespace and any comment, and the printer writes it verbatim (supports.rs:200 write_bytes) in minified output. This runs for every @ supports/@ import supports with a function value in every build; it is a printed-output regression for input that was valid on base. Remedy: trim trailing whitespace/comments at the two slice sites.
Verification: Base src/css/css_parser.rs:3310 returned right after the first nested block, so slice_from_cloned(p) at supports.rs:340 / :320 ended at that block's ); expect_exhausted (css_parser.rs:3630-3649) skipped trailing or /* c */. Now the loop (3309-3311) consumes them, so the slice is :is(a) and to_css writes it raw via write_bytes (supports.rs:162, :200) under minify.
There was a problem hiding this comment.
Not changed. This is the output of #38552, which is the same one-line change and pins selector(:is(a) ) and supports(transform:translate(1px) ) with the space. The description lists it under "@supports", and a test here pins the same text so the two PRs agree. A trim at the two slice_from_cloned sites in supports.rs is possible, but it should be decided in one place for both PRs.
| // A group that starts with a function is a range, such as | ||
| // `(calc(1px + 2px) <= width)`, or it is kept whole. Without its | ||
| // parentheses `(layer(x))` would be a layer after an `@import` URL. | ||
| if let Ok(inner) = parse_query_condition_with_options::<C>(i, flags, options) |
There was a problem hiding this comment.
🟣 pre-existing, not blocking: pre-existing: a valid query such as @ media screen and ((color) or (hover)) is now kept verbatim as MediaCondition::Unknown instead of being parsed, so it is never minified or downleveled. parse_paren_block at src/css/media_query.rs:1676 hands the nested parse the caller's flags, which lack ALLOW_OR after a media type, so the inner or errors at src/css/media_query.rs:1555 and the block falls to parse_general_enclosed. Fix: parse the content of a ( .. ) group with flags | QueryConditionFlags::ALLOW_OR (as lightningcss does), so or inside parentheses is accepted for every QueryCondition, while keeping top-level or after a media type an error. The base errors on this input; after this PR it builds, but features inside the group are not normalized.
A small fix can ride a push you are already making; otherwise a short reply is enough.
Why this was flagged
Input @ media screen and ((min-width: 100px) or (hover)) {.a{top:1px}} reaches MediaQuery::parse at src/css/media_query.rs:1479-1484 with QueryConditionFlags::empty() because a media type was given. parse_paren_block (src/css/media_query.rs:1676) calls parse_query_condition_with_options with those same flags; after (min-width: 100px) the or operator trips the !flags.allow_or() && operator == Operator::Or check at src/css/media_query.rs:1555 and returns an error. The closure resets and tries parse_feature_with_options on (min-width: 100px) or (hover), which fails, so parse_general_enclosed (src/css/media_query.rs:1701) keeps the whole group as Unknown. Media Queries 4 defines <media-in-parens> = ( <media-condition> ) | ... where <media-condition> allows or, so the input is valid CSS. On the base branch this input is a hard Invalid media query error; after this PR it builds but the group is printed as source text: (min-width: 100px) is not rewritten to (width>=100px) under --minify and range syntax inside the group is not downleveled for older targets.
Verification: pre-existing (the base rejects this valid query outright; the PR turns that error into silent verbatim keeping). Trigger: any @ media <type> and ((...) or (...)) query. parse_paren_block (src/css/media_query.rs:1676) hands flags, empty after a media type, to the nested group; the inner or hits the allow_or check at src/css/media_query.rs:1555, so parse_general_enclosed returns MediaCondition::Unknown.
There was a problem hiding this comment.
| loop { | ||
| match parse_parens_or_function::<C>(input, flags, options) { | ||
| Ok(operand) => conditions.push(operand), | ||
| Err(error) => return Err(operand_error(input, &conditions, error)), |
There was a problem hiding this comment.
🟣 pre-existing, not blocking: A stylesheet with a truncated media query after and still builds with exit 0 and the rule applies to every medium, unless one kept operand happens to be unknown. operand_error at src/css/media_query.rs:1588 only converts the end_of_input into invalid_media_query when kept holds a general-enclosed operand; for @ media (color) and { the same end_of_input reaches MediaList::parse at src/css/media_query.rs:1437 which treats it as the end of the list and drops the query. Fix: make MediaList::parse distinguish a query that failed mid-way from a list that ended, so every and/or with a missing operand errors, and drop the has_general_enclosed special case.
A small fix can ride a push you are already making; otherwise a short reply is enough.
Why this was flagged
Input: @ media (color) and {.c{top:1px}} or @ media screen and {.c{top:1px}} passed to bun build. parse_query_condition_with_options reaches the loop at src/css/media_query.rs:1567 and calls parse_parens_or_function, which hits the { delimiter and returns end_of_input. operand_error (src/css/media_query.rs:1588-1601) returns the error unchanged because no kept operand is MediaCondition::Unknown. MediaList::parse at src/css/media_query.rs:1436-1442 matches end_of_input and breaks, leaving an empty media list, so the rule is emitted unconditionally and --minify prints .c{top:1px} with exit 0. The base branch behaves the same for these inputs, but the PR adds a guard that fixes only the subset containing an Unknown operand and leaves the typed case; the PR text itself calls the MediaList::parse behaviour a defect and defers it. The correct fix lives at MediaList::parse so that all populations get the same error, after which operand_error and has_general_enclosed can be deleted.
Verification: The comment above operand_error (src/css/media_query.rs:1591-1592, "MediaList::parse takes end_of_input for the end of the list and drops the query") and the PR text ("The defect in MediaList::parse is reported separately") describe exactly this, and the stated bound is accurate: the guard only covers queries that contain a kept general-enclosed operand.
There was a problem hiding this comment.
Agreed that the fix belongs in MediaList::parse. It is a defect on main for every truncated query, and it is being fixed separately. This PR only keeps itself from adding a new way into it: before this change a query with an unknown block could not reach that path, because the block was an error. When MediaList::parse tells a short query from the end of the list, operand_error can be deleted.
A typo or a preprocessor leftover such as `(max-width: $tablet)` was an error. As a `<general-enclosed>` block it now builds, so the bundler reports each query that holds one, as it reports an unknown at-rule that it keeps. A range that starts with a function is known and is not reported. `ParserInput` counts the blocks that become `<general-enclosed>`. A query that made none does not walk its condition. The foundation fixture keeps its IE hack commented out: doesnt_crash fails on any log entry.
|
#46869 is the Checked on a build of both: this PR applies on top of #46869 with no conflict, and the tests of both PRs pass on that tree. #46869 also stops |
### What does this PR do? Consolidates the open pull requests on the CSS parser (`src/css`: parsing, minifying, lowering, printing, CSS modules) into one. 179 open pull requests were in scope. Each was judged by one rule: **the output must mean to a browser what the input meant.** Every claim was run through a build of `main`, a build with the change, lightningcss 1.33 and Chromium 156 (CSSOM, computed values, painted pixels for colors and gradients), and counted against 10,116 real stylesheets: the published CSS of about 190 popular npm packages, plus stylesheets from open source repositories. 107 land here, whole or in part, most of them cut down. The source diff is net negative. Of the 10,116, 9,509 are stylesheets that someone ships. The rest are test fixtures of other tools (521), Tailwind v4 source that needs its plugin (59) and SCSS, PostCSS or template source (27). | 9,509 shipped stylesheets | `main` | this branch | |---|---|---| | fail to build | 71 | 35 | | ... of those, valid CSS that Bun rejects | | 0 | | minified output reads differently in Chromium than the input | 552 | 2 | | CSS modules with a name that is referenced but not defined, or exported but not defined | 166 | 0 | | all 10,116 | `main` | this branch | |---|---|---| | fail to build | 192 | 141 | | minified output reads differently in Chromium than the input | 593 | 15 | | build on `main`, fail here | | 0 | | right on `main`, wrong here (minified, and at the default targets) | | 0 | | size of the minified output, no targets | 162.32 MB | 162.47 MB (+0.093%) | | size of the output at the default targets | 224.1 MB | 210.2 MB (-6.2%) | A syntax error is a build error, with one exception: the IE star hack `*zoom: 1` is skipped, because tachyons and handsontable ship it. The 35 shipped stylesheets that fail are invalid CSS: 28 have a selector that Chromium drops (semantic-ui-css, swiper, @ionic/core, Adobe Spectrum, xp.css), 2 an `@import` after other rules, and one each `@supports not X and Y`, a nested `@keyframes`, a stray comma, a selector list in `::slotted()`, `@media screen\0` (govuk-frontend). The 2 that read differently fold a `color-mix()` inside a gradient (see the known gaps). The growth of the minified output is rules and declarations that `main` deleted by mistake: 87 KB of rules, 45 KB of prefixed declarations that only browser targets can rule out. #### Popular CSS that `main` breaks today - normalize.css, modern-normalize, sanitize.css: `font-family: monospace, monospace` is collapsed to `monospace`, which changes the font size from 16px to 13px (#46662). - CKEditor 5, Tabler, Carbon, VitePress, Ionic, PatternFly, Excalidraw: a rule of custom properties is deleted as a "duplicate" of a later rule with the same selector and other custom properties. 37 stylesheets lose 1,681 custom properties (#33684). - Material Components, daisyUI, DocSearch, plyr: a `var()` size loses to its own fallback (#42101). - nextra, daisyUI, CKEditor: declarations written after a nested rule are moved above it (#45462). - antd 4, purecss, govuk-frontend: browser hacks in selector lists start to apply everywhere (#42100). - foundation-sites, flatpickr, 98.css, blueprintjs, scalar, react-datepicker: the build fails on valid CSS (#46756, #38552, #38534). tachyons, handsontable: it fails on the IE star hack (#46751). - nodejs.org, Mantine: a CSS module exports classes that no rule defines, because the `@apply` or `@mixin` in them is moved out of the rule (#46759). - hint.css and 42 more: `z-index: 1000001` and `1000000` become the same number (#46715). - Adobe Spectrum: `--version: 5.0.1` prints as `5.1`. - A quarter of all stylesheets: minified output has a stray space in `align-items: center` (#41993). #### Behavior changes to look at first 1. **`light-dark()` is kept as written at the default browser targets** (#42009). The lowered form `var(--buncss-light, a) var(--buncss-dark, b)` is invalid unless a compiled `color-scheme` covers the element: 184 of the 215 real stylesheets that get it never define the variable, so every browser drops the declaration. The docs section on the polyfill is removed. 2. **`border` is not built from longhands** (#42072): `border-width:0;border-style:solid;border-color:red` stays three declarations, because `border` also resets `border-image`. 305 real stylesheets grow by 30 KB in total; `bootstrap.min.css` by 113 bytes, 5 gzipped. No real stylesheet was found where the old merge shows, so this is the one to drop if size matters more. 14 existing expectations change. 3. **A CSS module no longer exports names that only occur inside `:global(...)`** (#45487). 73 real modules lose 202 export keys, all of them names that no rule has. `import { g }` or `composes: g` of such a name becomes a build error. 4. **Container names and `@counter-style` names stay global in a CSS module** (#45247 by @mikamikasuki). `main` hashes the `@container` name but not `container-name`, so a named query never matches. postcss-modules (Vite, css-loader, Next.js) keeps them global; lightningcss and esbuild hash both. Global keeps a container that is queried from another file or from JS working. 5. **Syntax errors are build errors, also two that `main` lets through**: a media query that stops short (`@media screen and {`, `@media not {`, `@import "a.css" screen and;`) and `@media (width <) foo {`. No stylesheet of the 10,116 has either. A condition that is valid by the grammar though no browser knows it, `( ... )` or `fn( ... )`, is kept as written (#46756): `(min-width:0\0)`, `(not(hover))`. 6. **Declarations after a nested rule stay where they were written**, as a nested declarations rule (#45462). Changes the output of 199 real stylesheets; in a fuzz of 2,850 nested programs compared by computed style, `main` was wrong in 1,056 and the pull request's code, before it was trimmed here, in none. 7. **`Bun.color`**: `color()` inputs are converted instead of returned as a string (`Bun.color("color(srgb 1 0 0)", "hex")` was `"color(srgb 1 0 0)"`); `currentColor`, system colors and `light-dark()` give `null` in the converting formats instead of the keyword as a string; a relative color or `color-mix()` outside sRGB is clipped, not gamut-mapped (`hsl(from oklch(100% .399 336.3) h s l)` is `#ff5bff`, was `#fff`). 8. The `:-moz-any()` and `:-webkit-any()` copies of rules with `:is()` are gone at the default targets (#45945, #42029). The `:-webkit-any()` copy outranked the original rule: `.title{color:blue} :is(h1,h2,h3){color:red}` painted red. 5.8% of all default-target output was the `:-moz-any()` copies. 9. `Property` grows from 216 to 224 bytes: `Url` stores its text as a slice so that two `url()` values can be compared (#46895). The pull request packed a pointer and a `u32` behind `unsafe` to avoid that. #### Not in this PR, needs a decision - **Raise the default browser targets (#40369) and add a `cssTarget` option (#40368).** Today: Chrome 80, Edge 80, Firefox 78, Safari 14, Opera 67, and no public way to change them. At Vite 8's floor the output of the 9,924 stylesheets is 15.3% smaller (3.9% gzipped). About a dozen fixes here only matter at today's floor, and code for older targets is unreachable without the option. - Vendor prefixes for `user-select`, `appearance`, `hyphens` and five more (#45814): 1,116 real stylesheets lack a prefix they need at the default targets. And for `backdrop-filter` (#44648): 173. - Clip instead of gamut-map in the sRGB fallback of a plain wide-gamut color: deletes about 90 lines, changes every pinned fallback hex. If the mapping stays, #44647 by @oddharsh makes it closer to what browsers paint (colors off by 2/255 or more: 168 to 110 of 3,170 real ones). #### Changes **Rules: duplicates, merging, nesting, fallbacks** - The `:-moz-any()` copy of every rule with `:is()` or `:not(a, b)` is gone: Firefox 78 has `:is()` (#45945). 1,164 real stylesheets - Keep declarations written after a nested rule in place with a nested declarations rule (#45462). 379 real stylesheets - With nesting compiled away, an unknown at-rule inside a style rule was moved out of it: `.wrapper{@apply flex}` printed a bare `@apply flex;` and the rule was gone, so a CSS module exported a class that no rule defines. It now stays in a block under the compiled selector. 72 real modules with Tailwind's `@apply`, 25 with Mantine's `@mixin` (#46759). 316 real stylesheets - Keep !important in the rules generated for logical properties and color-scheme (#38597). 311 real stylesheets - With browser targets, remove an earlier duplicate rule only when its declaration block is equal (#46861). 171 real stylesheets - Compare custom property names in `PropertyId` equality and importance in duplicate-rule detection (#33684). 40 real stylesheets - Keep same-condition @supports rules and empty named @layer blocks (#46733). 31 real stylesheets - Keep target-added vendor prefix passes on cloned and re-minified rules, and never print a bare `:full-screen` (#43339). 12 real stylesheets - Keep the selectors on rules added for color fallbacks and logical properties (#43240). 3 real stylesheets - A later declaration of a property drops the `@supports` color copies of the earlier one, which printed after the rule and won (#45560). 2 real stylesheets - Do not merge style rules in a stylesheet that holds `revert-rule` (#46953). 1 real stylesheets - `@import ... supports(-webkit-mask: none)` kept its prefix (#38589) **Properties and shorthands** - Print align-items:center without a stray space when no safe/unsafe keyword is given (#41993). 3,317 real stylesheets - Emit the physical longhands instead of the inset shorthand for targets without it (#38583). 1,452 real stylesheets - Keep the stops of an upward gradient as written, and drop a `50%` hint only at the midpoint (#46790). 104 real stylesheets - Emit unparsed border-block-* declarations when compiling logical borders (#38582). 89 real stylesheets - Keep repeated font families and dedupe only the inserted system-ui fallbacks (#46662). 88 real stylesheets - A `width` / `height` / `min-*` / `max-*` with `var()` or `env()` was printed before the plain fallback written above it, so the fallback won (#42101). 26 real stylesheets - Keep box-orient and box-direction when flex-direction or flex-flow follows (#45672). 16 real stylesheets - Write held prefixed background declarations at every flush (#46882). 6 real stylesheets - Derive equality for Size2D and BorderRadius, and keep every prefix in the border-radius handler (#45665). 2 real stylesheets - Print `none` instead of an empty border-image, mask-border or mask-box-image value (#45143). 2 real stylesheets - Keep font/transition sub-properties on the side of the shorthand they were written on (#41990). 2 real stylesheets - CSS-wide keywords are not names: `font:12px Arial;font-family:inherit` printed `font:12px inherit`, `color-scheme:inherit` and `color-scheme:auto` printed `normal`, `transition-property:unset` was folded into `transition` (#42022). 2 real stylesheets - Only fold font longhands into `font` when the rule declared a `font` (#45459) - Keep a font family name quoted when its identifiers do not read back as the same name (#46606) - Stop rewriting valid positions into ones browsers reject (#45197) - `border` is no longer built from longhands unless the rule also sets what `border` resets (`border-image`), and an author's `border-image` reset is no longer dropped. `transition` is likewise only written where the rule wrote one, because it resets `transition-behavior` (#42072) - `border; border-image: <gradient>; border-image-slice: 1` lost the gradient in every default build (#42018) - A prefixed `linear-gradient` keeps its direction, written or generated: the prefixed syntax names the starting side and measures angles differently (#46697) **At-rules** - Print @font-face format() keywords as strings (#37979). 439 real stylesheets - Print @supports `not` conditions without a leading space (#38559). 88 real stylesheets - Reset after the number token of a unicode-range (#45835). 33 real stylesheets - Keep `<general-enclosed>` media and container conditions as written (#46756). 24 real stylesheets - A strict range such as `(width < 768px)` lowered for old targets still matched at exactly 768px. The step is now `.02px`. The `not all and` form for other units is not taken (#46806). 18 real stylesheets - Keep scanning an @supports condition after a nested block (#38552). 9 real stylesheets - Every `@counter-style name` printed as `@counter-stylename`, which browsers drop (also #46692). In a CSS module the name now stays global, like the `list-style` that refers to it (#33679). 9 real stylesheets - Keep a keyframe whose block has an invalid item (#46288). 2 real stylesheets - Read an @Property initial-value on the stylesheet's parser, keep an unmatched value as written (#45806). 2 real stylesheets - `@media {}` is `all`. A media query that stops after `and`, `or` or `not` (`@media screen and {`) is a build error; `main` printed `not all` or dropped the condition, silently. The `@layer` and type selector parts are not taken (#46869). 2 real stylesheets - Accept a name-only `@container` and a `style()` feature with no value (#46689). 1 real stylesheets - Parse `<timeline-range-name> <percentage>` keyframe selectors (#45652) - Allow `or` inside parentheses after a media type (#45088) - Print the `=` comparison in media and container queries as `=` (#45071) - Parse the @font-palette-values font-family descriptor as a list of family names (#45484) **Selectors** - Never wrap a selector list in :is() (#46787). 414 real stylesheets - Stop emitting :-webkit-any() copies of :is() that outrank the original rule (#42029). 373 real stylesheets - With browser targets, a selector list with a vendor-prefixed member was split, which revives hacks that rely on other engines dropping the whole list: `.s *::-ms-backdrop,.s{flex:auto}` applied everywhere. Rewritten; the list is printed as written (#42100). 42 real stylesheets - Print the vendor prefix on downleveled :any-link, :read-only, :read-write, :placeholder-shown and :autofill (#42131). 12 real stylesheets - Fix nesting lowering of a `&` that does not lead its selector (#43047). 8 real stylesheets - `@scope`: `&` and implicit selectors compile to `:where(:scope)`, relative selectors and bare declarations parse (#43165). 7 real stylesheets - Only reject bare :global / :local pseudo-classes in CSS modules (#38534). 5 real stylesheets - Stop truncating `@scope to (...)` and stop replacing `&` in the scope-end selector (#43146). 2 real stylesheets - `[xl|href$=".pdf"]` lost its namespace prefix (#45159). 2 real stylesheets - Match An+B keywords exactly and take only `+` as the leading sign (#42953). 1 real stylesheets - Parse ::details-content and ::picker() like ::part() (#45504). 1 real stylesheets - Parse :window-inactive as an ordinary pseudo-class, and accept it after ::selection (#45815) - Print :nth-col(1) and :nth-last-col(1) in functional form (#41575) - `li::before::marker` failed the build (#45810) - `&` inside `:nth-child(... of S)` is resolved when nesting is compiled away. The second selector serializer (326 lines) is deleted (#43024) **Colors and `Bun.color`** - Keep light-dark() as written under the default browser targets (#42009). 227 real stylesheets - Number and percentage channels parse in `lab()`, `lch()`, `oklab()`, `oklch()`, `hsl()` and `hwb()`; the hue of `lch` / `oklch` is no longer scaled by alpha (#45084). 130 real stylesheets - `Bun.color` returns `null` for `currentColor`, system colors and `light-dark()` in the converting formats, instead of the CSS keyword as a string (#33047). 130 real stylesheets - Relative colors: channel keywords are on the scale of their function (`rgb(from #369 calc(r + 10) g b)`), the origin's alpha is kept, a relative color kept as written gets no `@supports` copies, and a result outside sRGB is not folded (#45362). 31 real stylesheets - Keep rotate: 0deg out of none, and keep the origin alpha in relative colors (#38486). 13 real stylesheets - Compute the D50 reference white in f32 so greys convert to lab(l 0 0) like lightningcss (#38535). 7 real stylesheets - Return a missing hue for a grey from conversions into lch, oklch, hsl and hwb (#45201). 5 real stylesheets - Fix color(a98-rgb), srgb-linear relative colors and color-mix(in xyz-d50) (#38487). 4 real stylesheets - Match named colors without regard to ASCII case (#45092). 3 real stylesheets - Fold min(), max() and clamp() over plain numbers (#39515) - `calc(l - .08)` in a percentage channel of a relative color became NaN and printed `#000` (also #45970) (#45971) - Bun.color: convert color() inputs instead of returning them as a CSS string (#38488) - A `color-mix(in srgb, ...)` result outside sRGB prints `color(srgb ...)` instead of a gamut-mapped hex (#39269) **`calc()` and numbers** - Rounding to 6 significant digits ate into the integer part: `z-index:2147483647` became `2147480000`, `max-width:9999999px` became `10000000px`, `--x:1234567.5` became `1234570`. It now only cuts a fraction short: a value kept as tokens prints its source text, a typed value prints every integer digit and at least one digit of the fraction (#46715). 43 real stylesheets - A number without a unit is no longer repaired into `px` (`height:1.5715` is dropped by a standards-mode browser, so it prints as written), `calc(-1 * 0)` is not a length, and on a property of the range [0,∞] (sizes, border radii, padding, border and outline widths, gap, `flex-basis`, `font-size`) a `calc()` that folds below zero prints `0` instead of an invalid negative literal, and a negative literal no longer replaces the valid declaration before it (`width:10px;width:-5px` printed `width:-5px`) (#42104). 24 real stylesheets - Print numbers from the f32's shortest digits, not its f64 expansion (#38565). 17 real stylesheets - Print negative numbers below 1 with a single minus sign (#38516) - Keep math functions over two different length units as written (#38489) - Fold rem() with a truncated quotient, so it keeps the sign of the dividend (#32286) - A one-argument `min()` / `max()` around a sum printed without any function (`width:100% - 20px`) (#46145) - `round()` breaks ties upward and handles a negative step as browsers do (#45249) **CSS modules** - A CSS module exports its `@keyframes` names (`styles.spin`). Hashing is unchanged (#46720). 145 real stylesheets - A name inside `:global(...)` prints plain but was exported as `name_<hash>`, a class no rule has (also #38573) (#45487). 90 real stylesheets - Keep container names global in CSS modules (#45247). 6 real stylesheets - Tag the keyframes name when an `animation` value falls back to raw tokens (#46034). 6 real stylesheets - In a plain stylesheet `composes: a from "./b.css"` was treated as an import: the file was inlined and an absolute path printed (#45961). 4 real stylesheets - Drop a rejected composes instead of failing the file at print time (#38538). 1 real stylesheets - Keep every rule's custom properties for the composes conflict check (#38523) - Keep composes working after a nested rule in the same block (#38520) - `IdentOrRef::hash` read 2 of 16 bytes, so in release builds every class of a module hashed alike (#30784) **Tokens, `url()`, diagnostics** - The block skip stack no longer allocates on every block exit (#39533) - Report the diagnostic's line without its line breaks in position.lineText (#38342). 192 real stylesheets - Compare `url()` values by their text (#46895). 26 real stylesheets - Values kept as tokens keep the blanks they were written with instead of having them re-invented at print time: `attr(x type(<number>))` printed `type(< number> )`, which browsers drop. Minified output still drops the blanks that cannot matter (#45577). 19 real stylesheets - The IE star hack (`*zoom:1`) failed the whole build. The declaration is skipped, as every browser does. It is the one syntax error that builds: tachyons, handsontable and jasmine ship it (#46751). 8 real stylesheets - The path of a copied asset spliced into `url()` is always quoted, so a file name with a space or a parenthesis cannot end the `url()` (#41557). 7 real stylesheets - Move the skip loop of parse_until_before out of the generic body (#39520) - Remove dead code from bun_css: the unused `impl Default for Property` (#45618) - Default the at-rule and qualified-rule parser traits, drop the url_to_css shim (#31999) **Speed** Four inputs that took quadratic or exponential time, or produced output out of proportion, are now linear, with no new limit and no extra pass (#31639, #42243, #38531, #43421). **Tests only** - `css-fuzz.test.ts` never ran in CI and timed out on debug builds; it is a deterministic table now (#38500) - `nth-anplusb-ident.test.ts` counts heaps in a child process (#46331) - `color.test.ts` checks the ansi-16 sweep with one `expect()` (#42824) - Not CSS, but it failed this PR's CI and fails on `main` too: `ws-proxy.test.ts` on Windows, "a message sent during a TLS renegotiation arrives after it". The helper server now renegotiates from the callback of the frame's write (#46170) **No pull request covered these** - `--v: 5.0.1` printed as `5.1`, `--w: 1.0.0` as `10`, `--x: 1+2` as `12`, `2n+1` as `2n1`: a number token that touches another keeps its source text. - `color-scheme: auto` printed as `normal`, and `color-scheme: dark light` as `light dark`. - A bare `:global` or `:local` in a CSS module sets the scope of the names after it, up to the end of the selector, as in css-loader. It was a build error. - The scrollbar states (`a:horizontal`, `::-webkit-scrollbar{&:horizontal{}}`) are valid in a plain compound, as in WebKit and Blink. - Without browser targets, `-webkit-box-align`, `-ms-flex-align` and the like were dropped next to their standard form (flatpickr, foundation-sites, materialize-css: 28 stylesheets), and `-webkit-fill-available` before `-moz-available` (beercss). - `(-o-min-device-pixel-ratio: 2/1)` printed as `2`. - `sin()`, `cos()` and `tan()` are exact at multiples of 90deg: `calc(100px * cos(90deg))` printed `-.00000437114px`. - A `display-p3` color at targets that have P3 but not the other `color()` spaces (Safari 10.1 to 14) got itself as a fallback. - `width: rem(-18px, 5px)` folds to a negative literal once `rem()` is right; it prints `0`. - Consecutive nested declarations rules, which arise when same-query `@media` blocks in one rule merge, are folded into one and count against the existing selector expansion limit as the `&{}` rules they replace did. #### Known and not fixed here - Four longhands are still folded into a `transition` the author did not write where the targets add a prefixed copy of a property (`mask-image` at Chrome 80): only the shorthand gives the copy its own times. - A later `background`, `box-shadow` or `border-*` declaration still loses to the `@supports` copy of an earlier `var()` value of the same property with a wide-gamut color. Same on `main`; #45560 is taken for the properties of the fallback handler and for unparsed values only. - A merged rule prints the unprefixed name in the passes of the rule it absorbed: `.d::-webkit-input-placeholder{}.d::-moz-placeholder{}` prints `::placeholder` for the second (#42106, not taken: it breaks the merge of prefix-equivalent rules). - The `border`, `outline` and `font` shorthands, `line-height`, `scroll-padding`, `background-size` and a few more still print a `calc()` that folds below zero as a negative literal. No real stylesheet has one. - `all` is not a property Bun knows, so `a{margin-top:1px;all:unset}` prints `all` first. Same on `main`. - A selector kept as raw tokens loses the blank before `*` when minified: `--sel: .dark *` prints `.dark*`. Same on `main`. - `width:calc(0% + 12px)` folds to `12px`, which lays out differently in a table cell. Same on `main`. - The `:dir()` / `:lang()` rules of a lowered logical property print before the nested rules of their rule, so a logical declaration written after a nested rule of equal specificity still loses to it. Same on `main`. - A fraction is still rounded to 6 significant digits (`12345.678` prints `12345.7`, `33.3333333333%` prints `33.3333%`). Keeping every digit would add up to 282 KB over the corpus. - `@keyframes "quoted"` is hashed in a module but not exported. - In a module, a keyframes name inside a `var()` fallback is not hashed: `animation: var(--enter, fade 1s)`. Same on `main`. - A `color-mix()` or relative color folded to a hex inside a gradient changes the interpolation from Oklab to sRGB (VS Code, clawhub). Printing `color(srgb ..)` instead needs a fallback for `mask-image`, `list-style-image` and custom properties at the default targets, which have none. - A comma list in `@container` still fails the build. - After a bare `:global`, names that are hashed at print time (`::view-transition-old(foo)`) are still hashed; inside `:global(...)` they are not. #### Pull requests in scope that do not land here - Duplicates of a pull request that lands here: #36961 (of #37979), #38478 (of #46662), #38480 (of #46733), #38482 (of #46715), #38491 (of #38500), #38508 (of #45201), #38513 (of #45362), #38573 (of #45487), #38640 (of #38500), #45525 (of #45247), #45589 (of #46756), #45970 (of #45971) - Already fixed on `main`: #38674 - Right, but for input nobody writes (0 of 10,116 stylesheets, and not new syntax): #31916, #38526, #38592, #39349, #41139, #41801, #42042, #42484, #43078, #45604, #45908, #46086, #46221, #46224, #46273, #46694, #46707, #46737, #46755, #46816, #46870, #46934 - Right or harmless, but not worth their code, their bigger output or the behavior they change: #30905, #31924, #33332, #34426, #36225, #38006, #38512, #38547, #38551, #38561, #38572, #38576, #38607, #38623, #38667, #38692, #42106, #42912, #42918, #43407, #43434, #44700, #45442, #45546, #45747, #45759, #45825, #45946, #46061, #46127, #46206 - Need a decision (see above), stay open: #40368, #40369, #44647, #44648, #45814 - Stays open: #45787 Out of scope: CSS linking and chunking in the bundler, the dev server, docs-only pull requests, and repository-wide sweeps that touch `src/css` in passing. Thanks to @mikamikasuki (#45247), @alii (#39520, #31999) and @oddharsh, whose measurements on #40369 and #41990 were used. Fixes #16727. Fixes #24770. Fixes #30772. Fixes #44704. Fixes #45089. Fixes #45237. Fixes #46872. ### How did you verify your code works? - The 35 CSS test files pass on a debug build, also with `FORCE_COLOR=1`. 600+ of the tests in this branch fail on a build of `main`. An earlier head of this branch passed all 179 CI jobs that ran. - The reproduction of each issue listed above was run on `main` (wrong) and on this branch (right). - Differential run over the 10,116 stylesheets, `main` against this branch, minified without targets and through `Bun.build` at the default targets: no stylesheet that builds on `main` fails; Chromium 156 compares the output with the input rule by rule (CSSOM, then computed values on a sized element in a standards-mode document, colors as painted pixels in sRGB and P3). The comparison has 195 control pairs that must read equal or different. For stylesheets that read differently on both builds, mostly because of lowering, the sets of differences were compared; the three new ones are two minifier fixtures that are wrong on both and one comparison artifact. - CSS modules are checked for consistency instead, since hashed names defeat the comparison: every reference points at a name the output defines, every export exists. - `Bun.color`: 59 inputs in 13 formats on both builds; every changed cell was read.
Related to #45237
Problem
bun buildfails on@media screen and (min-width:0\0){}, an IE 9 and 10 hack (error: Invalid media query), and on@container scroll-state(stuck: top){}(error: Unexpected token: scroll-state().parse_paren_block(src/css/media_query.rs:1589) accepts only the conditions it knows, as lightningcss does in its strict mode.Fix
( .. )orname( .. )block that is no known condition and holds no error token becomesMediaCondition::UnknownorContainerCondition::Unknown: the source text, printed as written.Unknown condition is kept as written: (min-width:0\0)), as an unknown at-rule is today.Parser::expect_no_error_tokenreads past a nested block (css: keep scanning an @supports condition after a nested block #38552 is the same line).test/js/bun/css/css.test.ts(54 tests fail without the fix),test/bundler/esbuild/css.test.ts(5),nested-function-backtracking.test.ts(4). Self-reviewed: 14 concerns raised, 12 addressed. Open: theMediaList::parsedefect behindoperand_error, and the order against css: parse scroll-state() container queries #45589.Background
<general-enclosed>. It evaluates to unknown.(max-width: $tablet)was an error and is a warning now.(min-width:0\0)as written.Downsides
( .. )media condition, 0 more allocations, +5,632 bytes of.text.(take 20.1 G instructions, 13.4 G on main.Notes
Decision needed. lightningcss 1.33.0 rejects an unknown condition in its strict mode. With
errorRecoveryit keeps the condition and warns (and prints(min-width:0\0)with U+FFFD in place of the\0). esbuild keeps it with no warning. This PR keeps it and warns, as Bun does today for an unknown at-rule that it keeps (invalid @ rule encountered: '@foo'). So a typo or a preprocessor leftover in a user's own sheet ((max-width: $tablet),(min-width: 10 px),(min-width: hi)) goes from an error to a warning with exit 0, and a third-party sheet with the IE hack reports a warning in every build. If silence is wanted, the twowarn_general_enclosedcalls are the only thing to remove.The warning. One for each media query or container condition whose final condition holds an unknown block. It is not emitted where the block is read, because
(calc(1px + 2px) <= width)readscalc(1px + 2px)as a block first and is then read again as a range, and a valid range must not warn.ParserInputcounts the blocks that became<general-enclosed>, so a query that made none does not walk its condition.bun buildprints CSS parser warnings with no file name and no line today (also the@nestand unknown at-rule warnings), becauseParseTasksetsParserOptions.filenameonly for CSS modules. The message therefore carries the text of the condition. The same warnings inBun.build().logshave the line and column.What is kept. A block is
<general-enclosed>when it is closed and holds no error token (a bad string, a bad URL, an unmatched),]or}). The condition keeps the bytes from its(or function name to its), comments and line breaks included, so two rules merge only when their text is equal.StyleQueryhas no variant for it:@container style((x y) and (--a: 1))keeps the wholestyle( .. )as the container condition's unknown.A group that starts with a function.
(calc(1px + 2px) <= width)is still a range. Any other group that starts with a function keeps its parentheses:@media (foo(bar))prints(foo(bar)), notfoo(bar). After an@importURL a barelayer(x)orsupports(..)has its own meaning, so@import "b.css" (layer(x));must not lose the parentheses. Tests pin(layer(x)),((LAYER(x))),(supports(display:grid))and(layer(x) or (color)).Errors that stay, and the messages that move. Same message and position as main: an unclosed block (
@media (min-width:0\0 {), an error token (@media (a ] b)), an unknown function that is not closed. Same exit 1 with a different message or position:@media (min-width:0\0) foo{reportsUnexpected token: fooat thefoo(main:Invalid media queryat the block),@media (min-width:0\0) and {reportsInvalid media queryat 1:28 (main: 1:18),@import "./b.css" (min-width:0\0) and;at 1:38 (main: 1:29). One input that built on main is an error now:@media (width <) foo{..}printed an unconditional rule under--minify.The
operand_errorguard.MediaList::parsetakes everyend_of_inputfor the end of the list and drops the query that was being read. On main@media screen and {.c{top:1px}}prints.c{top:1px}under--minifyfor that reason. Before this PR a query with an unknown block could not reach that path, because the block was an error. The guard keeps@media (min-width:0\0) and {an error. The defect inMediaList::parseis reported separately. When it is fixed,operand_errorcan go.@supports.expect_no_error_tokenreturned after the first nested block.SupportsConditionuses it for an unknown condition, forselector( .. )and for@import .. supports( .. ). With the fix@supports (transform:translate(1px) rotate(1deg))and@supports selector(a:has(b) c)build. One output changes for input that built before: the text is kept up to the closing parenthesis, soselector(:is(a) )keeps its space. #38552 is the same line with 22 tests of its own. If it lands first, the line drops out of this diff.Other PRs that pin the opposite. #45589 (typed
scroll-state(), Fixes #45237) printsscroll-state(stuck:top)and expectsInvalid media queryfor@container scroll-state() {}andscroll-state(stuck: 100px). With this PR alone those build as written and warn. A typed parser runs first and this arm is the fallback, so the two fit together, but those error rows flip for whichever lands second. #45088 and #46689 touch the same functions inmedia_query.rsandrules/container.rs. #46720 adds an exhaustivematchoverContainerCondition(make_animation_names_local). Merged together with this PR it needs anUnknown(_) => {}arm, and git reports no conflict for that.Measurements. Release builds of this diff and of its base, x64 Linux. valgrind, perf and bloaty are not available in the build container, so the counts come from
perf_event_open(user-space instructions of the whole process) andsize -A.@media (min-width:Npx)rules: +0.08% (+15 instructions per rule). 10,000 rules withscreen andthree conditions: +0.10% (+9 per condition). 10,000@containerrules: +0.31% (+53 per rule). 40,000 declarations: +0.02%..text82,231,705 to 82,237,337 bytes,.rodataunchanged.size_of:MediaCondition40 to 40,ContainerCondition40 to 40,MediaQuery72 to 72,ParserInput200 to 208 (the counter). The new field has no niche, so the fields in front of it keep their offsets.Parser::next_including_whitespace_and_commentsis 503 bytes on both builds.@media (min-width:0)100,046 on main and here. Unknown(min-width:0\0): 110,045, one more for each unknown block (the block stack of the second skip. css: keep the block skip stack inline and off the cold path #39533 would remove it).@container (min-width:0)110,045 on both, an unknown function 100,045.--minify: 10 newly build (8 of foundation-sites, 2 of flatpickr), 0 newly fail, 0 of the 2,854 others change output. Only those 10 report the new warning.(x N,x y,)x N for N = 64 and 512: main rejects at 0.68M and 36M. This PR keeps it at 64K and 442K. Typed((((color)))): 827 to 853 for each level. Around an error token: 36M to 89M at 512 levels (each level scans down to the token). An unknown block at every one of 256 levels: 32.5M (main rejects at 25.4M). Over the depth limit (an error on both): 100,000(take 13.4 G on main and 20.1 G here, a(not (chain 20.5 G and 41.0 G.((((color))))296 on main and 316 here,(not (not ..264 and 280, an unknown innermost block 296 and 320. New tests build 512 levels for@mediaand@containerand expectInvalid media queryat 513.mediaTextfor 35 of 36 kept forms. The other one differs in a typed feature that main already prints in range syntax.Tests.
test/js/bun/css/css.test.ts: the elevenerror_teststubs of the lightningcss suite that were skipped are now assertions of the kept text, plus rows for the IE hack, merging, groups,@import,@supports, containers, the warning and the errors that stay.test/bundler/esbuild/css.test.ts:css/GeneralEnclosedCondition,...Minified,...OnExternalImport,...URL(the text of a kept condition is not an import),css/InvalidConditionIsStillAnError. The foundation fixture keeps its@media screen and (min-width: 0\0 )block commented out (#14832):doesnt_crash.test.tsfails on any log entry, and the block is a warning now. Also run: the other files oftest/js/bun/css/andtest/bundler/css/css-modules.test.ts.