Skip to content

css: keep the prelude and block of an unknown at-rule as raw tokens - #46061

Open
robobun wants to merge 3 commits into
mainfrom
robobun/9768db6d/css-unknown-at-rule-hash
Open

robobun wants to merge 3 commits into
mainfrom
robobun/9768db6d/css-unknown-at-rule-hash

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build reads an unknown at-rule as a declaration value. @foo { a#f00:hover {} } prints @foo{ared:hover {}}: the ID selector #f00 becomes the color red.
  • The cause is TokenList::parse at src/css/css_parser.rs:365 and :1881. It also prints .a .b as .a.b and fails the build on var(--value([*]), 0%) in @utility.

Fix

  • The prelude and the block are now RawTokens (src/css/properties/custom.rs): tokens, number source text and url() imports. No entry is a color, a length or a var().
  • The arguments of an unknown pseudo-class use the same type. It replaces TokenList::parse_raw and to_css_raw.
  • Verified: test/js/bun/css/css.test.ts (155 new, 121 fail on main), test/bundler/esbuild/css.test.ts (4 new, all fail on main). Also test/js/bun/css, test/bundler/css.
  • Self-reviewed: 18 concerns raised, 13 addressed. Notes list the rest.

Background

  • An unknown at-rule has no grammar in the parser: @custom-selector, @theme, a rule from a new CSS module.
  • A TokenList holds a declaration value and folds #ff0000 to red. That stays for --x: #ff0000.
  • Weighed: a flag in the hash arm of TokenList. It adds a branch for each hex color in each value and stops 1 of 9 rewrites.

Downsides

  • This text is not minified as a value: @theme{--x:#ff0000} stays as written, as in esbuild. lightningcss folds it. A Tailwind 4 directive sample: 1,031 to 1,088 bytes.
  • bun pm diff: #FFF to #fff in @theme is no longer "formatting only".
  • A url() in a value costs 16 more instructions (204 to 220): its printer is no longer inlined. Release .text: +256 bytes.
Notes

Report. There is no GitHub issue, and no user reported the hash fold. It was found during the work on #45362. The one user report on this path is #16712 (@apply something/80 printed something / 80). It is closed, and main still prints that text without --minify. Related to #16712.

Before and after (--minify, main is 91e7e3b)

input main this PR
@foo { a#f00:hover { color: red } } @foo{ared:hover { color: red }} @foo{a#f00:hover { color: red }}
@foo { #FACADE, a#aabbcc { } } #facade,a#abc as written
@custom-selector :--h a#f00, #FACADE; :--h ared,#facade as written
@foo { .a .b > .c { } } .a.b>.c .a .b > .c
@foo { a:nth-child(2n+1) { } } 2n1 2n+1
@foo { z-index: 9999999; width: 10PX } 10000000, 10px as written
@foo { a/**/b { } } ab a/**/b
@utility x { opacity: var(--value([*]), 0%) } build error as written
@theme { --color-x: #ff0000 } --color-x: red --color-x: #ff0000
a:unknown(p-2.5) { } a:unknown(p-20.5) a:unknown(p-2.5)

Without --minify, main also adds spaces: .a .b prints . a. b and something/80 prints something / 80.

What changes in an unknown at-rule

  1. No token is read as a value. A hash, a color function, a number, a dimension, var() and env() print as the same tokens. A number prints its source text.
  2. A run of whitespace prints as one space, with and without --minify. Main dropped it next to a delimiter, a comma and a closing bracket, and added spaces back when not minifying.
  3. A comment prints as /**/ when it is all of the text between two tokens. In every other place it does not print. Main dropped every comment, so a/**/b printed ab.
  4. A var() or env() that is not a value no longer fails the build. In a declaration it still does (a{x:var()}).
  5. @foo { } prints @foo{} (main: @foo{ }).
  6. A url() is an import in the prelude and in the block, as on main.
  7. A unit that is written with an escape or a NUL is written again from its decoded form. A leading e is written as \65 , because e after a number can start an exponent.

What changes in the arguments of an unknown pseudo-class or pseudo-element

This half is a consolidation. The two raw helpers of TokenList had these callers only, and the new type replaces them. Real selectors print the same bytes.

  • A number prints its source text (:x(p-2.5) printed :x(p-20.5), 1e3 printed 1000.0).
  • A comment between two tokens stays as /**/ (:x(a/**/b) printed :x(ab)).
  • Two selectors are one selector only when the arguments are written the same. a:x(.5) and a:x(0.5) are no longer merged as duplicates. Main compared the parsed numbers.
  • Whitespace and url() print as on main.

Other changes in the diff

  • TokenList::parse is private to its module. TokenList::parse_value is the one door for a caller outside it (SyntaxString::parse_value), so a new caller has to name the value context.
  • TokenList::parse_raw, TokenList::to_css_raw and the TokenListFns re-exports are deleted. Two comments that named them are corrected.

Not in this PR

Questions for a maintainer

  1. lightningcss 1.33.0 has every rewrite of the table except the dropped whitespace, because it stores this text as a value too. esbuild 0.28.2 keeps this text as written, except that it drops comments (a/**/b prints ab). Is it accepted that Bun leaves lightningcss here? If a declaration in an unknown at-rule must keep its minification, the type stays and a splitter adds a value entry for each declaration.
  2. Three open PRs change the same functions: css: keep a url() in an at-rule prelude as written, and never print the resolved path of a url() #45604 (a prelude url() is not an import), css: keep whitespace and comments in token lists #45577 (token lists keep whitespace and comments, and the printer collapses them) and css: fold relative colors on the scale of their function, and keep them as written when the fold is not exact #45362 (a separator between tokens that would join). This PR is written to land in any order:

Self-review. Addressed: the join table of the first draft is gone (it missed / before *=, which opened a comment), an escaped unit is written again, whitespace is collapsed by the printer and not by the parser, TokenList::parse is private, the stale comments and the TokenListFns re-export are gone, tests for the error arm, the copies of a rule and the duplicate check. Not done:

Measured (release builds of 91e7e3b and of f0dd700 on x64 Linux. f0dd700 is this PR before main was merged into it, and the diff of src/ is the same now. Instruction counts are from qemu-user 10.0.13 with one instruction per block, for the code of bun_css)

  • A sheet with no unknown at-rule:
    • TokenList::parse_into_impl: 5,750 bytes and 1,222 instructions in both builds, 0 differing instructions (llvm-objdump).
    • TokenList::to_css: 7,135 -> 5,556 bytes. ext::url_to_css (1,488 bytes) is no longer inlined into it, because RawTokens::to_css also calls it. Instructions inside the two functions: a{--x:url(./a.png) 1px} 204 -> 220, a{--x:1px solid #fff} 197 -> 195.
    • TokenList::parse: 429 -> 436 bytes, 42 -> 41 instructions for one call.
    • tailwind.css (127 KB): 28,910,228 -> 28,896,629 instructions (-13,599). TokenList::to_css -13,899, TokenList::parse -2,938, token equality +4,746 (Token::eql is no longer inlined).
    • hint.css: -549 of 2.04 M. One drop_glue runs 1 to 3 times in a build (618 instructions each), in both builds. No other count changes between runs.
  • An unknown at-rule:
    • 1,000 rules of @foo { a:is(.b) { width: calc(1px + var(--x)) } }: 12,313,241 -> 11,530,859 instructions (-6.4%). TokenList::parse calls 4,000 -> 0. Vec growths 5,000 -> 4,000.
    • The repro: parse_hash_color calls 1 -> 0.
  • Binary: release .text 81,665,765 -> 81,666,021 bytes (+256), .rodata equal. RawTokens::parse_into is 2,041 bytes and RawTokens::to_css is 973 bytes. TokenList::parse_raw (1,508 bytes) is gone.
  • size_of::<RawToken>() is 32 bytes, equal to Token (const assertion). A TokenOrValue is 72.
  • Output: the 15 sheets of test/js/bun/css/files, with and without --minify: 30 of 30 outputs byte-identical.
  • Tailwind 4 directive sample, minified bytes: @theme 379 -> 407, @apply 212 -> 226, @utility 143 -> 143, @custom-variant 185 -> 191, @custom-selector 112 -> 121.
  • valgrind and perf are not on the build machine.

Not in the test suite. Two random runs on the debug build found no difference: 80,000 sequences of tokens with random separators print the expected text, and 30,000 dimensions with escaped units print as the same token in an at-rule and in a pseudo-class (the value path reads both spellings the same way).

Suites run. Debug build: css.test.ts (1,469 pass), test/bundler/esbuild/css.test.ts (61 pass, 1 todo), test/bundler/css/ (184 pass), test/bake/dev/css.test.ts (15 pass), test/bundler/bundler_html.test.ts (24 pass), test/js/bun/http/bun-serve-html.test.ts (31 pass, 2 skip). Release build of this PR: all of test/js/bun/css/ (2,672 pass, 0 fail). On the debug build, css-fuzz.test.ts and nested-selector-expansion.test.ts in that directory reach their time limits. They have no unknown at-rule. The first three suites were run again after main was merged in.

The prelude and the block of an unknown at-rule went through the parser for
declaration values. That parser reads a hash as a color, so the ID selector
in `@foo { a#f00:hover {} }` printed as `ared:hover`.

Store this text as `RawTokens`: a list that holds tokens, the source text of
numbers, and `url()` imports. It has no entry for a color, a length or a
`var()`. The arguments of an unknown pseudo-class or pseudo-element use the
same type. It replaces `TokenList::parse_raw` and `TokenList::to_css_raw`.
`TokenList::parse` is now private, and `parse_value` is its one caller
outside the module.
@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main (91e7e3b) with:

printf '@foo { a#f00:hover { color: red } }\n' > c.css
bun build ./c.css --minify --outdir=out && cat out/c.css

Main prints @foo{ared:hover { color: red }}. This PR (#46061) prints @foo{a#f00:hover { color: red }}.

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:24 AM PT - Oct 22nd, 2026

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


🧪   To try this PR locally:

bunx bun-pr 46061

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

bun-46061 --bun

@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 16 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between a155979 and b1ede6c.

📒 Files selected for processing: 9
  • src/css/css_parser.rs
  • src/css/lib.rs
  • src/css/properties/custom.rs
  • src/css/rules/unknown.rs
  • src/css/selectors/parser.rs
  • src/css/selectors/selector.rs
  • src/css/values/syntax.rs
  • test/bundler/esbuild/css.test.ts
  • test/js/bun/css/css.test.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 0e555e18-08af-4180-88d4-af130c0c260a
📥 Commits

Reviewing files that changed from the base of the PR and between f0dd700 and a155979.

📒 Files selected for processing: 1
  • test/bundler/esbuild/css.test.ts

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

Walkthrough

Unknown CSS at-rules and custom selector function arguments now use context-aware raw tokens. The changes add raw-token parsing and serialization, update related exports and call sites, and add tests for token preservation, URL inlining, invalid tokens, and nesting limits.

Changes

CSS Raw Token Preservation

Layer / File(s) Summary
Raw token parsing and serialization
src/css/properties/custom.rs, src/css/lib.rs, src/css/values/syntax.rs
Adds RawTokens, RawToken, and RawTokensContext, including parsing, context-sensitive serialization, and cloning. Updates TokenList parsing visibility and related exports.
Unknown at-rule integration
src/css/css_parser.rs, src/css/rules/unknown.rs, test/js/bun/css/css.test.ts, test/bundler/esbuild/css.test.ts
Unknown at-rule preludes and blocks use RawTokens in AtRule context. Tests cover token preservation, formatting, ID selector preservation, PNG URL inlining, invalid tokens, and nesting limits.
Custom selector function arguments
src/css/selectors/parser.rs, src/css/selectors/selector.rs
Unknown functional pseudo-class and pseudo-element arguments use RawTokens in SelectorArguments context for parsing and serialization.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to a1559

The reviewed change adds tests that protect unknown at-rule token preservation. No actionable merge-blocking risk was found in the supplied context.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: preserving unknown at-rule preludes and blocks as raw tokens.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Description check Passed The description is complete and relevant. It explains the problem, implementation, scope, trade-offs, limitations, and verification results. Although it does not use the exact template headings, it pr…
Full details: Description check

Explanation

The description is complete and relevant. It explains the problem, implementation, scope, trade-offs, limitations, and verification results. Although it does not use the exact template headings, it provides the required content, including how the code was verified.

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

@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 that RawTokens::parse_into keeps the old parse_raw contract (same 500-deep nesting limit, same parse-error token rejection), that the number source slice is safe after a nested block or url() (the parser state is re-taken at the top of every loop iteration, so slice_from_cloned only covers the number token), and that number_len and write_gap's gap.start - 1 are bounds-safe (.get() throughout; inside guarantees gap.start > 0).

Extended reasoning...

The change replaces TokenList-based parsing of unknown at-rule preludes/blocks and unknown pseudo-function arguments with a new RawTokens type in src/css/properties/custom.rs, touching css_parser.rs, rules/unknown.rs, selectors/parser.rs and selector.rs, plus ~250 lines of new tests. It touches no auth, crypto or permission surface; the parsing is of CSS source text with bounded index arithmetic. Two inline findings were reported, so human review is already signalled; this note only records the additional paths examined.

Comment thread src/css/properties/custom.rs
Comment thread src/css/rules/unknown.rs

This branch has not been deployed

No deployments
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.

1 participant