Skip to content

css: keep <general-enclosed> media and container conditions as written - #46756

Closed
robobun wants to merge 3 commits into
mainfrom
robobun/36446711/css-general-enclosed
Closed

robobun wants to merge 3 commits into
mainfrom
robobun/36446711/css-general-enclosed

Conversation

@robobun

@robobun robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #45237

Problem

  • bun build fails 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

  • A closed ( .. ) or name( .. ) block that is no known condition and holds no error token becomes MediaCondition::Unknown or ContainerCondition::Unknown: the source text, printed as written.
  • Each query that keeps such a block is a warning (Unknown condition is kept as written: (min-width:0\0)), as an unknown at-rule is today.
  • An unclosed block and an error token stay errors. Parser::expect_no_error_token reads past a nested block (css: keep scanning an @supports condition after a nested block #38552 is the same line).
  • Verified: 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: the MediaList::parse defect behind operand_error, and the order against css: parse scroll-state() container queries #45589.

Background

  • Media Queries 4 names such a block <general-enclosed>. It evaluates to unknown.
  • This is a behaviour change and needs a maintainer's yes. A leftover such as (max-width: $tablet) was an error and is a warning now.
  • Considered a token list as in lightningcss: it cannot print (min-width:0\0) as written.

Downsides

  • Sheets without such a block: -0.006% instructions on 2,854 npm sheets (noise), +9 to +15 per ( .. ) media condition, 0 more allocations, +5,632 bytes of .text.
  • foundation-sites builds and reports one warning each time. The CLI prints it with no file name.
  • A nest that is an error anyway is slower: 100,000 nested ( 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 errorRecovery it 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 two warn_general_enclosed calls 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) reads calc(1px + 2px) as a block first and is then read again as a range, and a valid range must not warn. ParserInput counts the blocks that became <general-enclosed>, so a query that made none does not walk its condition. bun build prints CSS parser warnings with no file name and no line today (also the @nest and unknown at-rule warnings), because ParseTask sets ParserOptions.filename only for CSS modules. The message therefore carries the text of the condition. The same warnings in Bun.build().logs have 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. StyleQuery has no variant for it: @container style((x y) and (--a: 1)) keeps the whole style( .. ) 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)), not foo(bar). After an @import URL a bare layer(x) or supports(..) 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{ reports Unexpected token: foo at the foo (main: Invalid media query at the block), @media (min-width:0\0) and { reports Invalid media query at 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_error guard. MediaList::parse takes every end_of_input for 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 --minify for 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 in MediaList::parse is reported separately. When it is fixed, operand_error can go.

@supports. expect_no_error_token returned after the first nested block. SupportsCondition uses it for an unknown condition, for selector( .. ) 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, so selector(: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) prints scroll-state(stuck:top) and expects Invalid media query for @container scroll-state() {} and scroll-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 in media_query.rs and rules/container.rs. #46720 adds an exhaustive match over ContainerCondition (make_animation_names_local). Merged together with this PR it needs an Unknown(_) => {} 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) and size -A.

  • 2,854 npm sheets that build on main, parsed, minified and printed once each: 13,311,336,686 to 13,310,592,994 instructions (-0.006%). Run to run spread: 0.9M.
  • 10,000 @media (min-width:Npx) rules: +0.08% (+15 instructions per rule). 10,000 rules with screen and three conditions: +0.10% (+9 per condition). 10,000 @container rules: +0.31% (+53 per rule). 40,000 declarations: +0.02%.
  • The cost is one parser state saved in front of each block (the nested parse resets to it and no longer saves its own), the start position of the token, the check of the result, and one load and compare of the block counter for each query.
  • .text 82,231,705 to 82,237,337 bytes, .rodata unchanged.
  • size_of: MediaCondition 40 to 40, ContainerCondition 40 to 40, MediaQuery 72 to 72, ParserInput 200 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_comments is 503 bytes on both builds.
  • mallocs (ASAN statistics, debug build), for each parse of 10,000 rules: typed @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.
  • Corpus, 3,129 sheets, plain and --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.
  • Nesting, instructions for each parse. ( 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.
  • Stack, debug build with ASAN, deepest nest at a 2 MB stack: ((((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 @media and @container and expect Invalid media query at 513.
  • Chrome 153 reads the same mediaText for 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 eleven error_test stubs 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.ts fails on any log entry, and the block is a warning now. Also run: the other files of test/js/bun/css/ and test/bundler/css/css-modules.test.ts.

`@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.
@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Closed. <general-enclosed> media and container conditions landed in css: consolidate the open CSS parser fixes #47025 (7942008). They are kept as written with no warning. This PR added a warning.
  • Checked on main at 7942008 with a debug build: @media screen and (min-width:0\0){.a{top:1px}} and @container scroll-state(stuck: top){.a{top:1px}} build and are printed as written.
  • The cases from this PR's review hold on main: @import "b.css" (layer(x)); keeps its parentheses, (calc(1px + 2px) <= width) is still a range, an unclosed block or an error token is still an error, and 512 nested blocks build (513 is Invalid media query).
  • css: consolidate the open CSS parser fixes #47025 also fixes the MediaList::parse defect that this PR guarded against: @media screen and { and @media (min-width:0\0) and { are now error: Unexpected end of input.

@robobun

robobun commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:53 AM PT - Oct 24th, 2026

❌ @robobun, your commit 8537115 has 1 failures in Build #132104 (All Failures):

  • ❌ Baseline verification failed on Windows x64 — 1 check(s)
  • The baseline build crashed or failed tests under Nehalem (SSE4.2, no AVX/AVX2/AVX512) emulation (not an unsupported-instruction fault). See the step log for the crash output.

    • SIMD baseline tests

🧪   To try this PR locally:

bunx bun-pr 46756

That installs a local version of the PR into your bun-46756 executable, so you can run:

bun-46756 --bun

@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 467d2fa and 8537115.

Files selected for processing: 4
  • src/css/css_parser.rs
  • src/css/media_query.rs
  • test/js/bun/css/css.test.ts
  • test/js/bun/css/nested-function-backtracking.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

Changes

The CSS parser now preserves eligible unsupported media and container conditions as general-enclosed source text. It serializes the preserved text, warns about preserved conditions, and reports errors for malformed input. Tests cover parser output, bundling, and nesting limits.

CSS condition preservation

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: Normal

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: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: preserving unknown general-enclosed media and container conditions as written.
Linked Issues check Passed Directly linked issue #45237 requires that @container scroll-state(stuck: top) is parsed and emitted, not rejected with Unexpected token: scroll-state(. Whole-PR diff (merge base 179664e to head …
Out of Scope Changes check Passed All changes support one shared <general-enclosed> path for media, container, import, and supports conditions. Changes in src/css/media_query.rs add MediaCondition::Unknown, parse_function, `pa…
Description check Passed 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 #45237 requires that @container scroll-state(stuck: top) is parsed and emitted, not rejected with Unexpected token: scroll-state(. Whole-PR diff (merge base 179664e to head 8537115): parse_function in src/css/media_query.rs routes any non-style() function token to parse_general_enclosed. A closed block with no error token becomes ContainerCondition::Unknown(&amp;'static [u8]) (src/css/rules/container.rs). to_css writes the kept source bytes, so the rule is emitted as written. Errors remain for unclosed blocks, error tokens, and an operand missing after and (operand_error). Test coverage is in test/js/bun/css/css.test.ts and test/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 &lt;general-enclosed&gt; path for media, container, import, and supports conditions. Changes in src/css/media_query.rs add MediaCondition::Unknown, parse_function, parse_general_enclosed, starts_with_function, and operand_error. Changes in src/css/rules/container.rs add ContainerCondition::Unknown. Changes in src/css/css_parser.rs update expect_no_error_token so it scans past nested blocks, which the shared scan needs. The @supports change 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 adds warn_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 the operand_error guard and the MediaList::parse defect, 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.

❤️ Share

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/css/media_query.rs
Comment thread src/css/css_parser.rs
@@ -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())?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread test/js/bun/css/nested-function-backtracking.test.ts Outdated
Comment thread src/css/media_query.rs
// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is #45088, which gives the content of a ( .. ) group ALLOW_OR. On main the query is an error. With this PR alone it builds as written and reports the warning, and with #45088 the typed parser takes the group first and this arm is not reached. I did not copy that change here.

Comment thread src/css/media_query.rs
Comment on lines 1567 to +1570
loop {
match parse_parens_or_function::<C>(input, flags, options) {
Ok(operand) => conditions.push(operand),
Err(error) => return Err(operand_error(input, &conditions, error)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment thread src/css/css_parser.rs Outdated
Comment thread src/css/media_query.rs Outdated
Comment thread src/css/media_query.rs Outdated
Comment thread src/css/media_query.rs Outdated
Comment thread src/css/media_query.rs Outdated
Comment thread src/css/media_query.rs Outdated
Comment thread src/css/media_query.rs Outdated
Comment thread src/css/media_query.rs Outdated
Comment thread src/css/media_query.rs Outdated
Comment thread src/css/media_query.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (3):

  • Unresolved: 3 minor or pre-existing.

@robobun

robobun commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

#46869 is the MediaList::parse fix that the description here names as an open item. It makes a media query that stops short a parse error, so the operand_error guard in this PR is not needed after #46869 lands.

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 (min-width) from being read as (width). With both PRs, @media (min-width) {…} is kept as written.

Jarred-Sumner added a commit that referenced this pull request Oct 27, 2026
### 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.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Landed in #47025 (7942008).

Keep <general-enclosed> media and container conditions as written. 24 real stylesheets

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CSS parser rejects @container scroll-state(...) queries ("Unexpected token: scroll-state(")

2 participants