Conversation
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.
|
Warning Review limit reached
Next review available in: 14 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: reproduced and fixed, waiting on CI for 472e58c.
|
There was a problem hiding this comment.
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.insertwithout growingdurations/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→.3sin 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 fourmask-bordercases.
| 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; |
There was a problem hiding this comment.
🟣 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 }handle_propertysplits the shorthand intoproperties = [MaskBorder, Opacity],durations = [.5s, .1s](delays/timing similarly length-2).flushcallsexpand_properties.webkit_mask_property_to_insertreturnsWebKitMaskBoxImage(as_writtendoes not contain it), soproperties.insert(0, WebKitMaskBoxImage)runs. Nowproperties = [WebKitMaskBoxImage, MaskBorder, Opacity]— length 3.durationsis still[.5s, .1s]— length 2;flushnever grew it.get_transitionsiterates:WebKitMaskBoxImage→durations[0] = .5s, bump → idx 1MaskBorder→durations[1] = .1s, bump → idx 0 (cycle)Opacity→durations[0] = .5s
- Output:
transition: -webkit-mask-box-image .5s, mask-border .1s, opacity .5s—mask-borderlost its.5sandopacitywas rewritten from.1sto.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.
### 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>
Problem
bun build's defaultbrowsertarget, whose safari 14 entry still prefixes themask-*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(plainbun build a.css)@mediarules 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 .3stransition: margin-inline-start .2s, transform .2s(safari 8) duplicates-webkit-transforminside every generated:lang()rule, without any rule mergingtransition: -webkit-transform .3s, transform .3s, opacity .2s(safari 6) ->-webkit-transform .3s, -webkit-transform .3s, transform .3s, opacity .2s; same fortransition-property: -webkit-transform, transform-webkit-mask-box-imageentry inserted formask-border: a merged rule ortransition: -webkit-mask-box-image .2s, mask-border .2sends up with it twiceexpand_propertiesinsrc/css/properties/transition.rsexpands every unprefixed entry to the full prefix set for the targets (set_prefixes_for_targets) and inserts the mask-box-image counterpart of amask-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_mergeand theCssRule::Mediaarm insrc/css/rules/mod.rs) as well asadd_logical_ruleall run a block through the handlers more than once.Fix
expand_propertieskeeps 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.flushthose lists are paired with the property list by position), and author-written repeats such astransform 1s, transform 2sstill expand both entries, since the unprefixed bit is never something the expansion added.:lang()outputs now match lightningcss.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 turnstransition: -webkit-transform .3s, transform .3s, opacity .2sinto..., 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.test/js/bun/css/css.test.ts,transitionblock: 13 newprefix_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@mediarules, 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 buildon the stylesheet below produces the single-entry output with the fix.Background
DeclarationHandler); handlers such asTransitionHandlerbuffer the related declarations and write a minimal form infinalize/flush. WhenCssRuleList::minifymerges a rule into an earlier one with the same selector, or an@mediarule into the previous one with the same query, the merged block is minified again, and the:lang()rules a handler emits throughadd_logical_ruleare minified as new rules. So a handler sees its own output as input, and its output has to be stable under that.PropertyIdof a prefixable property carries aVendorPrefixbit set.Targets::prefixesreplaces a set that contains the unprefixed bit with the full set the browser targets need and returns an explicitly prefixed set unchanged; that is whatset_prefixes_for_targetsapplies to each list entry.flushthen turns a multi-bit entry of atransitionshorthand into one transition per bit (get_transitions), which is why the handler's own output comes back as separate single-prefix entries; atransition-propertylist prints a multi-bit entry as one name per bit.mask-borderand its longhands have no prefixed spelling; WebKit's equivalent is-webkit-mask-box-image, a different property, somasking::get_webkit_mask_propertyreturns a separatePropertyIdthat the handler inserts in front of the entry instead of setting a prefix bit.bun buildrepro (default targets)bun 1.4.0 / main:
with this change:
Comparison with lightningcss 1.30.2 (targets chrome 20, firefox 20, safari 5, ie 9, ios_saf 6, android 4 unless noted)
.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 1sopacity 1s,-webkit-transform 1s,-ms-transform 1s,transform 1s@media (x:1)rules, first withtransition: opacity 1s, transform 1s-webkit-transform 1s,-ms-transform 1s.a{transition: opacity 1s, -webkit-transform 1s, transform 1s}-webkit-transformtwiceopacity 1s,-webkit-transform 1s,-ms-transform 1s,transform 1s.a{transition-property: opacity, -webkit-transform, transform}-webkit-transformtwiceopacity,-webkit-transform,-ms-transform,transform.a{transition: -webkit-transform .3s, transform .3s, opacity .2s}(safari 6)-webkit-transformtwice,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 1stransform 1s,-webkit-transform 1s-webkit-transform 1s,transform 1s.a{font-weight:bold}.a{transition: mask-border 1s}-webkit-mask-box-imagetwice-webkit-mask-box-image 1s,mask-border 1s-webkit-mask-box-imagetwice