Skip to content

css: do not add transition entries the declaration already lists - #38692

Closed
robobun wants to merge 8 commits into
mainfrom
farm/e66ba5e1/css-transition-prefix-dedupe
Closed

robobun wants to merge 8 commits into
mainfrom
farm/e66ba5e1/css-transition-prefix-dedupe

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With default targets, bun build prints wrong transition lists for 18 properties (mask* and three more). .a{transition:-webkit-mask-box-image 1s,mask-border 2s,opacity 3s} prints -webkit-mask-box-image 1s, -webkit-mask-box-image 2s, mask-border 3s, opacity 1s. Merged rules repeat -webkit-mask-size .3s, and each rebuild adds one.
  • expand_properties (src/css/properties/transition.rs) adds entries without a look at the list, and rule merging runs it on its own output. No user reported this.

Fix

  • An entry gains only the prefixes that no other entry of its property has, and a -webkit- mask name only when the list lacks it. No entry is removed or reordered.
  • ListedPrefixes (one VendorPrefix per PropertyIdTag) answers both. The loop fills it once, when the first entry needs it.
  • The mask-composite name row now also returns PropertyId::WebKitMaskComposite for -webkit-.
  • Verified: test/js/bun/css/css.test.ts, test/bundler/css/: 74 new cases, 54 fail on main. Self-reviewed: 31 concerns raised, 27 addressed (Notes).

Background

  • A prefixable PropertyId carries a VendorPrefix bit set, which Targets::prefixes widens to what the targets need.
  • mask-border, mask-composite and mask-mode have WebKit properties with other names, inserted as entries of their own.
  • Considered: a scan per entry (the first version: 7 s for 64,000 entries), a table for every list, upstream's merge_properties (wrong durations).

Downsides

Notes

Reproduce. Plain bun build, default targets, no flags. Bun 1.4.3 and main print the same.

echo '.a{transition:-webkit-mask-box-image 1s,mask-border 2s,opacity 3s}' > c.css
bun build c.css
# before: transition: -webkit-mask-box-image 1s, -webkit-mask-box-image 2s, mask-border 3s, opacity 1s;
# after:  transition: -webkit-mask-box-image 1s, mask-border 2s, opacity 3s;

printf '.a{color:red}\n.a{transition:mask-size .3s}\n' > a.css
bun build a.css
# before: transition: -webkit-mask-size .3s, -webkit-mask-size .3s, mask-size .3s;
# after:  transition: -webkit-mask-size .3s, mask-size .3s;

echo '.a{transition:-webkit-mask-composite 1s,mask-composite 2s}' > b.css
bun build b.css
# before: transition: -webkit-mask-composite 1s, -webkit-mask-composite 2s, mask-composite 1s;
# after:  transition: -webkit-mask-composite 1s, mask-composite 2s;

echo '.a{transition:mask-composite 1s}' > cur.css
for i in 1 2 3; do bun build cur.css --outdir=o >/dev/null; cat o/cur.css; cp o/cur.css cur.css; done
# before: 1, 2, 3 entries of `-webkit-mask-composite 1s`
# after:  1, 1, 1

Cost of the handler change. Release builds (--profile=release --lto=off) of one tree: main, the head before this commit (2ef7de1, which copied the list and scanned it for each entry), and this head.

One rule with n x mask-size 1s, cssInternals.prefixTest, Safari 15, three rounds, in ms:

n main head before this head
16,000 12, 11, 9 438, 454, 455 10, 11, 10
32,000 17, 18, 17 1,744, 1,783, 1,788 17, 18, 18
64,000 43, 35, 31 7,002, 7,100, 7,077 38, 32, 33
128,000 66, 67, 72 28,125, 28,573, 27,675 84, 74, 68

bun build of one rule with 64,000 x mask-size 1s: 62, 60, 57 ms (main 83, 54, 61 ms, head before 7,007, 7,099, 7,072 ms). 40,000 rules with a list of 1 / 4 / 12 entries that needs nothing: 57 / 105 / 221 ms (main 60 / 104 / 222 ms).

Instructions executed in one TransitionHandler::flush, from entry to return, without the allocator (gdb single step). The three numbers are main / head before / this head.

list 1 entry 4 entries 12 entries
opacity, color, width, top 688 / 781 / 690 1,864 / 2,226 / 1,791 4,454 / 5,614 / 4,197
transform, box-shadow, border-radius, flex (no prefix needed) 821 / 923 / 839 2,378 / 2,776 / 2,369 5,996 / 7,264 / 5,931
block-size, then plain names, Safari 8 731 / 754 / 762 1,900 / 2,192 / 1,858 4,562 / 5,512 / 4,398
mask-size 1,067 / 1,273 / 1,235 2,821 / 4,063 / 3,257 7,145 / 14,305 / 8,309
mask-border, then plain names 1,201 / 1,352 / 1,335 2,292 / 2,740 / 2,453 5,302 / 6,914 / 5,639
list 2 entries 4 entries 12 entries
transition: -webkit-mask-size 1s, mask-size 1s pairs 1,516 / 1,659 / 1,514 2,463 / 2,985 / 2,477 6,071 / 9,571 / 6,149
transition-property: -webkit-mask-size, mask-size pairs 499 / 851 / 698 794 / 1,629 / 1,113 1,974 / 6,203 / 2,773
  • The table is built at most once in a flush. It is not built when no entry needs a prefix or a -webkit- name. A list that already has the -webkit- entry does build it, which is the last row.
  • Heap allocations in a flush are the same as on main. The head before had one more malloc and free from the fifth entry.
  • Stack frame of flush: 920 bytes (main 648, head before 792). Text of the handler: 6,822 bytes (main 5,945, head before 7,970).
  • #[cold] on the table build: 12 plain entries cost 4,197 instructions with it and 4,230 without.

Output against the head before. 600,000 random stylesheets print the same bytes on 2ef7de1 and on this head. The lists use about 100 property names, 24 sets of targets, merged rules, runs of same-query @media rules, and a second pass over the output.

What the new transition cases pin. Each of these changes to the loop fails at least one case: a table that ignores the property, no write of the expansion to the rtl list, no insert into the rtl list, added prefixes computed against the unprefixed bit alone, no entry for a name that takes no prefix, a table that is built after the first store, all added prefixes dropped when one of them is listed, and a -webkit- name taken as listed when any entry of its property is.

The long list case. a long list is expanded in linear time builds transition-property lists of 200,000 x mask-size and of 500,000 x mask-border with -webkit-mask-box-image listed last, in a child process with a 20 s limit. The child takes 4.1 s on the debug build with ASAN. A release build of the head before needs about 70 s for the first list, so the limit ends it. duplicate-declaration-merge-hang.test.ts has the same shape.

Considered and not taken for the lookup. A pass for prefixes and -webkit- names in front of the loop for logical properties needs no write to the rtl list, but it walks each list twice. A table that is filled for each list costs a 256-byte fill where no entry needs it. A rewrite that appends to a new list also removes the quadratic insert, but it allocates for each list that changes, and it belongs with the fix of the durations.

Self-review of this head. 31 concerns raised, 27 addressed. Not taken:

  • A split of the name table row into its own PR. It is its own commit (5ea1d73), and it closes the one default-target case of this class that still grew.
  • A second-pass check inside prefix_test and minify_test (see "Not in this PR").
  • Cases that pin a duration that prints wrong today.
  • An issue on lightningcss about merge_properties.

