Skip to content

css: keep a typed @property initial-value as written when no component matches - #45807

Closed
robobun wants to merge 2 commits into
robobun/b8157b98/css-property-initial-valuefrom
robobun/b8157b98/css-property-as-written
Closed

robobun wants to merge 2 commits into
robobun/b8157b98/css-property-initial-valuefrom
robobun/b8157b98/css-property-as-written

Conversation

@robobun

@robobun robobun commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build fails with error: Unexpected token: rgb( on a valid @property rule: syntax: "<color>"; initial-value: rgb(from currentColor r g b).
  • The typed parsers resolve a value while they parse it, so they refuse valid values. SyntaxString::parse_value (src/css/values/syntax.rs:220) makes each refusal a build error.
  • light-dark() in an initial-value is printed as var(), which Chrome 153 drops.

Fix

Background

Downsides

  • A typed value that is not valid (foo for <color>) now builds as written. It was a build error, which lightningcss tests. A browser drops that rule. Acceptable?
  • Some of them become valid: <integer> with 1.0 prints 1, which Chrome registers. Main prints z-index: 1.0 the same way.
  • A rule kept as written: 15,904 instructions, 7 allocations. Rules that match: 0 more to parse, 8 fewer to print. .text +256 B.
Notes

Behaviour changes

  • A typed initial-value that no syntax component matches was a build error. It is now printed as written. That covers valid values that the typed parsers do not resolve, and values that are not valid.
  • light-dark() in an initial-value was printed as var(--buncss-light, ...) var(--buncss-dark, ...) for the default targets of bun build. It is now printed as light-dark(). For the same targets 2dppx now prints as 2x. Each browser that reads @property (Chrome 85, Firefox 128, Safari 16.4) reads x, #rrggbbaa, space-separated colors and double-position color stops. src/css/compat.rs has no Safari version for the x unit. caniuse has 16.0.
  • initial-value: red; initial-value: foo was a build error. It now prints foo. The last descriptor wins in Chrome 153 too.
  • Not changed: an empty value for a typed syntax and a typed rule with no initial-value are build errors.

Question for a maintainer

A typed initial-value that is not valid has no diagnostic any more. Is that acceptable?

  • lightningcss keeps the error and tests it: <color> with 25px and <length> with var(--some-value) are two error_test cases of its test_property. This PR asserts the opposite for both. Its maintainer wrote on @property initial-value parsing error parcel-bundler/lightningcss#921, about a valid value that does not resolve: "We should preserve the original value as is in that case."
  • The error is not a check of validity. The typed parsers are the only grammar knowledge there is, and they refuse valid values (RED, oklch(0.5 0 0), calc(1 + 2) for <integer>). Of the 14 typed values of the sample that main refuses, Chrome 153 registers 4.
  • The error also misses most values that are not valid. Chrome 153 drops 49 typed bodies of the sample that have a known syntax and a value. Main stops 10 of them, each an rgba(var(--a), var(--b)) in @universal-material/core, whose 8 style sheets main cannot build. Main builds the other 39. 38 of those are a font-relative length under <length>.
  • A browser decides in both cases. Chrome 153 and WPT drop the rule. The Editor's Draft of css-properties-values-api ignores only the descriptor: "or else the descriptor is invalid and ignored" (w3c/css-houdini-drafts e1f11c5e6f, 2026-08-06).
  • Some values that are not valid also become valid in the built rule, because the print of a token list is not verbatim. The section below lists them. Main does the same to a declaration.
  • A warning for each value kept as written fires on every valid value of the measured set. If you want a diagnostic for a subset that can be proven not valid, name the subset. It goes on top of the fallback in SyntaxString::parse_value.
  • One subset from the review: warn when the kept value holds no function, var() or env(). Of the 28 values of the measured set that are kept as written, it warns on 11 of the 14 that are not valid and on 1 of the 14 valid ones (RED, until css: match named colors without regard to ASCII case #45092). In the sample it warns on 5 of the 16 bodies that are not valid, and on none of the 40 rgba(var(--a), var(--b)) rules. A warning of the CSS parser has no file name and no line in the output of bun build today.
  • That draft also makes each descriptor optional, so the two errors that stay (an empty typed value, a typed rule with no initial-value) reject rules that the draft registers. No engine does that yet. Both are as on main.

Measured against Chrome 153 (headless, the source rule and the built rule, pretty and minified, 57 values)

Values that are not valid and become valid (Chrome 153, a second set of 29 values, release builds of main, of #45806 and of this PR)

  • A value kept as written has the print of a declaration. That print is not verbatim, so a value that no component matches can print as a value that a component matches. 20 of the 29 values do. Each is a build error in css: read an @property initial-value on the stylesheet's parser, keep an unmatched value as written #45806. Chrome drops the source rule and registers the built rule.
  • The print drops the type of a number: <integer> with 1.0, +1.0, 1e0, 1e2 or -0.0 prints an integer. Also in a list: 1 2.0, 1, 2.0.
  • It drops a sign or a comment between two tokens: 1+2 and 1/**/2 print 12 (<integer>, <number>), calc(1+2) prints calc(12), 5/**/0% prints 50%, a/**/b prints ab (<custom-ident>).
  • It prints a color as its shortest name: #f00, #808080 and rgb(255 0 0) print red or gray, which <custom-ident> and a keyword syntax accept.
  • Main has the same print for a declaration: z-index: 1.0 prints z-index:1, z-index: calc(1+2) prints z-index:calc(12), --x: a/**/b prints --x:ab.
  • Main also makes a typed value valid: syntax: "<length>"; initial-value: 5 builds as 5px, which Chrome registers.
  • Two ways to stop it, neither in this PR. The first keeps the type of a number and the boundary between two tokens in the token list (src/css/properties/custom.rs). It changes each declaration and each custom property (--x: 1.0 then prints 1.0), and the color case stays. The second keeps the build error for a value that holds number tokens only. It covers 11 of the 20.

Real style sheets (release builds of #45806 and of this PR)

Cost (release builds of #45806 and of this PR, gdb on bun-profile, rule 2 of 4 equal rules)

rule parse, instructions print, instructions
syntax:'<color>';inherits:false;initial-value:rgb(255 0 0) 7,467 -> 7,467 1,623 -> 1,615
the same with initial-value first 8,940 -> 8,940
syntax:'*';inherits:false;initial-value:rgb(from currentColor r g b) 12,539 -> 12,539 3,620 -> 3,612
syntax:'*';inherits:false 2,484 -> 2,484 1,186 -> 1,161
syntax:'<color>';inherits:false;initial-value:rgb(from currentColor r g b) (kept as written) error -> 15,904
  • Parse is one call of NestedRuleParser::parse_block. Print is one call of PropertyRule::to_css.
  • The rule kept as written: 47 Tokenizer::next_impl calls, 5 Parser::expect_exhausted, 7 mi_malloc, 2 mi_realloc. The same value under syntax:'*': 37, 4, 5 and 2.
  • Tokenizer::next_impl, Parser::expect_exhausted and mi_malloc counts of the other rules are the same as in css: read an @property initial-value on the stylesheet's parser, keep an unmatched value as written #45806.
  • A style sheet with no @property (bulma.css): SyntaxString::parse_value calls 0 -> 0, output bytes identical.
  • Release binary (llvm-size -A, stripped): .text 81,368,293 -> 81,368,549 B (+256), .rodata 22,598,604 -> 22,598,604 B (+0).
  • File system calls (openat, open, readlink, read, pread64, write, close, getdents64, counted with gdb catch syscall, because strace is not installed here): 77 -> 77 for the first rule of the table, 121 -> 121 for bulma.css.

Weighed and set aside

Not in this PR

Other PRs

Self-review

24 concerns raised, 22 addressed. The review asked for the split from #45806. Not done: a tracking issue for the descriptor arms and one for the media features. #45806 names both.

Suites run (bun bd test, debug build with ASAN)

  • test/js/bun/css/css.test.ts: 1,324 pass, 0 fail. test/bundler/esbuild/css.test.ts: 58 pass, 0 fail.
  • Each file of test/js/bun/css and test/bundler/css, with the two files above and test/regression/issue/27117.test.ts, one file at a time: 2,763 pass, 4 fail. The 4 are in css-fuzz.test.ts, a timing test that does not run in CI: 1,000 Bun.build calls do not fit its 10 s limit on a debug build.
  • 9 of the 10 @property tests fail on a debug build of css: read an @property initial-value on the stylesheet's parser, keep an unmatched value as written #45806. All 10 fail on a release build of main.

…t matches

A typed `initial-value` that no component of its `syntax` matched
stopped the build with "Unexpected token". The typed parsers resolve a
value while they parse it, so they also refuse valid values:
`rgb(from currentColor r g b)`, `oklch(0 0 0 / 0.97)`, `calc(1 + 2)` for
`<integer>`.

`SyntaxString::parse_value` now keeps such a value as a token list, and
the value is printed as written, as a declaration is. The browser
validates it. An empty value for a typed syntax is still an error.

The initial value is printed without the browser targets. `light-dark()`
was lowered to `var()`, which is not valid in an initial value.
@robobun

robobun commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:17 PM PT - Oct 17th, 2026

✅ @robobun, your commit 66797440c3bcebfb2d62d2dcc00d754c0516bf9e passed in Build #127838! 🎉


🧪   To try this PR locally:

bunx bun-pr 45807

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

bun-45807 --bun

@robobun

robobun commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

Status: open, stacked on #45806, waiting for CI and review.

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

Beyond the inline findings, I also checked that the new fallback in SyntaxString::parse_value starts from the value's first token (is_exhausted restores the parser state before TokenList::parse runs) and that the * syntax path is byte-for-byte the same as before, including an empty initial-value under *. The uppercase named-color case (RED kept verbatim under <color>) was also examined and is consistent with the as-written intent rather than a bug.

Extended reasoning...

The change touches the CSS parser's @ property handling in src/css/values/syntax.rs (typed initial-value falls back to a raw TokenList instead of erroring) and src/css/rules/property.rs (initial-value printed with browser targets cleared), plus test updates in test/js/bun/css/css.test.ts. It touches no security-sensitive surface. The Rust diff is small, but it deliberately removes a build-time error for invalid typed values, diverges from lightningcss behavior, and the author explicitly asks a maintainer whether that is acceptable, so this is a design call a human should make rather than an approval.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/css/values/syntax.rs
Comment on lines +216 to +227
// An empty value is a missing one, which is an error for a typed syntax.
if input.is_exhausted() {
return Err(input.new_error_for_next_token());
}
}
}

Ok(ParsedComponent::TokenList(TokenList::parse(
input,
&ParserOptions::default(None),
0,
)?))

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.

🔴 Authors who mistype a typed initial-value (for example <length> with red) now get a silent successful build whose @ property rule every browser drops, where the base branch failed the build with "Unexpected token". The fallback at src/css/values/syntax.rs:223 keeps any typed value as a TokenList with no diagnostic once no component matched; only an empty value still errors at syntax.rs:217. Fix: keep a diagnostic for the subset the typed parsers fully cover, for example warn through the parser log when the kept TokenList has no Function, Var, Env or UnresolvedColor token, while still passing through function-bearing values like rgb(from currentColor r g b).

Why this was flagged

A style sheet contains @ property --p { syntax: "<length>"; inherits: false; initial-value: red; } and goes through bun build or the CSS transpiler, reaching SyntaxString::parse_value at src/css/values/syntax.rs:102. Every component fails, input.reset(&state) runs at syntax.rs:213, the value is not exhausted so the check at syntax.rs:217 does not fire, and syntax.rs:223 returns ParsedComponent::TokenList holding red. PropertyRule::to_css at src/css/rules/property.rs:51 prints initial-value: red and the build exits 0 with no warning. On the base branch the same input returned Err(input.new_error_for_next_token()) and the build failed with Unexpected token: red, which test/js/bun/css/css.test.ts previously asserted for <color> with 25px, <length> with var(--some-value), <string> with hi, and red; initial-value: foo. Chrome, Firefox and Safari drop an @ property rule whose initial-value does not match its syntax, so the registered property silently disappears at runtime instead of at build time.

Verification: Every component attempt fails and input.reset(&state) runs (src/css/values/syntax.rs:213); the only remaining guard is if input.is_exhausted() { return Err(...) } (lines 217-219), which does not fire for a non-empty value; control falls through to Ok(ParsedComponent::TokenList(TokenList::parse(input, ...)?)) (lines 223-227). The base branch instead returned Err(input.new_error_for_next_token()).

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 the question that the PR body asks a maintainer, so this push adds no diagnostic. I measured the subset you propose: warn when the kept value holds no function, var() or env().

  • The 57 values of the PR body: 28 are kept as written. The rule warns on 11 of the 14 that are not valid. It does not warn on var(--x), anchor-size(width) and rotate(1turn) foo(1). It warns on 1 of the 14 valid values: RED, which CssColor::parse refuses until css: match named colors without regard to ASCII case #45092.
  • The real sample: 20 rule bodies are kept as written. The rule warns on 5 of the 16 that are not valid, each a list of font names under <string># in one file. It does not warn on the 40 rgba(var(--a), var(--b)) rules of @universal-material/core.
  • A warning of the CSS parser has no file name and no line in the output of bun build today. A nested @property prints warn: invalid @ rule encountered: '@property'.

So the rule catches a plain type mismatch such as red under <length>, with one known false warning. If a maintainer wants it, it is a check of the token list after the fallback in SyntaxString::parse_value. PropertyRule::parse must then take the ParserOptions, which it does not take today.

Comment thread src/css/rules/property.rs
Comment thread test/js/bun/css/css.test.ts
…ter a function

The rule after an `@property` rule is printed in the same sheet, so the
test fails if `PropertyRule::to_css` does not put the targets back.

The `<transform-list>` value that is kept as written has its blank again.
The minified form drops it and the pretty form keeps it.

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

The new commit covers both earlier nits (targets restored for the rule after @ property, and the rotate(1turn) foo(1) blank pinned in both pretty and minified form). Beyond the inline finding, I also checked that is_exhausted() at src/css/values/syntax.rs:217 resets the parser state (via expect_exhausted) so the TokenList::parse fallback sees the full value, and that dest.targets is restored before the ? in src/css/rules/property.rs:52 so an error while printing the initial value does not leave later rules without targets. The silent-build question for invalid typed values is still a maintainer call.

Extended reasoning...

The PR changes two CSS parser/printer paths (typed @ property initial-value fallback to a raw TokenList, and printing the initial value without browser targets) plus tests; no security-sensitive surface. The latest commit only adds tests addressing the two earlier nits. One nit was posted this run, and the behavioral trade-off the author explicitly raised (invalid typed values now build without a diagnostic, diverging from lightningcss) needs a maintainer decision, so not approving.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (1):

  • 🔴 src/css/values/syntax.rs:227 — Authors who mistype a typed initial-value (for example <length> with red) now get a silent successful build whose @ pro…

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Comment thread src/css/values/syntax.rs
@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this change is now part of #45806, so that the work is one PR against main. The commits are on that branch, and the body of #45806 has the question for a maintainer and the measurements.

@robobun robobun closed this Oct 19, 2026
robobun added a commit that referenced this pull request Oct 20, 2026
…not reduced

A typed `initial-value` that is not reduced is an `Unexpected token`
error, because `SyntaxString::parse_value` has no unparsed form. The
rows asserted that error. #45806 keeps such a value as written, so the
rows failed on a merge with it.

They are now todo rows that assert the value as written, next to the
two rows for valid values. Without #45806 the todo test fails with the
error. With #45806 it passes. The file passes in both cases.

The comment pointed to #45807. That change is now part of #45806.
robobun added a commit that referenced this pull request Oct 20, 2026
…not reduced

A typed `initial-value` that is not reduced is an `Unexpected token`
error, because `SyntaxString::parse_value` has no unparsed form. The
rows asserted that error. #45806 keeps such a value as written, so the
rows failed on a merge with it.

They are now todo rows that assert the value as written, next to the
two rows for valid values. Without #45806 the todo test fails with the
error. With #45806 it passes. The file passes in both cases.

The comment pointed to #45807. That change is now part of #45806.
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