diff --git a/src/css/properties/transition.rs b/src/css/properties/transition.rs index 48ff249cd9d7..dfbf06f6c5c7 100644 --- a/src/css/properties/transition.rs +++ b/src/css/properties/transition.rs @@ -365,9 +365,10 @@ mod transition_handler_body { let mut _timing_functions: Option<(SmallList, VendorPrefix)> = self.timing_functions.take(); + let mut sources: Option> = None; let mut rtl_properties: Option> = if let Some(p) = &mut _properties { - expand_properties(&mut p.0, arena, context) + expand_properties(&mut p.0, &mut sources, arena, context) } else { None }; @@ -389,13 +390,20 @@ mod transition_handler_body { let intersection = *property_prefixes & *duration_prefixes & *delay_prefixes & *timing_prefixes; if !intersection.is_empty() { - let transitions = - get_transitions(arena, properties, durations, delays, timing_functions); + let transitions = get_transitions( + arena, + properties, + sources.as_deref(), + durations, + delays, + timing_functions, + ); if let Some(rtl_properties2) = &mut rtl_properties { let rtl_transitions = get_transitions( arena, rtl_properties2, + sources.as_deref(), durations, delays, timing_functions, @@ -468,40 +476,35 @@ mod transition_handler_body { fn get_transitions( arena: &bun_alloc::Arena, properties: &mut SmallList, + sources: Option<&[u32]>, durations: &mut SmallList, delays: &mut SmallList, timing_functions: &mut SmallList, ) -> SmallList { - #[inline] - fn cycle_bump(idx: &mut u32, len: u32) { - *idx = (*idx + 1) % len; - } - // transition-property determines the number of transitions. The values of other // properties are repeated to match this length. let mut transitions = SmallList::::init_capacity(1); - let mut durations_idx: u32 = 0; - let mut delays_idx: u32 = 0; - let mut timing_idx: u32 = 0; - for property_id in properties.slice() { - let duration = if durations.len() > durations_idx { - durations.at(durations_idx).deep_clone(arena) + debug_assert!(sources.is_none_or(|sources| sources.len() == properties.slice().len())); + for (index, property_id) in (0u32..).zip(properties.slice()) { + // Each name takes the values of its entry as written. lightningcss matches by position, which shifts them. + let source = sources.map_or(index, |sources| sources[index as usize]); + let duration = if !durations.is_empty() { + durations.at(source % durations.len()).deep_clone(arena) } else { Time::Seconds(0.0) }; - let delay = if delays.len() > delays_idx { - delays.at(delays_idx).deep_clone(arena) + let delay = if !delays.is_empty() { + delays.at(source % delays.len()).deep_clone(arena) } else { Time::Seconds(0.0) }; - let timing_function = if timing_functions.len() > timing_idx { - timing_functions.at(timing_idx).deep_clone(arena) + let timing_function = if !timing_functions.is_empty() { + timing_functions + .at(source % timing_functions.len()) + .deep_clone(arena) } else { EasingFunction::Ease }; - cycle_bump(&mut durations_idx, durations.len()); - cycle_bump(&mut delays_idx, delays.len()); - cycle_bump(&mut timing_idx, timing_functions.len()); let transition = Transition { property: property_id.deep_clone(arena), duration, @@ -525,6 +528,7 @@ mod transition_handler_body { fn expand_properties( properties: &mut SmallList, + sources: &mut Option>, arena: &bun_alloc::Arena, context: &mut PropertyHandlerContext, ) -> Option> { @@ -541,11 +545,24 @@ mod transition_handler_body { } } + // Once an entry becomes several names, each name keeps the index of its entry as written. + #[cold] + fn record_names(sources: &mut Option>, source: u32, names: u32, capacity: u32) { + let sources = sources.get_or_insert_with(|| { + let mut sources = Vec::with_capacity(capacity as usize); + sources.extend(0..source); + sources + }); + sources.resize(sources.len() + names as usize, source); + } + let mut rtl_properties: Option> = None; let mut i: u32 = 0; + let mut source: u32 = 0; // Expand logical properties in place. while i < properties.len() { + let start = i; let result = get_logical_properties(properties.at(i)); match result { LogicalPropertyId::Block(feature, block) @@ -555,7 +572,7 @@ mod transition_handler_body { if let Some(rtl) = &mut rtl_properties { replace(arena, rtl, block, i); } - i += 1; + i += u32::try_from(block.len()).expect("int cast"); } LogicalPropertyId::Inline(feature, ltr, rtl) if context.should_compile_logical(feature) => @@ -611,6 +628,12 @@ mod transition_handler_body { i += 1; } } + + let names = i - start; + if names > 1 || sources.is_some() { + record_names(sources, source, names, properties.len()); + } + source += 1; } rtl_properties diff --git a/test/bundler/css/mask-geometry-box.test.ts b/test/bundler/css/mask-geometry-box.test.ts index ea5d70a8fca7..14ae96d28bda 100644 --- a/test/bundler/css/mask-geometry-box.test.ts +++ b/test/bundler/css/mask-geometry-box.test.ts @@ -41,4 +41,29 @@ describe("css", () => { expect(output).toContain("padding-box"); }, }); + + // The default browser targets add `-webkit-mask-box-image` in front of `mask-border`. Both keep the 2s. + for (const backend of ["cli", "api"] as const) { + itBundled(`css/transition-mask-border-keeps-its-duration-${backend}`, { + backend, + files: { + "index.css": /* css */ ` + .a { + transition: mask-border 2s, opacity 3s; + } + `, + }, + outdir: "/out", + entryPoints: ["/index.css"], + onAfterBundle(api) { + api.expectFile("/out/index.css").toMatchInlineSnapshot(` + "/* index.css */ + .a { + transition: -webkit-mask-box-image 2s, mask-border 2s, opacity 3s; + } + " + `); + }, + }); + } }); diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 890ffa4dcd95..b2c18ed62520 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -7159,6 +7159,153 @@ describe("css tests", () => { chrome: Some(95 << 16), }, ); + + // One entry of a `transition` can become several names: a logical shorthand becomes its physical longhands, + // and `mask-border` gets `-webkit-mask-box-image`. Each name keeps the values of the entry it came from. + describe("an entry that becomes several names keeps its duration, delay and timing function", () => { + const safari = (version: number) => ({ safari: Some(version << 16) }); + const rtlLangs = "ae ar arc bcc bqi ckb dv fa glk he ku mzn nqo pnb ps sd ug ur yi" + .split(" ") + .map(lang => `:lang(${lang})`) + .join(", "); + // The rules that one rule with an inline logical property becomes for Safari 8. + const directionRules = (ltr: string, rtl: string) => ` + .foo:not(:-webkit-any(${rtlLangs})) { ${ltr} } + .foo:not(:is(${rtlLangs})) { ${ltr} } + .foo:-webkit-any(${rtlLangs}) { ${rtl} } + .foo:is(${rtlLangs}) { ${rtl} } + `; + + prefix_test( + `.foo { transition: mask-border 1s, opacity 2s }`, + `.foo { transition: -webkit-mask-box-image 1s, mask-border 1s, opacity 2s; }`, + safari(15), + ); + prefix_test( + `.foo { transition: inset 1s, opacity 2s }`, + `.foo { transition: top 1s, bottom 1s, left 1s, right 1s, opacity 2s; }`, + safari(8), + ); + prefix_test( + `.foo { transition: margin-block 1s, opacity 2s, color 3s }`, + `.foo { transition: margin-top 1s, margin-bottom 1s, opacity 2s, color 3s; }`, + safari(8), + ); + prefix_test( + `.foo { transition: border-block-color 1s, border-inline-width 2s, opacity 3s }`, + `.foo { transition: border-top-color 1s, border-bottom-color 1s, border-left-width 2s, border-right-width 2s, opacity 3s; }`, + safari(8), + ); + prefix_test( + `.foo { transition: mask-composite 1s, mask-mode 2s, opacity 3s }`, + `.foo { transition: -webkit-mask-composite 1s, mask-composite 1s, -webkit-mask-source-type 2s, mask-mode 2s, opacity 3s; }`, + safari(15), + ); + prefix_test( + `.foo { transition: inset 1s linear 3s, opacity 2s ease-in 4s }`, + `.foo { transition: top 1s linear 3s, bottom 1s linear 3s, left 1s linear 3s, right 1s linear 3s, opacity 2s ease-in 4s; }`, + safari(8), + ); + // A shorter list repeats over the entries as written, and a value past the last entry is not used. + prefix_test( + `.foo { transition: inset 1s, opacity 2s, color 3s; transition-duration: 4s, 5s }`, + `.foo { transition: top 4s, bottom 4s, left 4s, right 4s, opacity 5s, color 4s; }`, + safari(8), + ); + prefix_test( + `.foo { transition: inset 1s; transition-duration: 4s, 5s }`, + `.foo { transition: top 4s, bottom 4s, left 4s, right 4s; }`, + safari(8), + ); + // daisyUI ships this shape in its megamenu, with `linear()` as the first easing. + prefix_test( + `.foo { transition: inset .3s cubic-bezier(.2, .9, .3, 1.1), background-color .2s ease-out }`, + `.foo { + transition: top .3s cubic-bezier(.2, .9, .3, 1.1), bottom .3s cubic-bezier(.2, .9, .3, 1.1), left .3s cubic-bezier(.2, .9, .3, 1.1), right .3s cubic-bezier(.2, .9, .3, 1.1), background-color .2s ease-out; + }`, + safari(14), + ); + // The order as written stays, so the last entry for a property still decides. + prefix_test( + `.foo { transition: top 5s, inset 1s, left 7s }`, + `.foo { transition: top 5s, top 1s, bottom 1s, left 1s, right 1s, left 7s; }`, + safari(8), + ); + prefix_test( + `.foo { transition: all 1s, inset 2s }`, + `.foo { transition: all 1s, top 2s, bottom 2s, left 2s, right 2s; }`, + safari(8), + ); + prefix_test( + `.foo { transition: inset 1s, opacity 2s; transition-duration: 3s, 4s }`, + `.foo { transition: top 3s, bottom 3s, left 3s, right 3s, opacity 4s; }`, + safari(8), + ); + prefix_test( + `.foo { transition-duration: 3s, 4s; transition: inset 1s, opacity 2s }`, + `.foo { transition: top 1s, bottom 1s, left 1s, right 1s, opacity 2s; }`, + safari(8), + ); + // The handler writes what it holds when a prefixed value differs or a longhand is not parsed. + prefix_test( + `.foo { -webkit-transition: inset 1s, opacity 2s; transition: inset 3s, opacity 4s }`, + `.foo { + -webkit-transition: top 1s, bottom 1s, left 1s, right 1s, opacity 2s; + transition: top 3s, bottom 3s, left 3s, right 3s, opacity 4s; + }`, + safari(8), + ); + prefix_test( + `.foo { transition: inset 1s, opacity 2s; transition-timing-function: var(--ease) }`, + `.foo { + transition: top 1s, bottom 1s, left 1s, right 1s, opacity 2s; + transition-timing-function: var(--ease); + }`, + safari(8), + ); + prefix_test( + `.foo { transition: inset 1s, opacity 2s, transform 3s }`, + `.foo { + -webkit-transition: top 1s, bottom 1s, left 1s, right 1s, opacity 2s, -webkit-transform 3s, transform 3s; + transition: top 1s, bottom 1s, left 1s, right 1s, opacity 2s, -webkit-transform 3s, transform 3s; + }`, + safari(6), + ); + // Both direction rules, with the inline logical property in front of the entry and behind it. + prefix_test( + `.foo { transition: margin-inline-start 1s, inset 2s, opacity 3s }`, + directionRules( + `transition: margin-left 1s, top 2s, bottom 2s, left 2s, right 2s, opacity 3s;`, + `transition: margin-right 1s, top 2s, bottom 2s, left 2s, right 2s, opacity 3s;`, + ), + safari(8), + ); + prefix_test( + `.foo { transition: inset 1s, margin-inline-start 2s, opacity 3s }`, + directionRules( + `transition: top 1s, bottom 1s, left 1s, right 1s, margin-left 2s, opacity 3s;`, + `transition: top 1s, bottom 1s, left 1s, right 1s, margin-right 2s, opacity 3s;`, + ), + safari(8), + ); + // Prints `color 1s, opacity 1s`: the later property list meets one duration per name of the first list. + cssTestTodo( + `.foo { transition: inset 1s, opacity 2s } .foo { transition-property: color, opacity }`, + `.foo { transition: color 1s, opacity 2s; }`, + safari(8), + ); + + // Nothing becomes several names without targets, or for targets that need nothing. + minify_test( + ".foo { transition: inset 1s, mask-border 2s, transform 3s }", + ".foo{transition:inset 1s,mask-border 2s,transform 3s}", + ); + prefix_test( + `.foo { transition: inset 1s, mask-border 2s, transform 3s, opacity 4s }`, + `.foo { transition: inset 1s, mask-border 2s, transform 3s, opacity 4s; }`, + { chrome: Some(130 << 16) }, + ); + }); }); describe("animation", () => {