The two changes need each other for mask-composite. masking::get_webkit_mask_property has 8 names that get a -webkit- entry with another name.

  • With the handler change only, 7 of the 8 are stable when the output is minified again. mask-composite still grows, because the written -webkit-mask-composite did not resolve to PropertyId::WebKitMaskComposite, the id that the handler looks for.
  • With the row change only, no transition output changes, because main inserts the entry without a look at the list.
  • The test transition over %s is the same when minified again runs all 8 names.

Reach. This PR has no issue. No user reported either case. A check of the output of this PR found the mask-composite case.

  • mask-composite is in 0 of 13,429 transition declarations in the 3,581 CSS files on the build machine (the packages of the repo and of its tests, and the install cache).
  • The checklist of MVP of CSS parser #14167 has -webkit-mask-composite ticked under "Minify and downlevel properties". Before this PR the parser never reached that property.
  • Under the default targets 18 property names gain an entry in a transition list: mask, mask-image, mask-position, mask-size, mask-repeat, mask-clip, mask-origin, mask-border and its five longhands, mask-composite, mask-mode, background-clip, backface-visibility, text-emphasis-color.
  • The 367 stylesheets that parse in the repository and in the install cache of the build machine print the same bytes on main and on this head.

What still prints wrong. The handler inserts a -webkit- mask entry into the property list only. Each property takes its duration by position, so the durations after an inserted entry shift. transition: mask-border .5s, opacity .1s prints -webkit-mask-box-image .5s, mask-border .1s, opacity .5s. main and lightningcss do the same. This PR stops the shift only for a list that already has the -webkit- entry, because nothing is inserted then. The open review thread on transition.rs describes the defect.

