Skip to content

css: keep the earlier color/text-shadow when a later unparsed value has no var() or env() - #47284

Open
robobun wants to merge 1 commit into
mainfrom
robobun/b4c742fd/css-keep-fallback-before-tokens
Open

robobun wants to merge 1 commit into
mainfrom
robobun/b4c742fd/css-keep-fallback-before-tokens

Conversation

@robobun

@robobun robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build deletes a color or text-shadow fallback. .a{color:#000;color:contrast-color(#ff69b4)} prints .a{color:contrast-color(#ff69b4)}, so a browser with no contrast-color() has no color.
  • Real CSS has it: pride_palette loses 232 fallbacks, Lighthouse one, and @csstools/postcss-contrast-color-function prints it. Since css: consolidate the open CSS parser fixes #47025, color:red;color:rgb(from color(display-p3 0 1 0) r g b) loses red too.
  • Cause: the Property::Unparsed branch of FallbackHandler::handle_property (src/css/properties/prefix_handler.rs:134) writes every unparsed value over the earlier declaration.

Fix

  • The branch replaces the earlier declaration only if UnparsedProperty::is_kept_at_parse_time() holds (var(), env(), inherit, initial, unset) or the two are equal. Otherwise it appends.
  • A browser validates var() and env() after substitution. It drops any other value that it cannot read and uses the earlier declaration.
  • Verified: test/js/bun/css/css.test.ts (73 new rows, 57 fail on main). Also test/js/bun/css, test/bundler/css, test/bundler/esbuild/css.test.ts.
  • Self-reviewed: 19 concerns raised, 17 addressed, 2 in part (Notes).

Background

Downsides

  • text-shadow:X;text-shadow:none prints both, as box-shadow does on main: +165 B in one real stylesheet.
  • Rules that merge, each with another token-only color, take quadratic time, as width:foo(i) does on main: 8,000 pairs 4.2 s, was 0.04 s.
  • Release .text +768 B. An unparsed color costs 88 instructions in handle_property, was 85.
Notes

Real input

Built with release builds of main 7942008 and of this branch, bun build <file> --minify:

Stylesheet Author wrote main branch
TRezendes/pride_palette pride-palette.css color:#000000; color:contrast-color(#ff69b4), 232 rules 0 fallbacks, 34,304 B 232 fallbacks, 36,856 B
djibe/recommandations-medicales assets/css/material.css (recomedicales.fr) color:#4150f5; color:contrast-color(primary), 7 rules 0 fallbacks, 220,120 B 7 fallbacks, 220,218 B
GoogleChrome/lighthouse viewer/app/styles/viewer.css color:#0000ee; color:-webkit-link color:-webkit-link, 2,049 B both, 2,060 B
  • No browser reads contrast-color(primary). Chrome 153 computes rgb(65, 80, 245) from the source and from the output of this branch. From the output of main it computes the inherited color.
  • The README of @csstools/postcss-contrast-color-function shows its output: color: rgb(0, 0, 0); color: contrast-color(oklch(82% 0.2 330));. Main prints that pair without color:#000.
  • No stylesheet was found with a relative color after a fallback where the origin is a static color, which is the input that changed with css: consolidate the open CSS parser fixes #47025. Before css: consolidate the open CSS parser fixes #47025 that pair printed .a{color:#00f942}. This branch keeps red. It does not bring back the folded color.
  • A corpus of 5,397 stylesheets (36 in this repo, 5,361 from 203 npm packages) has no such pair: 0 files differ between the two builds, at the default targets and with --target=bun.

History

Not fixed here

Two more sites drop held state for an unparsed value. A flush at either one makes other output wrong at the default targets, so they need their own design:

  • BorderRadiusHandler (src/css/properties/border_radius.rs:213): .a{border-start-start-radius:1px;border-start-start-radius:foo(1)} prints only foo(1). A flush sends the first corner to a :dir() rule, which is printed after the rule and wins over a later border-radius.
  • BorderHandler (src/css/properties/border.rs:1457): .a{border-image:linear-gradient(red,blue) 1;border:foo(1)} prints .a{border:foo(1)}.

Limits that are the same as on main:

  • color:red;color:var(--) and color:red;color:var(--x,!) print only the var(). Chrome 153 drops both at parse time.
  • VAR(--x) in upper case is kept as a function, so the earlier declaration stays.
  • The @supports copy of an earlier var() declaration is dropped by any later declaration of the property: color:var(--c,lab(50% 20 30));color:foo(1) prints color:var(--c,#a16945);color:foo(1) at Chrome 80.
  • Only color and text-shadow collapse X; var(). Other properties print both.
  • A typed color after a token value gets no sRGB fallback: color:foo(1);color:lab(50% 20 30) prints as written at Chrome 80. The typed arm adds fallbacks only for the first declaration of the property (prefix_handler.rs:46).
  • A typed color that a target lacks does not drop the @supports copy of an earlier token value: color:foo(lab(50% 20 30));color:lab(60% 0 0) at Chrome 80 prints the copy color:foo(lab(50% 20 30)) after the rule, where it wins over lab(60% 0 0). The push at prefix_handler.rs:55 does not call remove_conditional_property.

Output that grows with the same meaning

  • text-shadow:1px 1px red;text-shadow:none: 20 to 44 B. The typed parser of text-shadow takes no none and no calc(), min(), max() or clamp() length, so these values are tokens. kristensen-kor/tablecraft style-template.css has three such pairs: 10,433 to 10,598 B.
  • color:red;color:foo(1);color:var(--x) prints color:red;color:var(--x): 18 to 28 B. color:red;color:foo(1);color:blue prints color:red;color:#00f: 14 to 24 B.
  • n adjacent equal rules .a{color:red;color:foo(1)} print color:red n times: 20,017 B for 2,000 rules. Main prints 20,025 B for 2,000 .a{color:red;color:lab(50% 20 30)} rules.
  • Inside an at-rule that tests the later value: @supports (color:contrast-color(red)){.b{color:red;color:contrast-color(blue)}} is 69 B on main and 79 B here.

Cost

Release LTO x64 builds of main 7942008 and of this branch on that base. The head differs from the measured build in comments and one named constant.

  • FallbackHandler::handle_property, instructions per call (qemu-user): 28, 36, 97 or 102 for a declaration that is not an unparsed color or text-shadow, the same on both builds. Unparsed color with an empty slot: 85 to 88. Unparsed text-shadow: 79 to 80. The difference is register moves. Over the 15 stylesheets of test/js/bun/css/files: 887,386 to 892,015 instructions in 24,683 calls.
  • is_kept_at_parse_time ran 0 times in 45,777 DeclarationHandler::handle_property calls over those 15 stylesheets (gdb hit counts).
  • Per X; var() pair: +55 instructions. var() in an rgb() alpha: +95. Per pair that now keeps both: +249 and one more 224 B Property in the list.
  • .text 83,513,897 to 83,514,665 B. handle_property 5,767 to 5,891 B. Predicate and walk 536 B. The stripped binary has the same file size.
  • mimalloc entry calls per stylesheet, pinned to one CPU, 6 runs each: the same values on both builds (bulma.css: 54,406).
  • sizeof FallbackHandler 32 and UnparsedProperty 48 on both builds.
  • Merge cost, .a,.b{color:foo(0)} then n pairs .a{color:foo(i)}.b{color:foo(i)}, user CPU at n = 2,000 / 4,000 / 8,000: 0.24 / 1.02 / 4.25 s here, 0.01 / 0.02 / 0.04 s on main. width:foo(i) on main: 0.17 / 0.69 / 3.04 s. Each merge minifies the whole merged block again (src/css/rules/mod.rs:1202), and the block now holds one color per value. 2,000 and 4,000 adjacent .a{color:red;color:foo(1)} rules stay linear: 8,000 and 16,000 handle_property calls.

Self-review

Addressed: the real input and the history above, test rows for each clause of the predicate and for the slot that moves, the one-line comment. In part: the two sites under "Not fixed here" and the merge cost are described here. No tracking issue exists for them.

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:06 AM PT - Oct 29th, 2026

❌ @robobun, your commit 01d0796 has 3 failures in Build #135108 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47284

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

bun-47284 --bun

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on main 7942008 with the default targets:
    printf '.a{color:#000;color:contrast-color(#ff69b4)}\n.b{color:red;color:rgb(from color(display-p3 0 1 0) r g b)}\n' > in.css
    bun build ./in.css --minify --outdir out && cat out/in.css
    Main prints .a{color:contrast-color(#ff69b4)}.b{color:rgb(from color(display-p3 0 1 0) r g b)}.
    This branch prints .a{color:#000;color:contrast-color(#ff69b4)}.b{color:red;color:rgb(from color(display-p3 0 1 0) r g b)}.
  • PR: css: keep the earlier color/text-shadow when a later unparsed value has no var() or env() #47284
  • The branch is rebased on main da867c6 (head 01d0796).
  • CI on that head (build 135108): 184 jobs passed. They include every Linux and Windows job and the four darwin any aarch64 test jobs. The diff is green. Five macOS jobs did not pass:
    • The four darwin any x64 - test-bun jobs expired in the queue. No agent took them, so they did not run.
    • darwin 27 aarch64 - test-bun reached its 60 minute limit. In that job three network tests failed four attempts each: test/js/bun/http/serve-body-leak.test.ts, test/js/bun/http/serve.test.ts and test/js/bun/net/socket.test.ts. This diff changes only the CSS minifier and does not touch them. The same job passed on the previous head of this PR (build 134968), with the same diff.
    • Every other test in the failure list passed on a retry.
  • All review threads are resolved. The diff is ready for a maintainer.

@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 5ce072e and 01d0796.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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 37a5e3a and 5ce072e.

Files selected for processing: 3
  • src/css/properties/custom.rs
  • src/css/properties/prefix_handler.rs
  • 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; 0 remain after this review.

Walkthrough

The CSS prefix handler now checks whether a new unparsed value must remain at parse time before replacing an earlier declaration. Tests cover color and text-shadow declaration merging across default targets and Chrome 80.

Changes

CSS declaration retention

Layer / File(s) Summary
Parse-time value classification
src/css/properties/custom.rs
TokenList detects var() and env() substitutions recursively. UnparsedProperty::is_kept_at_parse_time returns true for values with substitutions or a single case-insensitive inherit, initial, or unset identifier.
Declaration merge behavior
src/css/properties/prefix_handler.rs, test/js/bun/css/css.test.ts
The prefix handler replaces an earlier unparsed declaration only when the new value is kept at parse time or matches the held value. Otherwise, it appends the new declaration. Tests cover color and text-shadow merging, including unsupported values, fallbacks, and CSS-wide keywords.

Suggested reviewers: jarred-sumner, alii

Priority: Normal

Merge Risk: Minimal · previous review 5ce07

The change preserves fallbacks for unsupported values. No merge-blocking issue remains.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title names the main change: the earlier color or text-shadow declaration is kept when a later unparsed value has no var() or env(). The changeset matches this, since prefix_handler.rs replaces th…
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 clearly explains the problem, root cause, fix, scope, verification steps, test coverage, performance impact, and known limitations. It does not use the template headings exactly, but i…
Full details: Title check

Explanation

The title names the main change: the earlier color or text-shadow declaration is kept when a later unparsed value has no var() or env(). The changeset matches this, since prefix_handler.rs replaces the earlier declaration only for kept-at-parse-time values and the new is_kept_at_parse_time predicate supports it. The title is specific and concise, and it is one sentence.

Full details: Description check

Explanation

The description clearly explains the problem, root cause, fix, scope, verification steps, test coverage, performance impact, and known limitations. 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.

@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/prefix_handler.rs — pre-existing: Users targeting an older browser get no color at all when a typed wide-gamut color follows an unparsed one, e.g. .a{color:foo(1);color:lab(50% 20 30)} at chrome80 prints foo(1);lab(50% 20 30) with no #a16945 fallback. The typed branch skips get_fallbacks at src/css/properties/prefix_handler.rs:46 whenever the slot is held, even when the held value is an Property::Unparsed that is_kept_at_parse_time() rejects, so nothing readable by the target remains. Fix: generate the typed value's fallbacks unless the held declaration is one every target reads (a typed compatible value, or an unparsed value kept at parse time), for both color and text-shadow.

    Why this was flagged

    Input .a{color:foo(1);color:lab(50% 20 30)} minified with chrome 80 targets through FallbackHandler::handle_property. foo(1) is Property::Unparsed; src/css/properties/prefix_handler.rs:141-142 pushes it and sets color = Some(0). The typed lab() then reaches the handle_unprefixed! macro: src/css/properties/prefix_handler.rs:46 tests $self_field.is_none(), which is false, so the fallbacks closure at prefix_handler.rs:81-89 never runs and no #a16945 is pushed; prefix_handler.rs:51-56 then pushes lab() because it is not compatible with chrome 80. Output is .a{color:foo(1);color:lab(50% 20 30)}; Chrome 80 parses neither and the element has no author color, whereas .a{color:lab(50% 20 30)} alone prints .a{color:#a16945;color:lab(50% 20 30)}. The base branch produces the same output for this input, so this is pre-existing. The guard at line 46 assumes the held slot is readable by every target, which the new is_kept_at_parse_time() predicate (src/css/properties/custom.rs:1413) now makes explicit is not true for an unparsed value without var()/env().

    Verification: pre-existing — the base branch produces the same output by the same route. In src/css/properties/prefix_handler.rs, lines 140-143 push foo(1) at slot 0 and set color = Some(0); line 46 if $self_field.is_none() is then false, so the fallbacks closure (lines 81-89) never runs, and lines 55-56 push lab(). Output: .a{color:foo(1);color:lab(50% 20 30)} with no #a16945, so a Chrome 80 target reads neither declaration and gets no color.

  • 🟣 src/css/properties/prefix_handler.rs — Users at a non-lab target who write an unparsed color containing lab() followed by a typed lab() color get the wrong color in lab-capable browsers: the @ supports copy of the earlier unparsed value prints after the rule and beats the later typed declaration. The typed push at src/css/properties/prefix_handler.rs:51-56 never calls remove_conditional_property, unlike the replace path at :58, and the diff now routes more unparsed values into dest alongside a live typed successor. Fix: every path that writes a later declaration of the same property, including the push at :55-56, must remove the staged conditional copies of the earlier declaration.

    Why this was flagged

    Trigger: an unparsed color whose tokens contain a lab()/lch()/oklab() color (color-mix, contrast-color, any unknown function) followed by a typed lab() color, minified with browsers that lack lab support (chrome80 in the tests). First declaration goes through prefix_handler.rs:133 and stages an @ supports (color:lab(0% 0 0)) copy via context.rs:387. Second declaration hits the typed macro: $self_field is Some so the push branch at prefix_handler.rs:51-56 runs because is_compatible is false; that branch does not call remove_conditional_property (only the replace branch at :58 does). The staged copy is emitted after the rule and, in a lab-capable browser that also reads the unparsed function (color-mix is supported wherever lab is), it wins over the typed lab(60% 0 0) the author wrote last. Base has the same push path, but the PR's own comment at context.rs:357 states the invariant ("The @ supports copies print after the rule, so they would beat a later declaration") and the diff adds the equal/kept predicate without applying that invariant to this sibling site.

    Verification: Pre-existing (base fails identically by the same route; the diff touches only the sibling unparsed branch and neither widens nor narrows this). Only the replace branch at src/css/properties/prefix_handler.rs:57-59 calls context.remove_conditional_property(property.property_id()); the push branch at :51-56 never does, so the staged copy survives.

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

robobun commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

The two findings outside the diff are confirmed on main 7942008 and on this branch. Both builds print the same output, so this PR does not change either one.

  • .a{color:foo(1);color:lab(50% 20 30)} at Chrome 80 prints as written, with no #a16945. The typed arm adds fallbacks only for the first declaration of the property (prefix_handler.rs:46).
  • .a{color:foo(lab(50% 20 30));color:lab(60% 0 0)} at Chrome 80 prints .a{color:foo(#a16945);color:lab(60% 0 0)}@supports (color:lab(0% 0 0)){.a{color:foo(lab(50% 20 30))}}. The push at prefix_handler.rs:55 does not call remove_conditional_property, so the copy wins over the later lab(60% 0 0).

Both are in the typed arm, which this PR leaves as it is. The description now lists them with the other limits that are the same as on main. The four inline threads have their own replies.

@robobun
robobun force-pushed the robobun/b4c742fd/css-keep-fallback-before-tokens branch from 5ce072e to 9c31edf Compare October 29, 2026 05:48
…as no var() or env()

FallbackHandler wrote every unparsed `color` or `text-shadow` over the
earlier declaration of the property. That is right only for a value that
no browser drops when it parses the declaration: one with `var()` or
`env()`, or `inherit`, `initial` or `unset`. A browser that cannot
read any other value drops the declaration and uses the earlier one, so
`color: red; color: rgb(from color(display-p3 0 1 0) r g b)` lost its
fallback.

UnparsedProperty::is_kept_at_parse_time holds that rule. The handler
replaces the earlier declaration when the rule holds or the two
declarations are equal. In every other case it appends the new one.
@robobun
robobun force-pushed the robobun/b4c742fd/css-keep-fallback-before-tokens branch from 9c31edf to 01d0796 Compare October 29, 2026 09:57

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