Repository navigation
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 reachedYou'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. View limit details
No actionable comments were generated in the recent review.
|
| 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:
Merge Risk: 
Minimal · up to f345c
-
No actionable issue remains from the supplied changes; the PR is mergeable after normal checks.
Pre-merge checks: 
4
-
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.
Comment @coderabbitai help to get the list of available commands.
|
Status Reproduced on Bun 1.4.3 and on
Not taken. The consolidated CSS parser work in #47025 leaves this change out, so this PR is closed. The branch stays available. |
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
### 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.
|
Updated 12:25 PM PT - Oct 26th, 2026
✅ @robobun, your commit 34d3eee884d23f103652a21376e914f81122f598 passed in 🧪 To try this PR locally: bunx bun-pr 38692That installs a local version of the PR into your bun-38692 --bun |
There was a problem hiding this comment.
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.
| // 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}", | ||
| ); |
There was a problem hiding this comment.
🟣 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
#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.
There was a problem hiding this comment.
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.
…he first minify output
There was a problem hiding this comment.
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:addedis computed as new-minus-written, so an entry's own prefix is never stripped;Targets::prefixesreturns an already-vendor-prefixed entry unchanged, so-webkit-transformpasses through untouched. RTL list gets the same two helpers at the same index.- Whole bug class:
set_prefixes_for_targets/get_webkit_mask_propertyhave no other call sites insrc/css, so there is no sibling handler left with the old shape. - Name table:
from_stringstrips the prefix beforefrom_name_and_prefix, confirming the removedb"-webkit-mask-composite"key was unreachable and the newmake(pre)closure returnsWebKitMaskCompositeonly for-webkit-;-moz-/-ms-/-o-still fall toCustom. - Tests:
cssInternals.minifyTest(source, "", targets)matches the bindgen signature; assertions aretoBe/toEqualon 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.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
ListedPrefixestable versusreplace()running before or after it: replaced logical ids contribute only the NONE bit, which can never intersectadded(always NONE-free) or satisfycontains()for a-webkit-name, so build order is harmless. targets.prefixes()returns a prefixedwrittenunchanged, so only unprefixed entries expand and theexpanded != writtengate is equivalent to the oldset_prefixes_for_targetswrites on both lists; no remaining callers of the deleted helper.- The name-table change:
from_stringstrips the vendor prefix before lookup, so the oldb"-webkit-mask-composite"row was unreachable and the new row adds parsing rather than altering an existing path;tag() as usizeis bounded byPROPERTY_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.
|
#47022 also changes 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 |
### 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.
Problem
bun buildprints wrongtransitionlists 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
-webkit-mask name only when the list lacks it. No entry is removed or reordered.ListedPrefixes(oneVendorPrefixperPropertyIdTag) answers both. The loop fills it once, when the first entry needs it.mask-compositename row now also returnsPropertyId::WebKitMaskCompositefor-webkit-.test/js/bun/css/css.test.ts,test/bundler/css/: 74 new cases, 54 fail onmain. Self-reviewed: 31 concerns raised, 27 addressed (Notes).Background
PropertyIdcarries aVendorPrefixbit set, whichTargets::prefixeswidens to what the targets need.mask-border,mask-compositeandmask-modehave WebKit properties with other names, inserted as entries of their own.merge_properties(wrong durations).Downsides
transition-property: -webkit-mask-size, mask-sizecosts 698 instructions per flush,main499.main, an inserted entry shifts later durations:mask-border 1s, opacity 2sprintsmask-border 2s, opacity 1s. Until css: compare custom property names inPropertyIdequality and importance in duplicate-rule detection #33684, rules that differ only in an unknown name merge in one more case.-webkit-mask-compositedeclaration is now parsed:XORprintsxor.Notes
Reproduce. Plain
bun build, default targets, no flags. Bun 1.4.3 andmainprint the same.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:mainbun buildof one rule with 64,000 xmask-size 1s: 62, 60, 57 ms (main83, 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 (main60 / 104 / 222 ms).Instructions executed in one
TransitionHandler::flush, from entry to return, without the allocator (gdbsingle step). The three numbers aremain/ head before / this head.opacity,color,width,toptransform,box-shadow,border-radius,flex(no prefix needed)block-size, then plain names, Safari 8mask-sizemask-border, then plain namestransition: -webkit-mask-size 1s, mask-size 1spairstransition-property: -webkit-mask-size, mask-sizepairs-webkit-name. A list that already has the-webkit-entry does build it, which is the last row.main. The head before had one moremallocandfreefrom the fifth entry.flush: 920 bytes (main648, head before 792). Text of the handler: 6,822 bytes (main5,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
@mediarules, 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 timebuildstransition-propertylists of 200,000 xmask-sizeand of 500,000 xmask-borderwith-webkit-mask-box-imagelisted 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.tshas 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:
prefix_testandminify_test(see "Not in this PR").merge_properties.The two changes need each other for
mask-composite.masking::get_webkit_mask_propertyhas 8 names that get a-webkit-entry with another name.mask-compositestill grows, because the written-webkit-mask-compositedid not resolve toPropertyId::WebKitMaskComposite, the id that the handler looks for.maininserts the entry without a look at the list.transition over %s is the same when minified againruns 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-compositecase.mask-compositeis 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).-webkit-mask-compositeticked under "Minify and downlevel properties". Before this PR the parser never reached that property.mask,mask-image,mask-position,mask-size,mask-repeat,mask-clip,mask-origin,mask-borderand its five longhands,mask-composite,mask-mode,background-clip,backface-visibility,text-emphasis-color.mainand 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 .1sprints-webkit-mask-box-image .5s, mask-border .1s, opacity .5s.mainand 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 ontransition.rsdescribes the defect.More cases, all with plain
bun build:-webkit-mask entry while another entry still inserts one shifts the later durations by a different count thanmain.-webkit-mask-box-image 2s, mask-mode 1s, mask-border 1sends inmask-border 2shere. Onmainit ends inmask-border 1s, after a second-webkit-mask-box-image 2s.transition-property: mask-size, opacity; transition-duration: .3s, .2sprints three names for two durations, as onmain.transition: inset 1s, opacity 2sprintstop 1s, bottom 2s, left 1s, right 2s, opacity 1s, as onmain.PropertyIdequality and importance in duplicate-rule detection #33684 is open for it)..a{transition:fill .3s} .b{transition:stroke .3s}merges onmaintoo..a{color:red} .a{transition:mask-size .3s,fill .3s} .b{color:red;transition:mask-size .3s,stroke .3s}merges here and not onmain, where the doubled entry of.akeeps the two lists apart.-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 onmain: 64,000 xmask-border 1stakes 459 ms (main461 ms), 64,000 xinset 1stakes 472 ms (main481 ms).What changes for a
-webkit-mask-compositedeclaration. The declaration parser uses the same name lookup in each block type (style rules, nested rules,@keyframes,@page, style attributes) and in@container style()..a{-webkit-mask-composite:XOR}XORxor.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}.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)@container style(-webkit-mask-composite: XOR)XORxor@import 'x.css' supports(-WEBKIT-MASK-COMPOSITE: XOR)-webkit-mask-composite:XORexclude,plus-lighter,var(--x),inherit,xor xor-moz-,-ms-,-o-mask-compositeclear,copy,source-over,source-in,source-out,source-atop,destination-over,destination-in,destination-out,destination-atop,xor. WebKit also hasplus-darkerandplus-lighter(CSSProperties.json,<-webkit-compositing-operator>). Firefox takes the name as an alias ofmask-composite(longhands.toml,extra_prefixes = ["webkit"]), soexcludeis valid there. Such values stay unparsed and print as written.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 inPropertyIdequality and importance in duplicate-rule detection #33684 does).composesconflict check now takes-WEBKIT-MASK-COMPOSITEand-webkit-mask-compositein two composed files as one property, as it does forCOLORandcolor.-webkit-mask-compositeandmask-compositestay two properties.-webkit-mask-compositepasses throughDeclarationBlock::minifyin its place.mask-composite: excludedoes not get a-webkit-mask-composite: xorline. That is as before.@carbon/styles1.118.0,styles.cssandstyles.min.css) build to the same bytes with and without the row change.lightningcss.
masterhas the same two rows (src/properties/mod.rs: the key"-webkit-mask-composite", andFrom<CowArcStr>strips the prefix beforefrom_name_and_prefix). So lightningcss also reads the name as a custom property and prints the declaration as written. Itsmerge_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
main449877f with the handler change. Release builds, same machine, one after the other.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_stringis 841 B in both..textis 82,166,153 B and.rodatais 22,598,604 B in both, because padding takes up the 42 B. The strippedbunis 107,730,656 B in both.llvm-objdump -d): 3 of the 225 instructions offrom_stringdiffer. 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: 4strncasecmp, 1sorted_key_position, 1 indirect call of the maker. There is no allocation and no syscall. Themask-compositemaker goes from 6 to 10 instructions (a bit test and a conditional move, no branch).qemu-x86_64 -one-insn-per-tb -d exec,nochain -dfilteronfrom_stringandsorted_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-color470 to 502,border-block-end-style264 to 296,border-block-end-width330 to 264,-webkit-mask-composite231 to 233. The threeborder-block-end-*names are in the same 22-byte group of the sorted table as the removed key.from_stringand the table): the 46,574 names in the 15 sheets oftest/js/bun/css/filesneed 43,594 searches and 133,817 compare steps with both tables.valgrindandperfare not installed on the build machine.bun buildprints the same bytes for those 15 sheets. None of them has the name.gdbbreakpoints, debug builds): the fiveWebKitMaskCompositearms inproperties_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.eqlruns when two rules have equal values.deep_cloneruns for.foo, .bar:focus-visiblewith Safari 13, which splits the rule.Not in this PR.
bun buildcannot select.transition:flex-grow 1sfor 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.allhas no row:transition:ALL 1skeeps its upper case.bun buildbuilds 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).-. The table has no such key after this change. I can add the check if a maintainer wants it.test/js/bun/css/util.tsfor all tests, so it belongs in its own PR.-webkit-mask-compositedeclaration. 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 changesfrom_string. It does not touch the two rows of this PR. Branchrobobun/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 incss.test.ts, 1 intest/bundler/css/css-modules.test.ts). On a build ofmain, 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.tsand the other files oftest/js/bun/css/pass.css-fuzz.test.tshas 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
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