Skip to content

css: keep whitespace and comments in token lists - #45577

Open
robobun wants to merge 7 commits into
mainfrom
robobun/108dc636/css-token-list-whitespace
Open

robobun wants to merge 7 commits into
mainfrom
robobun/108dc636/css-token-list-whitespace

Conversation

@robobun

@robobun robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build re-spaces CSS values that the parser keeps as tokens: custom properties, var() values, unknown at-rules. --sel: .a .b prints as . a. b, or .a.b with --minify. @apply a/80 prints as a / 80 (Bun css transpiler formats the output wrongly when using @apply utility from tailwindcss #16712).
  • TokenList::parse_into_impl (src/css/properties/custom.rs:551) drops whitespace next to delimiters, functions and brackets, and skips comments. TokenList::to_css guesses the blanks back.

Fix

  • The parser stores every whitespace and comment token, and the printer adds none: it does not know their grammar. This ports lightningcss 32f1f52f and aa2ed1e3.
  • --minify prints each run of whitespace and comments as one space, or as /**/ for comments only. lightningcss prints nothing there: a/* c */b becomes ab.
  • Verified: test/js/bun/css/css.test.ts, token-list-roundtrip.test.ts, bun-pm-diff.test.ts. Self-reviewed: 16 concerns raised, 15 addressed. Not done: the upstream report.

Background

  • A TokenList holds a value with no typed grammar as raw tokens.
  • bun pm diff compares re-printed CSS. PrinterOptions::canonical_token_lists gives it one form for whitespace and comments.
  • Considered a patch for . and * only. .a .b and .a.b were stored as the same tokens.

Downsides

  • Output keeps the author's blanks and comments: 8,820 of 114,836 fixture lines change. Minified fixtures grow 0.32% (0.15% after gzip -9).
  • bun build of the 15 fixtures uses about 2 MB more memory (peak RSS +2.3% to +2.7%). Instructions: -0.13%, +0.24% with --minify.
  • bun pm diff reports a blank that a minifier adds after ): lightningcss 1.30.2 against 1.32.0 on bulma.css reads +33 -33.
Notes

Examples

