diff --git a/.changepacks/changepack_log_portable_binding_state.json b/.changepacks/changepack_log_portable_binding_state.json new file mode 100644 index 000000000..ed1d8deaf --- /dev/null +++ b/.changepacks/changepack_log_portable_binding_state.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/components/package.json": "Patch" + }, + "note": "Canonicalize complete exported property records, use portable content keyframe identities and short debug keyframe counters, and advance atomNamingVersion to 3. No new build errors.", + "date": "2026-10-20T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/src/cache_protocol_tests.rs b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs index b3545e669..28cb89852 100644 --- a/bindings/devup-ui-wasm/src/cache_protocol_tests.rs +++ b/bindings/devup-ui-wasm/src/cache_protocol_tests.rs @@ -31,7 +31,7 @@ fn legacy_import_changes_cache_generation_when_exported( assert_ne!(exported, legacy_json); let json: serde_json::Value = serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); - assert_eq!(json["atomNamingVersion"], 2); + assert_eq!(json["atomNamingVersion"], 3); let restored: StyleSheet = serde_json::from_str(&exported).unwrap_or_else(|error| panic!("{error}")); assert_eq!(restored.atom_plan, None); diff --git a/bindings/devup-ui-wasm/src/reservation/tests.rs b/bindings/devup-ui-wasm/src/reservation/tests.rs index a0d7344f2..e01c3dfe4 100644 --- a/bindings/devup-ui-wasm/src/reservation/tests.rs +++ b/bindings/devup-ui-wasm/src/reservation/tests.rs @@ -390,7 +390,6 @@ fn debug_configuration_stays_unchanged_when_reserving() -> TestResult { // Given let (_reset, option) = setup(true); css::debug::set_debug(true); - let before = css::class_map::get_class_map(); // When reserve_class_slots_internal( &[input("z.tsx", B), input("a.tsx", A)], @@ -399,13 +398,16 @@ fn debug_configuration_stays_unchanged_when_reserving() -> TestResult { ); // Then assert!(css::debug::is_debug()); - assert_eq!(css::class_map::get_class_map(), before); + let reserved = css::class_map::get_class_map(); + assert_eq!(reserved[""].len(), 2); assert_eq!(get_unreserved_class_slot_count(), 0); assert!( transform(&input("a.tsx", A), &option)? .code .contains("color-0-red") ); + assert_eq!(css::class_map::get_class_map(), reserved); + assert_eq!(get_unreserved_class_slot_count(), 0); Ok(()) } diff --git a/libs/css/src/atom_counter_tests.rs b/libs/css/src/atom_counter_tests.rs index a48143732..03c92ca0b 100644 --- a/libs/css/src/atom_counter_tests.rs +++ b/libs/css/src/atom_counter_tests.rs @@ -2,6 +2,27 @@ use super::*; use serial_test::serial; use std::collections::{HashMap, HashSet}; +#[rstest::rstest] +#[case(None, ["du-a", "du-b", "du-a"])] +#[case(Some("motion.tsx"), ["du-a-a", "du-a-b", "du-a-a"])] +#[serial] +fn ordinary_debug_keyframes_use_the_same_short_counters( + #[case] filename: Option<&str>, + #[case] expected: [&str; 3], +) { + for debug in [false, true] { + // Given + let _state = test_state::TestStateGuard::new(); + set_prefix(Some("du-".into())); + debug::set_debug(debug); + // When + let names = + ["frames", "other", "frames"].map(|key| keyframes_to_keyframes_name(key, filename)); + // Then + assert_eq!(names, expected); + } +} + fn setup() { class_map::reset_class_map(); file_map::reset_file_map(); diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 88f5a8bf5..ffea52b13 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -461,34 +461,26 @@ pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> S ); } with_prefix(|prefix| { - if is_debug() { - let mut result = String::with_capacity(prefix.len() + 2 + keyframes.len()); + let filename_key = filename.unwrap_or_default(); + let class_num = class_num_for_key(filename_key, |key| { + key.reserve(2 + keyframes.len()); + key.push_str("k-"); + key.push_str(keyframes); + }); + if let Some(fname) = filename { + let file_num = num_to_nm_base(get_file_num_by_filename(fname)); + let mut result = + String::with_capacity(prefix.len() + file_num.len() + 1 + class_num.len()); result.push_str(prefix); - result.push_str("k-"); - result.push_str(keyframes); + result.push_str(&file_num); + result.push('-'); + result.push_str(&class_num); result } else { - let filename_key = filename.unwrap_or_default(); - let class_num = class_num_for_key(filename_key, |key| { - key.reserve(2 + keyframes.len()); - key.push_str("k-"); - key.push_str(keyframes); - }); - if let Some(fname) = filename { - let file_num = num_to_nm_base(get_file_num_by_filename(fname)); - let mut result = - String::with_capacity(prefix.len() + file_num.len() + 1 + class_num.len()); - result.push_str(prefix); - result.push_str(&file_num); - result.push('-'); - result.push_str(&class_num); - result - } else { - let mut result = String::with_capacity(prefix.len() + class_num.len()); - result.push_str(prefix); - result.push_str(&class_num); - result - } + let mut result = String::with_capacity(prefix.len() + class_num.len()); + result.push_str(prefix); + result.push_str(&class_num); + result } }) } @@ -1330,8 +1322,8 @@ mod tests { ); reset_class_map(); set_debug(true); - assert_eq!(keyframes_to_keyframes_name("spin", None), "k-spin"); - assert_eq!(keyframes_to_keyframes_name("spin1", None), "k-spin1"); + assert_eq!(keyframes_to_keyframes_name("spin", None), "a"); + assert_eq!(keyframes_to_keyframes_name("spin1", None), "b"); } #[test] diff --git a/libs/extractor/src/extract_style/extract_keyframes.rs b/libs/extractor/src/extract_style/extract_keyframes.rs index ad385940e..75dd7662b 100644 --- a/libs/extractor/src/extract_style/extract_keyframes.rs +++ b/libs/extractor/src/extract_style/extract_keyframes.rs @@ -1,7 +1,4 @@ -use std::{ - collections::BTreeMap, - hash::{DefaultHasher, Hash, Hasher}, -}; +use std::collections::BTreeMap; use css::keyframes_to_keyframes_name; @@ -14,71 +11,22 @@ pub struct ExtractKeyframes { pub keyframes: BTreeMap>, } -/// Hashes the original map/vector/seven-field stream without global declaration order. -struct KeyframeIdentity<'a>(&'a BTreeMap>); - -impl Hash for KeyframeIdentity<'_> { - fn hash(&self, state: &mut H) { - self.0.len().hash(state); - for (key, styles) in self.0 { - key.hash(state); - styles.len().hash(state); - for style in styles { - style.property.hash(state); - style.value.hash(state); - style.level.hash(state); - style.selector.hash(state); - style.style_order.hash(state); - style.layer.hash(state); - style.theme_token_resolution.hash(state); - } - } - } -} - impl ExtractStyleProperty for ExtractKeyframes { fn extract(&self, filename: Option<&str>) -> StyleProperty { - if css::atom_hoist::is_atom_hoist() { - let mut content = String::new(); - for (step, styles) in &self.keyframes { - content.push_str(&css::atom_name::hex(step)); - content.push('{'); - for style in styles { - content.push_str(&css::atom_name::hex(style.property())); - content.push(':'); - content.push_str(&css::atom_name::hex(style.value())); - content.push(';'); - } - content.push('}'); + let mut content = String::new(); + for (step, styles) in &self.keyframes { + content.push_str(&css::atom_name::hex(step)); + content.push('{'); + for style in styles { + content.push_str(&css::atom_name::hex(style.property())); + content.push(':'); + content.push_str(&css::atom_name::hex(style.value())); + content.push(';'); } - return StyleProperty::ClassName(keyframes_to_keyframes_name(&content, filename)); - } - let mut hasher = DefaultHasher::new(); - KeyframeIdentity(&self.keyframes).hash(&mut hasher); - // Format the u64 hash into a stack buffer instead of a throwaway heap - // `String`; `keyframes_to_keyframes_name` only reads it as `&str` and - // copies it into its own key, so the owned allocation was pure waste. - let mut buf = [0u8; 20]; - let hash_key = write_u64(&mut buf, hasher.finish()); - StyleProperty::ClassName(keyframes_to_keyframes_name(hash_key, filename)) - } -} - -/// Writes `value`'s decimal digits into the tail of `buf` and returns the -/// written slice as `&str`. A `u64` is at most 20 decimal digits, so `buf` -/// never overflows. -fn write_u64(buf: &mut [u8; 20], mut value: u64) -> &str { - let mut pos = buf.len(); - loop { - pos -= 1; - buf[pos] = b'0' + (value % 10) as u8; - value /= 10; - if value == 0 { - break; + content.push('}'); } + StyleProperty::ClassName(keyframes_to_keyframes_name(&content, filename)) } - // Only ASCII digits were written, so this slice is valid UTF-8. - str::from_utf8(&buf[pos..]).unwrap_or("0") } #[cfg(test)] @@ -89,107 +37,72 @@ mod tests { use rstest::rstest; use serial_test::serial; - /// Main's pre-order layout, independently deriving the original hash contract. - #[derive(Hash)] - struct MainStaticStyle { - property: String, - value: String, - level: u8, - selector: Option, - style_order: Option, - layer: Option, - theme_token_resolution: ThemeTokenResolution, - } - - fn main_keyframes() -> BTreeMap> { - BTreeMap::from([ - ( - "from".to_string(), - vec![ - MainStaticStyle { - property: "opacity".to_string(), - value: "0".to_string(), - level: 0, - selector: None, - style_order: None, - layer: None, - theme_token_resolution: ThemeTokenResolution::CssVariable, - }, - MainStaticStyle { - property: "transform".to_string(), - value: "rotate(0deg)".to_string(), - level: 2, - selector: Some(StyleSelector::from("hover")), - style_order: Some(1), - layer: Some("motion".to_string()), - theme_token_resolution: ThemeTokenResolution::FirstValue, - }, - ], - ), - ("to".to_string(), vec![]), - ]) + #[test] + #[serial] + fn ordinary_keyframes_allocator_uses_lossless_content_identity() { + // Given + let _state = css::test_state::TestStateGuard::new(); + let mut keyframes = keyframes_with_order(47); + keyframes + .keyframes + .get_mut("from") + .unwrap_or_else(|| panic!("missing fixture frame")) + .reverse(); + let content = + "66726f6d{7472616e73666f726d:726f74617465283064656729;6f706163697479:30;}746f{}"; + // When + let name = match keyframes.extract(None) { + StyleProperty::ClassName(name) => name, + StyleProperty::Variable { .. } => panic!("expected keyframes class name"), + }; + // Then + assert_eq!(name, "a"); + let map = css::class_map::get_class_map(); + assert_eq!( + map[""].keys().cloned().collect::>(), + vec![format!("k-{content}")] + ); } fn keyframes_with_order(order: u32) -> ExtractKeyframes { ExtractKeyframes { - keyframes: main_keyframes() - .into_iter() - .map(|(key, styles)| { - let styles = styles - .into_iter() - .map(|style| ExtractStaticStyle { - property: style.property, - value: style.value, - level: style.level, - selector: style.selector, - style_order: style.style_order, - layer: style.layer, - theme_token_resolution: style.theme_token_resolution, + keyframes: BTreeMap::from([ + ( + "from".to_string(), + vec![ + ExtractStaticStyle { + property: "opacity".to_string(), + value: "0".to_string(), + level: 0, + selector: None, + style_order: None, + layer: None, + theme_token_resolution: ThemeTokenResolution::CssVariable, order, - }) - .collect(); - (key, styles) - }) - .collect(), - } - } - - #[derive(Default)] - struct HashWrites(Vec>); - - impl Hasher for HashWrites { - fn finish(&self) -> u64 { - 0 - } - - fn write(&mut self, bytes: &[u8]) { - self.0.push(bytes.to_vec()); + }, + ExtractStaticStyle { + property: "transform".to_string(), + value: "rotate(0deg)".to_string(), + level: 2, + selector: Some(StyleSelector::from("hover")), + style_order: Some(1), + layer: Some("motion".to_string()), + theme_token_resolution: ThemeTokenResolution::FirstValue, + order, + }, + ], + ), + ("to".to_string(), vec![]), + ]), } } - #[rstest] - #[case(0)] - #[case(47)] - fn hash_stream_matches_main_when_order_changes(#[case] order: u32) { - let mut expected = HashWrites::default(); - main_keyframes().hash(&mut expected); - let keyframes = keyframes_with_order(order); - let mut actual = HashWrites::default(); - - KeyframeIdentity(&keyframes.keyframes).hash(&mut actual); - - assert_eq!(actual.0, expected.0); - } - #[rstest] #[case(None)] #[case(Some("keyframe-identity.tsx"))] #[serial] - fn identifier_matches_main_when_only_order_changes(#[case] filename: Option<&str>) { - let mut main_hasher = DefaultHasher::new(); - main_keyframes().hash(&mut main_hasher); - let expected = keyframes_to_keyframes_name(&main_hasher.finish().to_string(), filename); - + fn identifier_matches_when_only_authored_order_changes(#[case] filename: Option<&str>) { + let _state = css::test_state::TestStateGuard::new(); let identifiers = [0, 47].map( |order| match keyframes_with_order(order).extract(filename) { @@ -198,36 +111,78 @@ mod tests { }, ); - assert_eq!(identifiers, [expected.clone(), expected]); + assert_eq!(identifiers[0], identifiers[1]); } #[rstest] - #[case("color", "0")] - #[case("opacity", "1")] + #[case(("opacity", "0"), ("color", "0"))] + #[case(("opacity", "0"), ("opacity", "1"))] + #[case(("a", "bc"), ("ab", "c"))] + #[case(("a:b", "c;{}"), ("a", "b:c;{}"))] #[serial] fn identifier_changes_when_property_or_value_changes( - #[case] property: &str, - #[case] value: &str, + #[case] before: (&str, &str), + #[case] after: (&str, &str), ) -> Result<(), &'static str> { - let original = keyframes_with_order(0); + let _state = css::test_state::TestStateGuard::new(); + // Given + let mut original = keyframes_with_order(0); + let style = &mut original + .keyframes + .get_mut("from") + .ok_or("missing fixture frame")?[0]; + style.property = before.0.into(); + style.value = before.1.into(); let mut changed = original.clone(); let style = &mut changed .keyframes .get_mut("from") .ok_or("missing fixture frame")?[0]; - style.property = property.to_string(); - style.value = value.to_string(); + style.property = after.0.into(); + style.value = after.1.into(); let original_identifier = match original.extract(None) { StyleProperty::ClassName(name) => name, StyleProperty::Variable { .. } => panic!("keyframes must extract a class name"), }; + // When let changed_identifier = match changed.extract(None) { StyleProperty::ClassName(name) => name, StyleProperty::Variable { .. } => panic!("keyframes must extract a class name"), }; + // Then assert_ne!(changed_identifier, original_identifier); Ok(()) } + + #[rstest] + #[case(false)] + #[case(true)] + #[serial] + fn identifier_changes_when_declarations_reorder_or_empty_step_is_removed( + #[case] remove_empty_step: bool, + ) -> Result<(), &'static str> { + // Given + let _state = css::test_state::TestStateGuard::new(); + let original = keyframes_with_order(0); + let mut changed = original.clone(); + if remove_empty_step { + changed.keyframes.remove("to"); + } else { + changed + .keyframes + .get_mut("from") + .ok_or("missing fixture frame")? + .reverse(); + } + // When + let names = [original, changed].map(|frames| match frames.extract(None) { + StyleProperty::ClassName(name) => name, + StyleProperty::Variable { .. } => panic!("keyframes must extract a class name"), + }); + // Then + assert_ne!(names[0], names[1]); + Ok(()) + } } diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 10f315d47..e4eef508e 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -15602,7 +15602,7 @@ const Button = styled.button({ bg: 'red' }) )] #[case( "css(yellow, fade)", - r#""k-525370705245237976 color-0-red-_a__c_hover-255 color-0-yellow--255""# + r#""a color-0-red-_a__c_hover-255 color-0-yellow--255""# )] #[case( "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })", diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index e4b0e533b..820c645da 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -257,6 +257,40 @@ type KeyframesMap = BTreeMap Vec<(selector, property, value)> collected for `@layer` output. type LayeredStyles = BTreeMap; +fn serialize_properties( + properties: &BTreeMap, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + let mut files = BTreeMap::new(); + for (file, levels) in properties { + let mut sorted_levels = BTreeMap::new(); + for (level, orders) in levels { + let mut sorted_orders = BTreeMap::new(); + for (order, properties) in orders { + let mut records = properties + .iter() + .map(|property| serde_json::to_vec(property).map(|bytes| (bytes, property))) + .collect::, _>>() + .map_err(serde::ser::Error::custom)?; + records.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + sorted_orders.insert( + order, + records + .into_iter() + .map(|(_, property)| property) + .collect::>(), + ); + } + sorted_levels.insert(level, sorted_orders); + } + files.insert(file, sorted_levels); + } + files.serialize(serializer) +} + fn deserialize_btree_map_u8<'de, D>( deserializer: D, ) -> Result, D::Error> @@ -288,7 +322,11 @@ where pub struct StyleSheet { #[serde(default, skip_serializing_if = "Option::is_none")] pub atom_plan: Option>, - #[serde(deserialize_with = "deserialize_btree_map_u8", default)] + #[serde( + deserialize_with = "deserialize_btree_map_u8", + serialize_with = "serialize_properties", + default + )] pub properties: BTreeMap, #[serde(default)] pub css: BTreeMap>, @@ -320,7 +358,7 @@ impl StyleSheet { Export { sheet: self, - atom_naming_version: 2, + atom_naming_version: 3, } } @@ -1542,6 +1580,50 @@ mod tests { use rustc_hash::FxHashSet; use serial_test::serial; + #[test] + #[serial] + fn export_snapshot_totally_orders_complete_property_records() { + let _state = css::test_state::TestStateGuard::new(); + // Given an explicitly noncanonical set, including ties in both existing Ord impls. + let (properties, expected) = (0..8) + .find_map(|salt| { + let properties: FxHashSet<_> = (0..64) + .map(|i| StyleSheetProperty { + class_name: format!("{salt}-{}", i & 1), + property: "color".into(), + value: "red".into(), + selector: Some(StyleSelector::Global("body".into(), (i & 32).to_string())), + layer: Some((i & 2).to_string()), + typography: i & 4 != 0, + hoisted: i & 8 != 0, + order: i & 16, + }) + .collect(); + let actual: Vec<_> = properties + .iter() + .map(|p| serde_json::to_string(p).unwrap()) + .collect(); + let mut expected = actual.clone(); + expected.sort(); + (actual != expected).then_some((properties, expected)) + }) + .expect("fixture must expose noncanonical hash iteration"); + let mut sheet = StyleSheet::default(); + sheet.properties.insert( + String::new(), + BTreeMap::from([(0, BTreeMap::from([(0, properties)]))]), + ); + let css = sheet.create_css(None, false); + // When + let raw = serde_json::to_string(&sheet.export_snapshot()).unwrap(); + // Then: compare raw array bytes, preserving all 64 unequal records. + assert!( + raw.contains(&format!("\"0\":[{}]", expected.join(","))), + "{raw}" + ); + assert_eq!(sheet.create_css(None, false), css); + } + #[rstest] #[serial] #[case("1px", "1px")] diff --git a/packages/components/src/components/Button/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Button/__tests__/__snapshots__/index.browser.test.tsx.snap index 2e03d4a14..42c6aec2e 100644 --- a/packages/components/src/components/Button/__tests__/__snapshots__/index.browser.test.tsx.snap +++ b/packages/components/src/components/Button/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -201,7 +201,7 @@ exports[`Button should render loading spinner when loading is true 1`] = `