From 35317917bff8eeb216a4455fe1ff1df6ab0b2d7e Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 10:50:04 +0900 Subject: [PATCH 1/3] fix(extractor): give stylex.attrs() style as attribute text Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_stylex_attrs_style.json | 8 +++ bindings/devup-ui-wasm/src/lib.rs | 8 +-- libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/lib.rs | 27 ++++++++-- libs/extractor/src/tailwind.rs | 4 +- libs/extractor/src/visit.rs | 53 +++++++++++++++++-- packages/react/src/utils/stylex.ts | 2 +- 7 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 .changepacks/changepack_log_stylex_attrs_style.json diff --git a/.changepacks/changepack_log_stylex_attrs_style.json b/.changepacks/changepack_log_stylex_attrs_style.json new file mode 100644 index 000000000..7ccca57d3 --- /dev/null +++ b/.changepacks/changepack_log_stylex_attrs_style.json @@ -0,0 +1,8 @@ +{ + "changes": { + "packages/react/package.json": "Patch", + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "stylex.attrs() gives the dynamic CSS variables it sets as `style` attribute text (`\"--a:\" + value`), as StyleX does for spreading onto non-React targets, instead of a style object; stylex.props() keeps the object React takes. The attrs() return type declares `style` as a string", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f3..0a2347ac2 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1551,7 +1551,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1951,7 +1951,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +1975,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2000,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb021..3b2346aed 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36c..015794482 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -836,8 +836,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -13598,7 +13598,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -18778,7 +18778,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", @@ -20728,6 +20728,25 @@ const e =
;" ))); } + // `attrs()` gives the `style` attribute as text, as StyleX does, while + // `props()` keeps the object React takes + #[test] + #[serial] + fn test_stylex_attrs_style_is_text() { + let code = extract_tsx( + r"import * as stylex from '@stylexjs/stylex'; +const s = stylex.create({ box: (w, h) => ({ width: w, height: h }) }); +export const a = stylex.attrs(s.box(w, h)); +export const p = stylex.props(s.box(w, h));", + ) + .code; + assert!( + code.contains("style: \"--a:\" + ((v) => typeof v === \"number\" ? v + \"px\" : v)(w) + \";--b:\" + ((v) => typeof v === \"number\" ? v + \"px\" : v)(h)"), + "{code}" + ); + assert!(code.contains("style: {\n\t\t\"--a\":"), "{code}"); + } + #[test] #[serial] fn test_emotion_global_with_spread_attribute() { diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f5178..70643aa7c 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -284,7 +284,7 @@ pub struct TailwindClass { /// non-overlapping) but mutates the existing buffer instead of allocating a new /// `String`. `needle` must be non-empty. fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); + debug_assert_ne!(needle, ""); let mut search_from = 0; while let Some(rel) = haystack[search_from..].find(needle) { let at = search_from + rel; @@ -3934,7 +3934,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test] diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 93846123e..41ef1bd50 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -637,6 +637,44 @@ impl<'a> DevupVisitor<'a> { self.stylex_function(callee).as_ref() == Some(function) } + /// `"--a:" + a + ";--b:" + b`: the variables `props` sets, as the text of a + /// `style` attribute + fn style_text(&self, props: Vec>) -> Expression<'a> { + let mut text: Option> = None; + for (index, prop) in props.into_iter().enumerate() { + let ObjectPropertyKind::ObjectProperty(prop) = prop else { + continue; + }; + let prop = prop.unbox(); + let name = get_string_by_property_key(&prop.key).unwrap_or_default(); + let prefix = format!("{}{name}:", if index == 0 { "" } else { ";" }); + let head = Expression::new_string_literal( + SPAN, + Str::from_in(prefix.as_str(), self.ast.allocator()), + None, + &self.ast, + ); + let head = match text { + Some(text) => Expression::new_binary_expression( + SPAN, + text, + BinaryOperator::Addition, + head, + &self.ast, + ), + None => head, + }; + text = Some(Expression::new_binary_expression( + SPAN, + head, + BinaryOperator::Addition, + Expression::new_parenthesized_expression(SPAN, prop.value, &self.ast), + &self.ast, + )); + } + text.unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)) + } + /// Resolve `stylex.props()` arguments to className expressions and style properties. /// Returns (`class_exprs`, `style_props`) where `style_props` are CSS variable assignments /// from dynamic namespace calls like `styles.bar(h)`. @@ -1565,11 +1603,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { // Add style property for dynamic CSS variables if !style_props.is_empty() { - let style_obj = Expression::new_object_expression( - SPAN, - oxc_allocator::Vec::from_iter_in(style_props, &self.ast), - &self.ast, - ); + // `attrs()` gives the `style` attribute as text, `props()` an object + let style_obj = if class_attribute == "class" { + self.style_text(style_props) + } else { + Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(style_props, &self.ast), + &self.ast, + ) + }; props.push(ObjectPropertyKind::new_object_property( SPAN, PropertyKind::Init, diff --git a/packages/react/src/utils/stylex.ts b/packages/react/src/utils/stylex.ts index e46905f6c..e0f051acd 100644 --- a/packages/react/src/utils/stylex.ts +++ b/packages/react/src/utils/stylex.ts @@ -32,7 +32,7 @@ export function props( export function attrs( ..._styles: ReadonlyArray -): { class?: string; style?: Record } { +): { class?: string; style?: string } { throw new Error('Cannot run on the runtime') } From efc1e9c8304d262b6aa99832a77e1ba3a79cf6f0 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 12:03:54 +0900 Subject: [PATCH 2/3] refactor(extractor): filter style props before building attrs text Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/visit.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 41ef1bd50..f06e6fbf5 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -641,11 +641,11 @@ impl<'a> DevupVisitor<'a> { /// `style` attribute fn style_text(&self, props: Vec>) -> Expression<'a> { let mut text: Option> = None; - for (index, prop) in props.into_iter().enumerate() { - let ObjectPropertyKind::ObjectProperty(prop) = prop else { - continue; - }; - let prop = prop.unbox(); + let props = props.into_iter().filter_map(|prop| match prop { + ObjectPropertyKind::ObjectProperty(prop) => Some(prop.unbox()), + ObjectPropertyKind::SpreadProperty(_) => None, + }); + for (index, prop) in props.enumerate() { let name = get_string_by_property_key(&prop.key).unwrap_or_default(); let prefix = format!("{}{name}:", if index == 0 { "" } else { ";" }); let head = Expression::new_string_literal( From 893a535416acad180a3333cca72aefdb49f57c05 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 12:18:02 +0900 Subject: [PATCH 3/3] refactor(extractor): keep attrs style prop filter on one line Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/visit.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index f06e6fbf5..39e65e43c 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -641,9 +641,12 @@ impl<'a> DevupVisitor<'a> { /// `style` attribute fn style_text(&self, props: Vec>) -> Expression<'a> { let mut text: Option> = None; - let props = props.into_iter().filter_map(|prop| match prop { - ObjectPropertyKind::ObjectProperty(prop) => Some(prop.unbox()), - ObjectPropertyKind::SpreadProperty(_) => None, + let props = props.into_iter().filter_map(|prop| { + if let ObjectPropertyKind::ObjectProperty(prop) = prop { + Some(prop.unbox()) + } else { + None + } }); for (index, prop) in props.enumerate() { let name = get_string_by_property_key(&prop.key).unwrap_or_default();