input before after after, --minify
--sel: .a .b . a. b (.a.b minified) .a .b .a .b
@custom-variant dark (&:is(.dark *)); (& :is(. dark * )) as written as written
@apply text-[#fff] bg-[#000] minified: text-[#fff]bg-[#000] as written as written
width: attr(data-w type(<length>), 100px) type(< length> ) as written as written
filter: progid:DXImageTransform.Microsoft.gradient(...) DXImageTransform. Microsoft. gradient( as written as written
margin: var(--a) auto minified: var(--a)auto as written var(--a) auto
--x: a/* c */b ab as written a/**/b
.a:foo(a//**/*b) .a:foo(a/*b), an open comment as written .a:foo(a//**/*b)
--x: rgb(1 2 3 / *x) minified: rgb(1 2 3/*x), an open comment as written rgb(1 2 3/ *x)
--x: 2n+1 2n1 as written 2n+1
--r: 16 / 9 minified: 16/9 as written 16 / 9
.c{--sel:.a .b}.d{--sel:.a.b} one merged rule two rules two rules

Where this differs from lightningcss

  1. Under --minify a comment that is the only separator prints as /**/. lightningcss prints nothing, so it prints ab and an open /*.
  2. Under --minify the whitespace and comments at each end of a list do not print, and a value of only whitespace and comments prints as one blank. Printed as /**/ it would be an empty value, which older browsers reject.
  3. A number keeps the sign it was written with. lightningcss prints calc(1px+2px) as calc(1px2px).
  4. \ is a token only before a newline, so a newline follows it in the output. Before ) or ; it is an escape.
  5. A line break in a value prints as \n for CR LF, CR and FF in the source. The output does not follow the line endings of the checkout.
  6. The rule for a whitespace-only value stays in TokenList::to_css. lightningcss moved it into each caller.
  7. PrinterOptions::canonical_token_lists is new. bun pm diff is its only user.
  8. Two whitespace tokens are equal whatever their text, so two rules that differ only in the amount of whitespace merge. lightningcss compares the text.

The text of a value is kept as written without --minify, as in lightningcss. The printer no longer adds a blank on each side of a + or - delimiter.

#33683 added a check to TokenList::to_css that keeps / and * apart under --minify. This PR removes the check. The parser stores the blank now, so the printer has nothing to guess.

Not in this PR

Measurements

Release builds (--profile=release, ThinLTO) of the merge base d940fbe and of this PR, linux x64. The workload is one bun build with the 15 sheets in test/js/bun/css/files as entry points (2.9 MB), pinned to one CPU. Counters come from perf_event_open, user space only. Each number is the median of 30 runs.

default --minify
instructions 725,916,494 -> 725,003,443 (-0.13%) 721,762,708 -> 723,473,576 (+0.24%)
page faults 16,120 -> 16,603 (+483) 15,943 -> 16,358 (+415)
peak RSS 80,380 -> 82,576 KB (+2,196 KB, +2.7%) 79,600 -> 81,484 KB (+1,884 KB, +2.4%)
  • Another session of 40 runs read -0.12% and +0.24% for instructions, +375 and +369 page faults, and +1,876 KB and +1,836 KB of peak RSS.
  • CPU cycles do not resolve the cost. For the same two binaries they read +0.6% and +1.3% in this session and +1.8% and +2.0% in the other.
  • Wall clock on one CPU (41 interleaved iterations, two sets for each binary): 134.10 -> 136.12 ms (+1.5%, noise floor 1.8%). With --minify: 132.42 -> 136.41 ms (+3.0%, noise floor 1.1%). The noise floor is the difference between two sets of runs of one binary.
  • Wall clock, not pinned: 160.83 -> 156.84 ms, noise floor 11.68 ms. With --minify: 147.19 -> 148.57 ms, noise floor 13.02 ms. Two copies of the base binary differ by 1.67 ms. The delta does not show.
  • Binary: size text 106,411,931 -> 106,414,776 (+2,845 bytes). Data and bss do not change. Stripped file 106,555,144 -> 106,559,240 bytes.
  • size_of::<TokenOrValue>() is 72 before and after. Printer and PrinterOptions get one bool each. One host function is new (canonicalTest, in bun:internal-for-testing only).
  • Token lists: stored elements 69,833 -> 79,620 (+14%), Vec growth calls 38,292 -> 40,417, retained capacity 11,118,528 -> 12,149,280 bytes (gdb breakpoint at the end of TokenList::parse).
  • The second commit inlines the body of the print loop. Before it the instruction counts were +0.38% and +0.60%.
  • tokens.shrink_to_fit() at the end of TokenList::parse is not in this PR. In this session it took peak RSS to 77,984 KB (-3.0% against the merge base) and instructions to +0.46%, +0.83% with --minify. The spare capacity is on main too, so that change stands alone.
  • Minified CSS: 2,732,962 -> 2,741,835 bytes (+8,873, +0.32%), gzip -9 290,279 -> 290,705 (+426, +0.15%). bulma.css +7,827 raw, +323 after gzip -9.
  • The blank after a comma is 8,201 of those 8,873 bytes: 4,792 in custom properties and 3,409 in other declarations. lightningcss 1.32.0 keeps that blank under --minify too. A browser does not read it in the value of a known property, so --minify can drop it there again if the bytes matter more than one rule for every token list.
  • /**/ separators added under --minify: 0.
  • Output without --minify: 8,820 changed lines of 114,836.
  • Rules in minified output: 23,336 -> 23,336 on these fixtures. Two values that differ only in the amount of whitespace are equal for the rule merge, as on main, and the merged rule prints the whitespace of the first. Two values that differ by a blank (a, b and a,b) print differently now, so those rules no longer merge.
  • bun pm diff: a multi-line custom property with new indentation reads formatting only. tailwindcss 4.3.3 formatted again by Prettier: 3 of 3 files formatting only. A sheet against its bun fmt output: formatting only. bulma.css minified by lightningcss 1.30.2 and by 1.32.0: formatting only -> +33 -33, each line a blank after ).
  • The repro from the report: 0 of 10 runs change the text (8 on base). Outputs that differ from lightningcss 1.32.0 on 66 sheets in both modes: 6 of 132 (52 on base). They are 2 sign rows, 2 comment rows and 2 url() rows that the bundler cannot resolve.

Tests

  • exact_test in test/js/bun/css/util.ts compares byte for byte. cssTest ignores all whitespace and stays as it is.
  • canonicalTest in bun:internal-for-testing prints as bun pm diff does.
  • Suites run on the debug build: all of test/js/bun/css/, test/cli/install/bun-pm-diff.test.ts, test/bundler/css/, test/bundler/esbuild/css.test.ts, test/bake/dev/css.test.ts, test/bundler/bundler_html.test.ts and 13 CSS files in test/regression/issue/. css-fuzz.test.ts does not run in CI and times out on a debug build. On release builds it passes in the same time before and after.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/install/bun-pm-diff.test.ts

A token list is a value with a grammar that the parser does not know: a
custom property, an unparsed or unknown declaration, an unknown at-rule,
the arguments of an unknown function or pseudo-class.

The parser dropped the whitespace next to a delimiter, a function and a
closing bracket, and it skipped comments. The printer then guessed the
blanks back. `--sel: .a .b` printed as `. a. b`, or as `.a.b` with
--minify, and `a/* c */b` printed as `ab`.

The parser now stores every whitespace and comment token, and the
printer writes the stored tokens and adds none. When it minifies, each
run of whitespace and comments between two tokens prints as one space,
or as `/**/` when the run holds only comments, and the run at each end
of a list does not print. A number keeps the sign it was written with,
and a `\` keeps the newline after it.

`bun pm diff` compares a print with one form for what no grammar reads
in these values: the amount of whitespace, comments, and the blank next
to a comma or next to `*` and `/` in a math function.

This is a port of lightningcss 32f1f52f ("Do not remove whitespace in
token lists") and aa2ed1e3.
@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:55 AM PT - Oct 21st, 2026

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


🧪   To try this PR locally:

bunx bun-pr 45577

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

bun-45577 --bun

@robobun

robobun commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3-canary.1+367d939d9 (linux x64) with the script from the report. 8 of 10 runs change the text of the value. On this branch 0 of 10 runs change it.

printf '.c { --sel: .a .b; }\n' > s.css
bun build ./s.css --outdir out            # --sel: . a. b
bun build ./s.css --outdir out --minify   # --sel:.a.b

PR: #45577

`TokenList::write_token` is the body of the print loop. Inline it, print a
comma directly, and print a whitespace token of one blank without the scan
for line breaks.

`bun build` of the 15 sheets in test/js/bun/css/files, user instructions
against main: +0.38% before this commit, -0.12% after. With --minify:
+0.60% before, +0.24% after.
Comment thread src/css/printer.rs Outdated
Comment thread src/css/printer.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
Comment thread src/css/properties/custom.rs Outdated
@robobun
robobun marked this pull request as ready for review October 17, 2026 23:27
@coderabbitai

coderabbitai Bot commented Oct 17, 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 2 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
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 592ded0d-e4d5-4737-a519-ebda80973118
📥 Commits

Reviewing files that changed from the base of the PR and between 9af91ad and fd18d1d.

📒 Files selected for processing: 12
  • docs/pm/cli/pm.mdx
  • src/css/printer.rs
  • src/css/properties/custom.rs
  • src/css_jsc/css_internals.rs
  • src/js/internal-for-testing.ts
  • src/runtime/cli/pm_diff_normalize.rs
  • src/runtime/dispatch_js2native.rs
  • test/cli/install/bun-pm-diff.test.ts
  • test/js/bun/css/css.test.ts
  • test/js/bun/css/token-list-backtracking.test.ts
  • test/js/bun/css/token-list-roundtrip.test.ts
  • test/js/bun/css/util.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: b92bfa22-ca91-4d52-ab6c-772e18061405
📥 Commits

Reviewing files that changed from the base of the PR and between 6ec79fa and 9af91ad.

📒 Files selected for processing: 5
  • src/css/properties/custom.rs
  • src/js/internal-for-testing.ts
  • src/runtime/dispatch_js2native.rs
  • test/cli/install/bun-pm-diff.test.ts
  • test/js/bun/css/css.test.ts

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

Walkthrough

CSS token-list parsing and printing now retain whitespace and comments for mode-specific serialization. bun pm diff uses canonical token-list printing during CSS normalization. The changes add CSS printer tests, seeded round-trip tests, and CLI normalization coverage.

Changes

CSS token-list handling

Layer / File(s) Summary
Token-list parsing and printing
src/css/printer.rs, src/css/properties/custom.rs
Parsing retains whitespace and comments as tokens. Shared writers handle token serialization, whitespace collapsing, line breaks, and spacing around math operators and slash delimiters.
Canonical CSS normalization
src/css_jsc/css_internals.rs, src/js/internal-for-testing.ts, src/runtime/dispatch_js2native.rs, src/runtime/cli/pm_diff_normalize.rs, docs/pm/cli/pm.mdx, test/cli/install/bun-pm-diff.test.ts
A canonical test mode is exposed through CSS internals, and CSS normalization enables canonical token-list printing. Documentation and CLI tests cover the CSS comparison rules.
Token-list regression and round-trip tests
test/js/bun/css/css.test.ts, test/js/bun/css/token-list-backtracking.test.ts, test/js/bun/css/token-list-roundtrip.test.ts, test/js/bun/css/util.ts
Tests cover token preservation and canonical output across CSS contexts, browser-targeted rewrites, and build paths. Seeded tests check normal, minified, and canonical output stability.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 9af91

No identified issue blocks merging. The investigated unicode-range value remains intact.

Pre-merge checks: Passed 4
Check name Status Explanation
Description check Passed The description clearly explains the problem, implementation, tradeoffs, scope, and verification. It does not use the template headings exactly, but it provides the required change summary and test ev…
Title check Passed The title is concise, specific, and accurately summarizes the main change: preserving whitespace and comments in CSS token lists.
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.
Full details: Description check

Explanation

The description clearly explains the problem, implementation, tradeoffs, scope, and verification. It does not use the template headings exactly, but it provides the required change summary and test evidence in equivalent sections.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • 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.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/css/properties/custom.rs — Users running --minify get larger output: two rules whose token-list values differ only in whitespace amount or a comment no longer merge, even though both print the same bytes. Token equality at src/css/properties/custom.rs:216 compares Whitespace/Comment payloads byte for byte, and the parser now stores every run verbatim instead of one b" ". Fix: when minifying, compare token lists on the form the printer emits (any whitespace run equal to any other, comments ignored next to whitespace), so --x: a b and --x: a b still merge as on the base branch; the author's note that no merge is lost holds only for the fixtures measured.

    Why this was flagged

    Input: .a{--x:a b}.b{--x:a b} (or .a{--x:a /*c*/ b}.b{--x:a b}) built with bun build --minify. parse_into_impl at src/css/properties/custom.rs:718 now pushes Token::Whitespace(b" ") and Token::Comment as stored; the base replaced every run with a single Whitespace(b" ") and dropped comments. Rule merging in the minify pass compares declarations with TokenList eql, which reaches src/css/properties/custom.rs:216-217 and compares the whitespace/comment bytes with strings::eql, so the two values are unequal. The printer then collapses both runs to one space (src/css/properties/custom.rs:489), emitting .a{--x:a b}.b{--x:a b} where the base emitted .a,.b{--x:a b}. Nothing else canonicalises the lists before the comparison.

    Verification: nit — triggered when two rules carry the same token-list value whose whitespace runs differ only in length, built with --minify. src/css/properties/custom.rs:216-217 compares whitespace with strings::eql(a, b); parse_into_impl (custom.rs:715) stores whitespace verbatim, not one b" " as on base. Rule merging at src/css/rules/mod.rs:1150 uses that equality, so the rules no longer merge.

Comment thread src/css/properties/custom.rs
@robobun

robobun commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

A note on merge order with #45835, for the reviewer.

On main, unicode-range: U+4e00 } fails the typed parser, because a blank follows a value that is one number token. The raw token list then prints it as U4. #45835 fixes that parser.

This PR makes the raw printer keep the sign of a number (write_sign). If this PR merges first, the same input prints U+4, because the number token +4e00 has the value 4. U+4 is a valid range for a different code point, so a browser applies it. U+1e1 } becomes U+10 in the same way.

I read this diff and did not build the branch. With #45835 merged first, the value parses and never reaches the raw printer.

… rule

The diff print drops the blank next to `*` and `/` in the arguments of a
math function. A `var()` or `env()` fallback in those arguments printed
without that rule, so `calc(var(--x,10px*2))` and
`calc(var(--x, 10px * 2))` had two forms in `bun pm diff`.

A fallback behind a function with a grammar that is not known keeps its
blanks: `calc(foo(var(--w, a*b)))` prints as written.
On main the parser stored each run of whitespace as one blank, so two rules
whose values differ only in the amount of whitespace merged. With the text
kept as written they did not merge, although `--minify` prints the same
bytes for both.

Two whitespace tokens are now equal, and hash the same, whatever their
text. The merged rule prints the whitespace of the rule that stays.
@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

About the merge that --minify lost (.a{--x:a b}.b{--x:a b}): fixed in 9af91ad.

Two whitespace tokens now compare equal whatever their text, so these rules merge as on main: .a,.b{--x:a b}. The merge pass also runs without --minify. There the merged rule prints the whitespace of the rule that stays: .a, .b { --x: a b; }.

Two cases stay as they are:

  • .a{--x:a /*c*/ b}.b{--x:a b} prints two rules. Main prints two rules for it too.
  • .a{--x:a, b}.b{--x:a,b} prints two rules. The two values print differently now, so they are two values.

@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 completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

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

// Seeded round-trip test of CSS token lists. A token list is a value whose grammar the parser
// does not know, so it must print with the tokens it was written with. Replay a failure with
// BUN_CSS_TOKEN_FUZZ_SEED=<seed> (and BUN_CSS_TOKEN_FUZZ_ITERS for a soak).
import { cssInternals } from "bun:internal-for-testing";

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.

🟡 nit (optional): Maintainers get a new test file for CSS token-list behavior instead of the coverage living beside the existing CSS tests. CLAUDE.md:49 says to add tests to the existing test file for the code being changed and not to create a new file. Fix: move the seeded round-trip cases into test/js/bun/css/css.test.ts (next to the new "whitespace and comments in token lists" describe block), or state in the PR why a separate file is needed.

Why this was flagged

The PR adds test/js/bun/css/token-list-roundtrip.test.ts, a new file that exercises TokenList::parse_into and the printer in src/css/properties/custom.rs. The applicable root CLAUDE.md:49 states "Default: add your test to the existing test file for the code you're changing. Do not create a new file." and test/CLAUDE.md:152 repeats it. The existing file for this module is test/js/bun/css/css.test.ts, which this PR already extends with a describe block for the same behavior at css.test.ts (the "whitespace and comments in token lists" block). On the base branch no such file exists; after merging the module's coverage is split across two files, which the convention exists to prevent. No safeguard applies; this is a convention check, not a runtime failure.

Verification: nit. Trigger: unconditional — the PR adds a brand-new test file. git diff --stat shows test/js/bun/css/token-list-roundtrip.test.ts as a new file, while the same PR already extends the existing test/js/bun/css/css.test.ts with the same TokenList parse/print behavior. CLAUDE.md:49: "Do not create a new file." The PR description gives no reason for a separate file.

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.

2 participants