More cases, all with plain bun build:

  • A list that names one -webkit- mask entry while another entry still inserts one shifts the later durations by a different count than main. -webkit-mask-box-image 2s, mask-mode 1s, mask-border 1s ends in mask-border 2s here. On main it ends in mask-border 1s, after a second -webkit-mask-box-image 2s.
  • transition-property: mask-size, opacity; transition-duration: .3s, .2s prints three names for two durations, as on main.
  • transition: inset 1s, opacity 2s prints top 1s, bottom 2s, left 1s, right 2s, opacity 1s, as on main.
  • Two rules whose lists differ only in a name that Bun does not parse are taken for equal rules (CSS minifier drops one of two adjacent :where() rules that hold only custom properties (1.4.2 regression) #44704, css: compare custom property names in PropertyId equality and importance in duplicate-rule detection #33684 is open for it). .a{transition:fill .3s} .b{transition:stroke .3s} merges on main too. .a{color:red} .a{transition:mask-size .3s,fill .3s} .b{color:red;transition:mask-size .3s,stroke .3s} merges here and not on main, where the doubled entry of .a keeps the two lists apart.
  • The insert of a -webkit- mask entry, or of the names of a logical property, moves the rest of the list each time. Such a list costs quadratic time, as on main: 64,000 x mask-border 1s takes 459 ms (main 461 ms), 64,000 x inset 1s takes 472 ms (main 481 ms).

What changes for a -webkit-mask-composite declaration. The declaration parser uses the same name lookup in each block type (style rules, nested rules, @keyframes, @page, style attributes) and in @container style().

input before after
.a{-webkit-mask-composite:XOR} XOR xor
.a{-WEBKIT-MASK-COMPOSITE:xor} -WEBKIT-MASK-COMPOSITE:xor -webkit-mask-composite:xor
.a{-webkit-mask-composite: xor , source-over /* c */ } xor,source-over (space at the end) xor,source-over
.a{-webkit-mask-composite:xor}.b{-webkit-mask-composite:XOR} two rules .a,.b{-webkit-mask-composite:xor}
.a{-webkit-mask-composite:xor}.b{color:red}.a{-webkit-unknown:1} .b{color:red}.a{-webkit-unknown:1} (first rule lost) all three rules
@container style(-webkit-mask-composite: XOR) XOR xor
@import 'x.css' supports(-WEBKIT-MASK-COMPOSITE: XOR) name as written -webkit-mask-composite:XOR
values exclude, plus-lighter, var(--x), inherit, xor xor as written as written
names -moz-, -ms-, -o-mask-composite unknown name unknown name
  • The keyword list is the one of lightningcss: clear, copy, source-over, source-in, source-out, source-atop, destination-over, destination-in, destination-out, destination-atop, xor. WebKit also has plus-darker and plus-lighter (CSSProperties.json, <-webkit-compositing-operator>). Firefox takes the name as an alias of mask-composite (longhands.toml, extra_prefixes = ["webkit"]), so exclude is valid there. Such values stay unparsed and print as written.
  • The lost rule in row 5 comes from StyleRule::is_duplicate. It compares property ids, and all unknown names have the same id. The name now has its own id. This PR does not change the general case (css: compare custom property names in PropertyId equality and importance in duplicate-rule detection #33684 does).
  • CSS modules: the composes conflict check now takes -WEBKIT-MASK-COMPOSITE and -webkit-mask-composite in two composed files as one property, as it does for COLOR and color. -webkit-mask-composite and mask-composite stay two properties.
  • Bun has no mask handler, so a parsed -webkit-mask-composite passes through DeclarationBlock::minify in its place. mask-composite: exclude does not get a -webkit-mask-composite: xor line. That is as before.
  • The two real stylesheets on the build machine that have the declaration and parse (@carbon/styles 1.118.0, styles.css and styles.min.css) build to the same bytes with and without the row change.

lightningcss. master has the same two rows (src/properties/mod.rs: the key "-webkit-mask-composite", and From<CowArcStr> strips the prefix before from_name_and_prefix). So lightningcss also reads the name as a custom property and prints the declaration as written. Its merge_properties() folds a later entry of the same property into the first one and leaves the duration list at full length.

Measurements of the row change. Base is main 449877f with the handler change. Release builds, same machine, one after the other.

  • Size (nm -S, size -A): the name table values go from 4,016 to 4,000 B, the sort order from 502 to 500 B, the key text from 3,704 to 3,682 B. There are 183 maker functions where there were 184 (2,944 to 2,942 B). from_string is 841 B in both. .text is 82,166,153 B and .rodata is 22,598,604 B in both, because padding takes up the 42 B. The stripped bun is 107,730,656 B in both.
  • Code (llvm-objdump -d): 3 of the 225 instructions of from_string differ. All 3 are immediates: the key count 251 to 250 at two sites, and the length of the key text. The calls are the same: 4 strncasecmp, 1 sorted_key_position, 1 indirect call of the maker. There is no allocation and no syscall. The mask-composite maker goes from 6 to 10 instructions (a bit test and a conditional move, no branch).
  • Instructions executed (qemu-x86_64 -one-insn-per-tb -d exec,nochain -dfilter on from_string and sorted_key_position): one CSS module with a declaration for each of the 251 table keys and for 10 other names makes 264 lookups. They take 84,428 instructions in both builds. 4 lookups differ: border-block-end-color 470 to 502, border-block-end-style 264 to 296, border-block-end-width 330 to 264, -webkit-mask-composite 231 to 233. The three border-block-end-* names are in the same 22-byte group of the sorted table as the removed key.
  • Table replay (a Python model of from_string and the table): the 46,574 names in the 15 sheets of test/js/bun/css/files need 43,594 searches and 133,817 compare steps with both tables. valgrind and perf are not installed on the build machine.
  • Output: bun build prints the same bytes for those 15 sheets. None of them has the name.
  • Newly reached code (gdb breakpoints, debug builds): the five WebKitMaskComposite arms in properties_generated.rs (property_id, value_to_css, parse, deep_clone, eql) run 0 times without the row change for the new test inputs. All five run with it. eql runs when two rules have equal values. deep_clone runs for .foo, .bar:focus-visible with Safari 13, which splits the rule.

Not in this PR.

  • The duration shift after an inserted entry (see above).
  • Names that the handler prints only for targets that bun build cannot select. transition:flex-grow 1s for Firefox 20 prints -moz-flex-grow, which the table does not read back (45, 63, 81 bytes over three passes). -ms-order (IE 10) and -o-transition (Opera 12) behave the same.
  • all has no row: transition:ALL 1s keeps its upper case.
  • Other output that grows when bun build builds it again, for example :root{color-scheme:light} (64, 103, 142, 181 bytes over four passes, css: emit the color-scheme fallback vars once when a block is minified again #38512 is open for it).
  • A compile-time check that no key of the table starts with -. The table has no such key after this change. I can add the check if a maintainer wants it.
  • A shared test helper that checks each expected output again for a fixed point. It is a change to test/js/bun/css/util.ts for all tests, so it belongs in its own PR.
  • Links to reports in other projects about a doubled -webkit-mask-composite declaration. They are about declarations and not about transition lists, and I did not check them.

Other work on these lines. #45825 rewrites the table rows with an alias: column and changes from_string. It does not touch the two rows of this PR. Branch robobun/0e87a129/css-mask-handler (no PR) adds a mask handler and has the same row change in 61eb965.

Tests. bun bd test test/js/bun/css/css.test.ts: 1388 pass, 0 fail. 74 cases are new (73 in css.test.ts, 1 in test/bundler/css/css-modules.test.ts). On a build of main, 54 of them fail. With the handler change and without the row change, 23 fail. The other 20 pin behavior that does not change. test/bundler/css/, test/bundler/esbuild/css.test.ts and the other files of test/js/bun/css/ pass. css-fuzz.test.ts has 10 s limits that the debug build does not meet, with and without the change. It passes on a release build.

First description of the handler change (2026-08-14)

Problem

  • With CSS targets that need vendor prefixes (this includes bun build's default browser target, whose safari 14 entry still prefixes the mask-* properties), transition lists come out with duplicated prefixed entries:
    • .a{color:red} .a{transition: mask-size .3s} -> transition: -webkit-mask-size .3s, -webkit-mask-size .3s, mask-size .3s (plain bun build a.css)
    • a run of same-query @media rules merged into one gains one more copy per merged rule: three rules give -webkit-mask-size .3s, -webkit-mask-size .3s, -webkit-mask-size .3s, mask-size .3s
    • transition: margin-inline-start .2s, transform .2s (safari 8) duplicates -webkit-transform inside every generated :lang() rule, without any rule merging
    • already autoprefixed input does it in a single pass: transition: -webkit-transform .3s, transform .3s, opacity .2s (safari 6) -> -webkit-transform .3s, -webkit-transform .3s, transform .3s, opacity .2s; same for transition-property: -webkit-transform, transform
    • the same for the -webkit-mask-box-image entry inserted for mask-border: a merged rule or transition: -webkit-mask-box-image .2s, mask-border .2s ends up with it twice
  • Cause: expand_properties in src/css/properties/transition.rs expands every unprefixed entry to the full prefix set for the targets (set_prefixes_for_targets) and inserts the mask-box-image counterpart of a mask-border* entry unconditionally, without looking at what the rest of the list already says. The handler's own output lists every prefix as a separate entry, so each pass over a block adds the expansion again, and rule merging (flush_pending_style_merge and the CssRule::Media arm in src/css/rules/mod.rs) as well as add_logical_rule all run a block through the handlers more than once.
  • lightningcss 1.30.2 prints each of the inputs above with a single prefixed entry.

Fix

  • expand_properties keeps a copy of the list as written. When an entry is expanded for the targets, the prefixes the expansion added that another entry of the same property already carries are dropped again (expand_prefixes), and the -webkit-mask-box-image* entry is only inserted when the list does not contain it yet (webkit_mask_property_to_insert). Both helpers replace the previously duplicated ltr/rtl blocks.
  • Why this is the right shape:
    • Only bits the expansion added are ever removed, and no entry is removed or reordered. Each entry therefore keeps its own duration, delay and timing function (in flush those lists are paired with the property list by position), and author-written repeats such as transform 1s, transform 2s still expand both entries, since the unprefixed bit is never something the expansion added.
    • The handler's output is now a fixed point of the handler: every prefix it generated is present as an explicit entry of the same property, so a second pass adds nothing, and the output no longer depends on how many rules were merged into a block. The merged and :lang() outputs now match lightningcss.
    • Upstream handles the explicit-prefix case with merge_properties(), which folds later entries of the same property into the first one and drops them while leaving the duration/delay/timing lists alone, so every later entry is paired with the wrong values: lightningcss 1.30.2 turns transition: -webkit-transform .3s, transform .3s, opacity .2s into ..., opacity .3s, also without targets. Bun gets that input right today, so that function is deliberately not ported. Subtracting from the expansion instead gives upstream's output whenever the merged entries have the same values (every re-minify case) and keeps the author's values otherwise.
    • Upstream does not dedupe the mask-box-image insertion either (it duplicates it on a merged rule too); it is covered here because it is the same fixed-point requirement in the same function.
  • Verified:
    • test/js/bun/css/css.test.ts, transition block: 13 new prefix_tests (explicit prefixed entry before and after the unprefixed one, different durations per entry, transition-property, repeated unprefixed entries, merged same-selector rules, a run of three merged @media rules, the :lang() rules for a logical property, and mask-border: insertion for safari 15, no insertion for safari 16, explicit entry, merged rule, logical rules). 10 of them fail on the unfixed build with the duplicated entries, the other 3 pin the unchanged behavior they build on; all pass with the fix, along with the rest of the file.
    • bun bd test test/bundler/css/ test/bundler/esbuild/css.test.ts: pass.
    • bun build on the stylesheet below produces the single-entry output with the fix.

Background

  • Minifying a stylesheet runs every declaration block through the property handlers (DeclarationHandler); handlers such as TransitionHandler buffer the related declarations and write a minimal form in finalize/flush. When CssRuleList::minify merges a rule into an earlier one with the same selector, or an @media rule into the previous one with the same query, the merged block is minified again, and the :lang() rules a handler emits through add_logical_rule are minified as new rules. So a handler sees its own output as input, and its output has to be stable under that.
  • A PropertyId of a prefixable property carries a VendorPrefix bit set. Targets::prefixes replaces a set that contains the unprefixed bit with the full set the browser targets need and returns an explicitly prefixed set unchanged; that is what set_prefixes_for_targets applies to each list entry. flush then turns a multi-bit entry of a transition shorthand into one transition per bit (get_transitions), which is why the handler's own output comes back as separate single-prefix entries; a transition-property list prints a multi-bit entry as one name per bit.
  • mask-border and its longhands have no prefixed spelling; WebKit's equivalent is -webkit-mask-box-image, a different property, so masking::get_webkit_mask_property returns a separate PropertyId that the handler inserts in front of the entry instead of setting a prefix bit.
bun build repro (default targets)
.a { color: red }
.a { transition: mask-size .3s }
.b { transition: -webkit-mask-size .3s, mask-size .3s, opacity .2s }
@media (min-width: 1px) { .e { transition: mask-size .3s } }
@media (min-width: 1px) { .f { color: blue } }
@media (min-width: 1px) { .g { color: green } }

bun 1.4.0 / main:

.a { color: red; transition: -webkit-mask-size .3s, -webkit-mask-size .3s, mask-size .3s; }
.b { transition: -webkit-mask-size .3s, -webkit-mask-size .3s, mask-size .3s, opacity .2s; }
@media (min-width: 1px) {
  .e { transition: -webkit-mask-size .3s, -webkit-mask-size .3s, -webkit-mask-size .3s, mask-size .3s; }
  ...
}

with this change:

.a { color: red; transition: -webkit-mask-size .3s, mask-size .3s; }
.b { transition: -webkit-mask-size .3s, mask-size .3s, opacity .2s; }
@media (min-width: 1px) {
  .e { transition: -webkit-mask-size .3s, mask-size .3s; }
  ...
}
Comparison with lightningcss 1.30.2 (targets chrome 20, firefox 20, safari 5, ie 9, ios_saf 6, android 4 unless noted)
input bun before bun after lightningcss
.a{font-weight:bold}.a{transition: opacity 1s, transform 1s} opacity 1s,-webkit-transform 1s,-ms-transform 1s,-webkit-transform 1s,-ms-transform 1s,transform 1s opacity 1s,-webkit-transform 1s,-ms-transform 1s,transform 1s same as after
three merged @media (x:1) rules, first with transition: opacity 1s, transform 1s three copies of -webkit-transform 1s,-ms-transform 1s one same as after
.a{transition: opacity 1s, -webkit-transform 1s, transform 1s} -webkit-transform twice opacity 1s,-webkit-transform 1s,-ms-transform 1s,transform 1s same as after
.a{transition-property: opacity, -webkit-transform, transform} -webkit-transform twice opacity,-webkit-transform,-ms-transform,transform same as after
.a{transition: -webkit-transform .3s, transform .3s, opacity .2s} (safari 6) -webkit-transform twice, opacity .2s -webkit-transform .3s,transform .3s,opacity .2s -webkit-transform .3s,transform .3s,opacity .3s
.a{transition: transform 1s, -webkit-transform 1s} (safari 6) -webkit-transform 1s,transform 1s,-webkit-transform 1s transform 1s,-webkit-transform 1s -webkit-transform 1s,transform 1s
.a{font-weight:bold}.a{transition: mask-border 1s} -webkit-mask-box-image twice -webkit-mask-box-image 1s,mask-border 1s -webkit-mask-box-image twice

When the transition handler expands a property in a transition list to
the vendor prefixes the targets need, it now leaves out the prefixes that
other entries of the same property in that declaration already carry, and
it no longer inserts the -webkit-mask-box-image* counterpart of a
mask-border* entry when the declaration already contains it.

Without this, running the handler over its own output (a merged
same-selector rule, a merged @media body, or the :lang() rules generated
for a logical property) appended another copy of every prefixed entry per
pass, and already-autoprefixed input such as
"transition: -webkit-transform .3s, transform .3s" got a duplicate
-webkit-transform entry in a single pass.
@coderabbitai

coderabbitai Bot commented Aug 14, 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
Commits

Reviewing files that changed from the base of the PR and between f345c0f and 34d3eee.

Files selected for processing: 1
  • test/js/bun/css/css.test.ts

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 2ef7de1 and f345c0f.

Files selected for processing: 3
  • src/css/properties/properties_generated.rs
  • src/css/properties/transition.rs
  • test/js/bun/css/css.test.ts
Files with no reviewable changes: 1
  • src/css/properties/properties_generated.rs

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 property lookup now resolves WebKit-prefixed mask-composite through the prefixed mask-composite entry. Transition expansion preserves explicitly written prefixes and adds required WebKit mask aliases only when absent. Tests cover prefix expansion, mask-property parsing and minification, composition warnings, and rebuilt output.

Changes

CSS prefix handling

Layer / File(s) Summary
Property lookup and transition expansion
src/css/properties/properties_generated.rs, src/css/properties/transition.rs
The lookup maps WebKit-prefixed mask-composite to its distinct ID. Transition expansion skips prefixes already listed for the same property and adds the WebKit mask alias only when the target requires it and it is absent.
Transition prefix tests
test/js/bun/css/css.test.ts
Tests cover prefix expansion for existing transform entries and WebKit mask properties, including duplicate handling, target thresholds, merged rules, logical-property rules, long lists, and rebuilt output.
Mask property integration tests
test/js/bun/css/css.test.ts, test/bundler/css/css-modules.test.ts
Tests cover WebKit mask-composite parsing and serialization, container style queries, and composition warnings.

Suggested reviewers: jarred-sumner

Priority: Normal

Merge Risk: Minimal · up to f345c

No actionable issue remains from the supplied changes; the PR is mergeable after normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: preventing duplicate transition entries when the declaration already lists the corresponding prefixed property.
Description check Passed The description is detailed and covers the problem, fix, scope, limitations, and verification results. It does not use the template headings exactly, but it provides the required information, includin…
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 is detailed and covers the problem, fix, scope, limitations, and verification results. It does not use the template headings exactly, but it provides the required information, including how the code was tested.

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

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on Bun 1.4.3 and on main with plain bun build (default targets):

  • .a{color:red} .a{transition:mask-size .3s} prints transition: -webkit-mask-size .3s, -webkit-mask-size .3s, mask-size .3s;
  • .a{transition:-webkit-mask-composite 1s,mask-composite 2s} prints transition: -webkit-mask-composite 1s, -webkit-mask-composite 2s, mask-composite 1s;

Not taken. The consolidated CSS parser work in #47025 leaves this change out, so this PR is closed. The branch stays available.

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

LGTM — focused fixed-point fix in expand_properties so re-minifying a block (rule merge, :lang() emission, or already-prefixed input) no longer duplicates prefixed transition entries. The inline note below is pre-existing and strictly mitigated here, not introduced.

Extended reasoning...

Overview

Touches src/css/properties/transition.rs (expand_properties + two new local helpers, ~40 net lines) and adds 13 prefix_test cases to test/js/bun/css/css.test.ts. The behavioral change: before expanding prefixes, snapshot the list as written; after set_prefixes_for_targets, drop only the newly added prefix bits that another entry of the same .tag() already carries; and skip the -webkit-mask-box-image* insert when the snapshot already contains it. The two helpers also replace the previously duplicated ltr/rtl blocks.

Security risks

None. Pure CSS-minifier output shaping — no I/O, no unsafe, no external input beyond parsed CSS that was already handled identically before this change. Worst-case failure mode is suboptimal (but still valid) CSS output.

Level of scrutiny

Moderate. The prefix-bit arithmetic is subtle, so I traced it: written bits are never in added, so an entry can never lose a bit it started with (the result is never empty and author order/positions are preserved — transform 1s, transform 2s still expands both, pinned by a test). as_written is used only for containment/tag-match, not indexed access, so index shifts from earlier logical-property replace() calls don't affect it. The rtl branch reuses the ltr as_written, which is sound because rtl_properties is cloned from properties and the two lists stay index-aligned (all Inline ltr/rtl replacement slices are equal-length). PropertyId::tag() returns the prefix-less discriminant, so Transform(WEBKIT) and Transform(NONE) compare equal for the fold — verified in properties_generated.rs.

Other factors

  • The one finding is explicitly pre-existing (the properties.insert without growing durations/delays/timing_functions); this PR reduces how often that path fires and does not make it worse.
  • The deliberate divergence from upstream merge_properties() is well-argued: upstream's approach shifts durations onto the wrong entries (opacity .2s → .3s in the comparison table), which Bun avoids today and this change preserves.
  • Test coverage spans the variant matrix the PR description enumerates: explicit prefix before/after unprefixed, distinct durations, transition-property, repeated unprefixed, merged same-selector, three merged @media, :lang() rules, and the four mask-border cases.

Comment thread src/css/properties/transition.rs Outdated
Comment on lines +551 to +557
let targets = &context.targets;
expand_prefixes(properties.r#mut(index), &as_written, targets);
if let Some(property_id) =
masking::get_webkit_mask_property(properties.at(index))
webkit_mask_property_to_insert(properties.at(index), &as_written, targets)
{
if context
.targets
.prefixes(VendorPrefix::NONE, Feature::MaskBorder)
.contains(VendorPrefix::WEBKIT)
{
properties.insert(index, property_id);
i += 1;
}
properties.insert(index, property_id);
i += 1;

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 introduced by this PR, and strictly mitigated by it): when the -webkit-mask-box-image* entry is inserted here, durations/delays/timing_functions in flush are not grown to match, so get_transitions cycles and shifts every subsequent entry — safari 15 + transition: mask-border .5s, opacity .1s comes out as -webkit-mask-box-image .5s, mask-border .1s, opacity .5s. The LogicalPropertyId::Block/Inline replace() arms (e.g. inset-block → two entries) have the same shape. Noting only because the refactored block is where a fix would land and the new tests all use single-entry or equal-duration lists that hide the cycling; not blocking.

Extended reasoning...

What the bug is

expand_properties receives only the properties list (flush() calls it as expand_properties(&mut p.0, arena, context)) and grows it in place via properties.insert(index, property_id) when a mask-border* entry needs a -webkit-mask-box-image* counterpart. The durations, delays, and timing_functions lists that flush holds alongside it are never touched, so after the insert the property list is one entry longer than the value lists it is paired with.

get_transitions then walks properties.slice() and pairs each entry with durations.at(durations_idx), bumping the index once per property via cycle_bump(&mut durations_idx, durations.len()). When properties.len() > durations.len(), that cycling shifts every entry after the insertion point onto its neighbor's value and wraps the last entry back to the first slot.

Step-by-step repro

Input (targets: safari 15, which needs the -webkit-mask-box-image insert):

.foo { transition: mask-border .5s, opacity .1s }
  1. handle_property splits the shorthand into properties = [MaskBorder, Opacity], durations = [.5s, .1s] (delays/timing similarly length-2).
  2. flush calls expand_properties. webkit_mask_property_to_insert returns WebKitMaskBoxImage (as_written does not contain it), so properties.insert(0, WebKitMaskBoxImage) runs. Now properties = [WebKitMaskBoxImage, MaskBorder, Opacity] — length 3.
  3. durations is still [.5s, .1s] — length 2; flush never grew it.
  4. get_transitions iterates:
    • WebKitMaskBoxImage → durations[0] = .5s, bump → idx 1
    • MaskBorder → durations[1] = .1s, bump → idx 0 (cycle)
    • Opacity → durations[0] = .5s
  5. Output: transition: -webkit-mask-box-image .5s, mask-border .1s, opacity .5s — mask-border lost its .5s and opacity was rewritten from .1s to .5s.

The prefix-expansion path (expand_prefixes) is not affected: a multi-bit VendorPrefix stays a single properties[] entry and get_transitions fans it out with one durations_idx increment. Only the mask insert (and the LogicalPropertyId::Block/Inline arms whose replace() calls insert_slice for multi-entry expansions like InsetBlock → [Top, Bottom]) add real list entries.

Why this is pre-existing and why the new tests don't catch it

The removed code did the identical properties.insert(index, property_id) without growing the value lists; this PR only wraps the condition in webkit_mask_property_to_insert and adds the as_written dedup. So this is not a regression — and the PR strictly reduces how often it fires, since the second-pass insert (merged rules, :lang() rules) is now skipped when the entry is already listed. The upstream lightningcss code the file was ported from has the same behavior.

The 13 new prefix_tests all use either a single mask-border entry (durations.len() == 1, so cycling is a no-op) or equal durations across every entry (.2s, .2s), both of which are invariant under the shift. A two-entry list with distinct durations, e.g. transition: mask-border .5s, opacity .1s at safari 15, would exercise it.

What a fix would look like

The value lists need the entry duplicated at the same index the property was inserted at (so the inserted -webkit-mask-box-image reuses mask-border's duration and everything after it stays paired). That means either passing the value lists into expand_properties and inserting alongside the property, or having expand_properties return the insertion indices for flush to apply to all four lists. The same treatment would cover the multi-entry Block/Inline replace() expansions. Not asking for it here — it's a separate change from the fixed-point fix this PR makes, and it exists upstream too — but flagging it since the PR description's "no entry is removed or reordered, so each entry keeps its own duration" holds for expand_prefixes but not for this insert.

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.

The description now states this limit under Downsides. The insert still changes the property list only, so transition: mask-border .5s, opacity .1s prints -webkit-mask-box-image .5s, mask-border .1s, opacity .5s, as on main.

This PR does not change the insert. It stops the shift only for a list that already has the -webkit- entry, because nothing is inserted then. 5ea1d73 adds cases with different durations for that path, for example -webkit-mask-composite 1s, mask-composite 2s, opacity 3s.

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.

One more case for this thread, found in the review of f345c0f. When a list names one -webkit- mask entry and another entry still inserts one, the later durations shift by a different count than on main. -webkit-mask-box-image 2s, mask-mode 1s, mask-border 1s ends in mask-border 2s with this PR and in mask-border 1s on main.

The description lists it under "What still prints wrong". This PR does not change the pairing. That fix needs the expansion to keep the source entry of each name that it inserts.

Jarred-Sumner pushed a commit that referenced this pull request Sep 21, 2026
### Problem
- 49 hand-written trait impls across 12 crates have no user: no call,
and no bound that needs them.
- rustc's `dead_code` lint exempts trait impls, so the workspace's deny
lints never report them.

### Fix
- Delete the 49 impls and `hash_array_list` (`src/css/generics.rs`),
whose only caller was a removed impl. 25 files, 436 lines removed.
- Candidates come from the linker: in a relink of the debug build with
`--gc-sections`, an impl block with no kept function address is a
candidate. The compiler decides next: an impl that a bound, a
supertrait, another target, or a unit test needs does not compile away,
so it stays. That kept 128 of 233 candidates.
- 56 more stayed by choice: open PRs use them, docs describe them, or
they are std ergonomics impls on shared containers. See the Notes.
- Verified: `bun run rust:check-all` (12 targets), `cargo check
--release` (3 targets), `cargo check --tests`, `cargo clippy`, `bun bd`,
11 test files, and a scan of all open PRs for users.

### Background
- To rustc a trait impl is always reachable: generic code could call it.
Only a whole-program view shows that nothing does.
- The debug build uses opt-level 0 and one section per function, so
`--gc-sections` drops every function with no caller. Callers in generic
instantiations and macro-expanded code count.
- That link proves linux only. `cargo check` covers the other targets.
- #39795 removed 92 impls this way.

<details><summary>Notes</summary>

**Removed impls, by file**

- `src/ast/e.rs`
  - `impl From<f64> for Number`
- `src/bun_alloc/baby_vec.rs`
  - `impl<'a, 'b, T: Copy> Extend<&'b T> for BabyVec<'a, T>`
  - `impl<'a, 'b, T> IntoIterator for &'b BabyVec<'a, T>`
  - `impl<'a, 'b, T> IntoIterator for &'b mut BabyVec<'a, T>`
  - `impl<'a, T> core::borrow::Borrow<[T]> for BabyVec<'a, T>`
  - `impl<'a, T> AsRef<[T]> for BabyVec<'a, T>`
- `src/bun_alloc/lib.rs`
  - `impl<const N: usize> BSSAppendable for [&[u8]; N]`
- `src/bun_core/atomic_cell.rs`
  - `unsafe impl<U> Atom for *const U`
- `src/bun_core/fmt.rs`
  - `impl From<InvalidCharacter> for crate::CrateError`
- `src/bun_core/output.rs`
  - `impl<T: fmt::Display> FmtTuple for &[T]`
- `src/bundler/Chunk.rs`
  - `impl core::ops::Index<usize> for CompileResultSlots`
- `src/bundler/bundle_v2.rs`
  - `impl Ord for StableRef`
  - `impl PartialOrd for StableRef`
- `src/collections/array_hash_map.rs`
- `impl<K, V, C, A: MapAllocator> ArrayHashMapExt for ArrayHashMap<K, V,
C, A>`
- `src/css/generics.rs`
  - `impl<'bump, T: DeepClone<'bump>> DeepClone<'bump> for &'bump T`
  - `impl<T: CssEql, const N: usize> CssEql for [T; N]`
  - `impl CssHash for ()`
  - `impl<T: CssHash, const N: usize> CssHash for [T; N]`
  - `impl<'bump, T: CssHash> CssHash for ArrayList<'bump, T>`
  - `impl CssHash for bun_ast::Loc`
  - `impl<T: IsCompatible, const N: usize> IsCompatible for [T; N]`
  - `impl<'bump, T: ToCss> ToCss for ArrayList<'bump, T>`
  - `impl ToCss for CustomIdent`
  - `impl ToCss for DashedIdent`
  - `impl ToCss for Ident`
- `src/css/media_query.rs`
  - `impl crate::generic::ToCss for MediaList`
- `src/css/properties/mod.rs`
- `impl crate::generics::ParseWithOptions for
css_values::length::Length`
- `impl<S, const P: u8> crate::generics::Parse for GenericBorder<S, P>`
- `src/css/rules/supports.rs`
  - `impl crate::generics::CssEql for SupportsCondition`
  - `impl css::generic::ToCss for SupportsCondition`
- `src/css/selectors/parser.rs`
  - `impl<Impl: SelectorImpl> Default for GenericSelector<Impl>`
  - `impl<Impl: BunSelectorImpl> CssEql for GenericSelectorList<Impl>`
  - `impl<Impl: BunSelectorImpl> CssHash for GenericSelectorList<Impl>`
  - `impl<Impl: BunSelectorImpl> CssEql for GenericComponent<Impl>`
  - `impl<Impl: BunSelectorImpl> CssHash for GenericComponent<Impl>`
- `src/css/values/alpha.rs`
  - `impl crate::generics::CssHash for AlphaValue`
- `src/install/lockfile/lockfile_json_stringify_for_debugging.rs`
  - `impl<const N: usize> JsonScalar for &[u8; N]`
- `src/install/resolution.rs`
  - `impl Default for Tag`
- `src/install_types/resolver_hooks.rs`
  - `impl<I: VersionInt> Default for ResolutionValue<I>`
  - `impl Default for Resolution`
- `src/jsc/JSPropertyIterator.rs`
  - `impl IntoIterObject for *const JSObject`
- `src/jsc/JSValue.rs`
  - `impl FromAny for ()`
  - `impl FromAny for &str`
  - `impl<T: FromAny> FromAny for Option<T>`
- `src/jsc/host_fn.rs`
  - `impl<T> IntoHostConstructReturn for *mut T`
- `src/react_compiler/hir/mod.rs`
  - `impl From<FloatValue> for f64`
- `src/runtime/bake/DevServer.rs`
  - `impl From<OpaqueFileId> for OpaqueFileIdOrOptional`
- `src/runtime/error.rs`
  - `impl From<Error> for bun_jsc::CrateError`
- `src/tcc_sys/tcc.rs`
  - `impl<ErrCtx> Default for Config<ErrCtx>`

**Candidates that stayed (184 of 233)**
- 128: the workspace does not compile without the impl (a bound, a
supertrait, another target, or a unit test needs it).
- 8: open PRs use them. The six `SmallList` impls (`Deref`, `DerefMut`,
`IntoIterator` x2, `FromIterator`, `Extend`) for #39497, #38692 and
#36605. `Default for Wyhash` for #40372. `AsRef<[u8]> for Utf8Bytes` for
#40708. This branch merged with #39497, #38692 and #40708 passes `cargo
check --workspace`. #36605 and #40372 conflict with main on their own.
- 19: std ergonomics impls on widely shared types. This is a design
call, so it needs a maintainer decision and its own PR: `Interned`
(`Deref`, `AsRef<[u8]>`, `Borrow<[u8]>`), `ZStr` (`AsRef<ZStr>`,
`PartialEq<[u8]>`, `PartialEq<&[u8; N]>`), `StoreStr`
(`PartialEq<[u8]>`), `StoreSlice` (`AsRef<[T]>`, `From<&[T]>`,
`From<ArenaVec>`), `IntoIterator for &HashMap` and `&mut HashMap`,
`BoundedArrayAligned` (`Deref`, `DerefMut`, `fmt::Write`), `AsRef<[T]>
for RawSlice`, `Deref for ArenaString` (also the only caller of
`ArenaString::as_str`), `Display for GenericIndex`, `Display for
CowSliceZ`.
- 21: docs or comments describe the impl as part of a design.
`ArrayHashContext<Box<[u8], A>> for BoxedSliceContext<C>` (its doc
comment records it as the fix for lookups reached through `Deref`),
`Default for Ordinal` (the default is `INVALID`, not 0), `IntoLogWrite
for &mut W`, `ThrowFmtArgs for fmt::Arguments`, `PoolStorage for
UnwiredStorage`, `StringBuilder for semver_string::Builder`, `fmt::Write
for FmtAdapter`, `AsFd for &File`, and the css blanket impls that the
`css_derive` module docs and the `Parse` section of
`src/css/generics.rs` name (`Option<T>`, `SmallList`, `bool`, `str`,
`[u8]`, `[T]`, `Size2D`, `Rect`).
- 6: next to a hunk of an open dead-code PR (#40690, #41335, #43010,
#42278), left alone to avoid a conflict.
- 2: the removal leaves something else half-used. See the follow-up
list.

**Method details**
- Link: the debug link command from `build.ninja` plus
`-Wl,--gc-sections`. `-rdynamic`, `--dynamic-list` and the version
script stay, so the exported NAPI/V8/uv surface is still a root.
- Liveness: `llvm-nm` lists the 669,524 kept function symbols and
`llvm-symbolizer` maps each address to `file:line`. An impl block is a
candidate when no kept address falls inside it and a rust-analyzer SCIP
index shows that linux compiles every method in it.
- Limit of the finder: it reads impl blocks from source text. Impls that
a `macro_rules!` type list generates are not candidates, so unused arms
of such lists are not in this PR.
- Restore loop: delete all candidates, run `cargo check --workspace
--keep-going --message-format=json`, put back each impl that an error
names, repeat until clean. Targets in the loop: linux-gnu, windows-msvc,
darwin, freebsd, android, musl, then `--tests`.
- Method resolution: on linux the link proves that no call to a removed
impl remains, so no call can resolve differently. On other targets a
call that used a removed impl fails to compile.
- Open PRs: main has no merge queue, so a green PR that uses a removed
impl would break main when it merges. I fetched the heads of all 5474
open non-draft PRs, took the Rust lines each one adds against its merge
base, and searched them for the types and traits of the 49 impls. No PR
uses one.
- Base: main at 18fe86e. The liveness data is from cf71211. The
commits between them do not touch the 25 files, and every check above
ran again on 18fe86e.
- `cargo check --workspace --tests` reports one error,
`src/bundler/options.rs:2328` (`expected ContentHash, found integer`).
Clean main has the same error. This PR does not touch it.
- Three tests fail in the local debug build with and without this
change: `ffi.test.js` "FTL-compiled call site" (5 s timeout under ASAN),
and two `serve.test.ts` tests that depend on the container (root can
bind low ports, loopback check).

**Other scans in this run**
- Free functions, inherent methods, types, consts and statics: a name
scan, the linker scan, and a rust-analyzer zero-reference scan for
linux, windows and darwin. Every hit was platform code with a caller on
another target, test-only code, or a deletion that an open PR already
carries.
- Also clean or already claimed: `.rs` files outside every mod tree,
unused Cargo dependencies, extern declarations without a Rust caller,
commented-out code in C++/TS, unused `src/js` modules and internal
exports, linker-dropped C++ functions in `src/jsc/bindings`.

**Follow-up candidates, not in this diff**
- 25 hand-written `Debug` impls that nothing formats. They are a
debugging aid, so this PR keeps them.
- `Clone for JsPoster` has no caller, and it is the only reader of the
`JsPosterVTable::clone` slot. Removing both is a small refactor in
`src/event_loop` and `src/jsc/VmHandle.rs`.
- `Display for ErrorLocation` (`src/css/error.rs`) has no caller, and it
is the only reader of `ErrorLocation::filename`.
- The 19 std ergonomics impls above, if a maintainer wants shared
containers to go through inherent methods only.
- `Bun__napi_get_version` in `src/jsc/bindings/napi.cpp` has no caller.
- The class generator emits `<Type>Class__call` shims for classes that
set `call: true` and `noConstructor: true` (the seven `Expect*` matchers
in `jest.classes.ts`). Nothing references those shims.

</details>
…w of the name table

`PropertyId::from_string` strips the vendor prefix before it searches the
property name table. The row for `-webkit-mask-composite` had the full
name as its key, so the search never found it. The name became an unknown
property.

The `mask-composite` row now accepts the `-webkit-` prefix and returns
`PropertyId::WebKitMaskComposite` for it. The unreachable row is removed.

A transition list that already has a `-webkit-mask-composite` entry no
longer gets a second entry, and each entry keeps its own duration. A
`-webkit-mask-composite` declaration is now parsed: the name and the
keywords print in lower case, and a value outside of the keyword list
prints as written.
@robobun

robobun commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:25 PM PT - Oct 26th, 2026

✅ @robobun, your commit 34d3eee884d23f103652a21376e914f81122f598 passed in Build #133189! 🎉


🧪   To try this PR locally:

bunx bun-pr 38692

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

bun-38692 --bun

@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 parts new since the last review: the removed b"-webkit-mask-composite" row in properties_generated.rs was unreachable (from_string strips the vendor prefix via VendorPrefix::strip_from before the table lookup, and no other full-name prefixed rows remain), and -webkit-mask-composite values outside the WebKit keyword set still fall to Unparsed and print as written, which the new mask tests pin. In expand_properties, deduping the rtl list against the ltr as_written snapshot is sound because the _ arm only runs for entries that are identical in both lists (logical entries are replaced in the other arms), so the ltr and rtl outputs can only differ in the replaced physical properties.

Extended reasoning...

The change touches the CSS transition handler's prefix expansion in src/css/properties/transition.rs and one row of the hand-maintained property name table in src/css/properties/properties_generated.rs, plus tests; no security-sensitive surface. The findings-present note records what else was examined and ruled out since the prior review; the inline findings already signal a human should look.

Comment thread test/js/bun/css/css.test.ts Resolved
Comment on lines +8098 to +8106
// A rule with the same selector and an unknown property is not a duplicate of the rule.
minify_test(
".foo{-webkit-mask-composite:xor}.bar{color:red}.foo{-webkit-unknown:1}",
".foo{-webkit-mask-composite:xor}.bar{color:red}.foo{-webkit-unknown:1}",
);
minify_test(
".foo{-webkit-unknown:1}.bar{color:red}.foo{-webkit-mask-composite:xor}",
".foo{-webkit-unknown:1}.bar{color:red}.foo{-webkit-mask-composite:xor}",
);

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: users minifying two same-selector rules that each hold only custom or unknown properties with different names lose the earlier rule. PropertyId equality at properties_generated.rs:896 compares tag and prefix only, so every Custom id is equal; is_duplicate (style.rs:480) then lets rules/mod.rs:858 drop the earlier rule. This PR moves only -webkit-mask-composite out of that population, which is what these two new cases pin. Fix: make duplicate detection compare the custom/unknown property name as well, so --a vs --b and -webkit-x vs -webkit-y rules are never duplicates, while identical names still dedupe.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

Input such as :root{--a:1} .x{color:red} :root{--b:2} goes through CssRuleList::minify. src/css/rules/mod.rs:850-859 computes a StyleRuleKey from the selector hash plus property-id tags (src/css/rules/style.rs:44-47; both declarations have tag Custom), then calls is_duplicate. src/css/rules/style.rs:480 compares a.property_id() != b.property_id(), and src/css/properties/properties_generated.rs:893-897 defines PropertyId equality as self.tag() == other.tag() && self.prefix() == other.prefix(), so PropertyId::Custom("--a") == PropertyId::Custom("--b"). The earlier rule is set to CssRule::Ignored at rules/mod.rs:858 and --a disappears from the output. The base branch behaves the same; this diff does not touch is_duplicate or PartialEq, it only makes -webkit-mask-composite a non-Custom id, and the new tests at test/js/bun/css/css.test.ts:8098-8106 pin that one escape while every other unknown or --custom name pair still collapses.

Verification: pre-existing — triggered whenever two same-selector rules whose declarations are all custom/unknown properties with different names are not adjacent; the earlier rule is dropped. PartialEq for PropertyId (src/css/properties/properties_generated.rs:893-897) is self.tag() == other.tag() && self.prefix() == other.prefix(). The base branch fails identically by the same route.

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 PropertyId equality ignores the custom name, and that this PR does not change is_duplicate. The two cases here pin only that -webkit-mask-composite is no longer a Custom id. A name-aware duplicate check is a separate change.

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.

#33684 is open for the general case. It compares the names of custom and unknown properties in PropertyId equality, so is_duplicate no longer takes two such rules for the same rule.

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.

Related, and now in the description: until #33684 lands, that equality merges two rules in one more case with this PR.

.a{color:red} .a{transition:mask-size .3s,fill .3s} .b{color:red;transition:mask-size .3s,stroke .3s} merges .a and .b here, so .b loses stroke. On main the doubled -webkit-mask-size entry of .a keeps the two lists apart. .a{transition:fill .3s} .b{transition:stroke .3s} merges on main too.

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

I re-reviewed after the latest push and found no bugs; the fixed-point matrix and the Bun.build test now pin exact first-pass output, which closes the earlier nit. A human look is still worthwhile for one deliberate product choice rather than any defect: the mask-composite row change makes Bun parse -webkit-mask-composite as its own property (keywords lowercased, same-selector rules merged), which lightningcss does not do, so it changes output for existing stylesheets beyond the transition bug.

What was reviewed:

  • expand_prefixes: added is computed as new-minus-written, so an entry's own prefix is never stripped; Targets::prefixes returns an already-vendor-prefixed entry unchanged, so -webkit-transform passes through untouched. RTL list gets the same two helpers at the same index.
  • Whole bug class: set_prefixes_for_targets / get_webkit_mask_property have no other call sites in src/css, so there is no sibling handler left with the old shape.
  • Name table: from_string strips the prefix before from_name_and_prefix, confirming the removed b"-webkit-mask-composite" key was unreachable and the new make(pre) closure returns WebKitMaskComposite only for -webkit-; -moz-/-ms-/-o- still fall to Custom.
  • Tests: cssInternals.minifyTest(source, "", targets) matches the bindgen signature; assertions are toBe/toEqual on exact output.
Extended reasoning...

The change touches the CSS transition property handler (src/css/properties/transition.rs, two new helpers replacing inline prefix-expansion and -webkit- mask insertion) and one row of the hand-maintained property name table (src/css/properties/properties_generated.rs), plus ~550 lines of tests in test/js/bun/css/css.test.ts and one css-modules test. It touches no security-sensitive surface. The handler logic was traced by hand and is behaviour-preserving except for the intended dedupe, and the prior-run nit about non-exact assertions was addressed in commit 2ef7de1; no third-party CHANGES_REQUESTED or CODEOWNERS coverage applies. Defer rather than approve because the name-table row intentionally diverges from lightningcss by parsing -webkit-mask-composite declarations, a user-visible output change a maintainer should consciously accept.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

@robobun
robobun marked this pull request as draft October 26, 2026 16:34
expand_properties scanned a copy of the whole list for each entry that
gained a prefix, and again for each mask-border entry. One rule with a
long list took time quadratic in its length.

A table with one VendorPrefix per PropertyIdTag now answers both
questions. The loop fills it the first time an entry needs a prefix or a
-webkit- mask name, so a list that needs neither builds no table and
makes no copy.

The loop reads the expanded prefix set from Targets::prefixes and stores
it only when it differs, so PropertyId::set_prefixes_for_targets has no
caller left and goes away.
@robobun
robobun marked this pull request as ready for review October 26, 2026 17:34

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

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

Comment thread test/js/bun/css/css.test.ts Resolved

@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 note, I also checked two things in this push: the deleted literal -webkit-mask-composite row in from_name_and_prefix was unreachable (from_string strips the vendor prefix before the lookup, so that key could never match), so the new mask-composite row is the only way that name resolves and nothing is lost; and the ltr/rtl lists in expand_properties stay parallel, since the rtl clone is taken after earlier entries were already expanded and every later prefix write and -webkit- insert goes to both lists at the same index. Also confirmed Targets::prefixes returns an already-prefixed entry unchanged, so a listed -webkit-x never re-expands.

Extended reasoning...

The change rewrites the prefix/webkit-alias expansion loop in src/css/properties/transition.rs around a lazily built per-tag ListedPrefixes table, deletes the now-unused PropertyId::set_prefixes_for_targets, and re-routes -webkit-mask-composite through the mask-composite name row; it touches no security-sensitive surface. The one confirmed finding is a test nit (child stderr inherited rather than captured), and the remaining open question from the prior push is the long-list test's 60 s/90 s wall-clock bound, which the latest commit widened rather than shrank.

Still open from earlier reviews (3):

  • Unresolved: 3 minor or pre-existing.

Comment thread test/js/bun/css/css.test.ts Resolved

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

I re-reviewed the head after the last push and found no bugs; the stderr capture in the linear-time test resolves what I raised last time. Because this rewrites the transition prefix-expansion loop and makes -webkit-mask-composite a parsed property (user-visible output change), a maintainer look is still worthwhile.

What was reviewed:

  • The lazily built ListedPrefixes table versus replace() running before or after it: replaced logical ids contribute only the NONE bit, which can never intersect added (always NONE-free) or satisfy contains() for a -webkit- name, so build order is harmless.
  • targets.prefixes() returns a prefixed written unchanged, so only unprefixed entries expand and the expanded != written gate is equivalent to the old set_prefixes_for_targets writes on both lists; no remaining callers of the deleted helper.
  • The name-table change: from_string strips the vendor prefix before lookup, so the old b"-webkit-mask-composite" row was unreachable and the new row adds parsing rather than altering an existing path; tag() as usize is bounded by PROPERTY_BITSET_BITS.
Extended reasoning...

The change touches src/css/properties/transition.rs (rewritten vendor-prefix expansion loop with a per-property listed-prefix table), src/css/properties/properties_generated.rs (removed helper, mask-composite row now maps the -webkit- prefix to PropertyId::WebKitMaskComposite), and two test files adding ~75 cases. It touches no security-sensitive surface. Since my previous review only one test-only commit landed, which addressed my last nit; the two remaining open inline threads of mine are pre-existing, non-blocking notes the author replied to. Not approving because the loop rewrite relies on non-obvious invariants (lazy table, rtl-list parity) and the parsing change alters bundler output for existing stylesheets, which a CSS maintainer should weigh; no CODEOWNERS entry covers these paths and the hunt ran dry.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

@robobun

robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

#47022 also changes expand_properties. It records, for each name, the index of the entry as written, so that get_transitions gives a transition entry that becomes several names its own duration, delay and timing function (transition: inset 1s, opacity 2s printed top 1s, bottom 2s, left 1s, right 2s, opacity 1s).

It has no duplicate-name rule, so the two PRs do not overlap in behavior. They touch the same function. The one that merges second has a small conflict there: the sources parameter, and the record_names call after the match.

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

Not taken in #47025 (7942008), which consolidated the open CSS parser pull requests. It is right or harmless, but not worth its code, the bigger output or the behavior it changes.

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