Skip to content

Commit 205aaa9

Browse files
authored
Merge pull request #719 from dev-five-git/fix/nested-css-results
fix(extractor): expand static css results under nested selectors
2 parents f16cd19 + a483ecd commit 205aaa9

7 files changed

Lines changed: 463 additions & 30 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"changes": {
3+
"bindings/devup-ui-wasm/package.json": "Patch"
4+
},
5+
"note": "Statically known css() results used as nested rule values expand into their source rules under each top-level comma-separated selector, preserving responsive values, nested selectors, layers and style order without runtime styling. Unknown rule sources retain a located build error naming the value and its static-value requirement.",
6+
"date": "2026-10-01T00:00:00.000Z"
7+
}

‎libs/css/src/style_selector.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,25 @@ pub fn split_selector_list(selector: &str) -> Vec<&str> {
110110
let mut parts = vec![];
111111
let mut depth = 0usize;
112112
let mut start = 0;
113+
let mut quote = None;
114+
let mut escaped = false;
113115
for (index, c) in selector.char_indices() {
116+
if escaped {
117+
escaped = false;
118+
continue;
119+
}
120+
if c == '\\' {
121+
escaped = true;
122+
continue;
123+
}
124+
if let Some(delimiter) = quote {
125+
if c == delimiter {
126+
quote = None;
127+
}
128+
continue;
129+
}
114130
match c {
131+
'\'' | '"' => quote = Some(c),
115132
'(' | '[' => depth += 1,
116133
')' | ']' => depth = depth.saturating_sub(1),
117134
',' if depth == 0 => {
@@ -789,6 +806,10 @@ mod tests {
789806
#[case("&:hover", vec!["&:hover"])]
790807
#[case(" a > b , i ", vec!["a > b", "i"])]
791808
#[case(":is(a, b), [title=\"a,b\"]", vec![":is(a, b)", "[title=\"a,b\"]"])]
809+
#[case(r#"&[title="a,]b"], &:focus"#, vec![r#"&[title="a,]b"]"#, "&:focus"])]
810+
#[case(r"&.a\,b, &:focus", vec![r"&.a\,b", "&:focus"])]
811+
#[case(r"&.a\(, &:focus", vec![r"&.a\(", "&:focus"])]
812+
#[case(r#"&[title="a\",]b"], &:focus"#, vec![r#"&[title="a\",]b"]"#, "&:focus"])]
792813
fn test_split_selector_list(#[case] selector: &str, #[case] expected: Vec<&str>) {
793814
assert_eq!(split_selector_list(selector), expected);
794815
}

‎libs/extractor/src/extractor/extract_style_from_expression.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ pub fn extract_style_from_expression<'a>(
392392
ast_builder,
393393
part,
394394
offset,
395-
expression,
395+
&mut expression.clone_in(ast_builder.allocator()),
396396
level,
397397
selector,
398398
literal_handling,
@@ -437,7 +437,7 @@ pub fn extract_style_from_expression<'a>(
437437
ast_builder,
438438
part,
439439
offset,
440-
&mut o.value,
440+
&mut o.value.clone_in(ast_builder.allocator()),
441441
level,
442442
selector,
443443
literal_handling,

‎libs/extractor/src/lib.rs‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11416,6 +11416,227 @@ export const e = spin.toString()",
1141611416
assert!(extract("a.css.ts", code, option(false)).is_ok());
1141711417
}
1141811418

11419+
#[rstest]
11420+
#[case::user_example(
11421+
"{ color: 'hotpink' }",
11422+
"'&:hover,&:focus': hotpink",
11423+
"'&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }"
11424+
)]
11425+
#[case::inner_comma(
11426+
"{ color: 'hotpink' }",
11427+
"'&:is(a,b), &:not(.x,.y)': hotpink",
11428+
"'&:is(a,b)': { ...hotpinkRules }, '&:not(.x,.y)': { ...hotpinkRules }"
11429+
)]
11430+
#[case::three_selectors(
11431+
"{ color: 'hotpink' }",
11432+
"' &:hover, &:focus, &:active ': hotpink",
11433+
"'&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }, '&:active': { ...hotpinkRules }"
11434+
)]
11435+
#[case::responsive(
11436+
"{ color: ['hotpink', null, 'blue'], p: [1, 2] }",
11437+
"'&:hover,&:focus': hotpink",
11438+
"'&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }"
11439+
)]
11440+
#[case::nested_layer_order(
11441+
"{ '@layer': { base: { color: 'hotpink', _active: { color: ['red', 'blue'] } } } }",
11442+
"styleOrder: 3, '&:hover,&:focus': hotpink, '&:hover': { color: 'green' }",
11443+
"styleOrder: 3, '&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }, '&:hover': { color: 'green' }"
11444+
)]
11445+
#[case::quoted_commas(
11446+
"{ color: 'hotpink' }",
11447+
r#"'&[data-x=a,b], &[title="a,]b"]': hotpink"#,
11448+
r#"'&[data-x=a,b]': { ...hotpinkRules }, '&[title="a,]b"]': { ...hotpinkRules }"#
11449+
)]
11450+
#[serial]
11451+
fn test_css_nested_static_result(
11452+
#[case] rules: &str,
11453+
#[case] nested: &str,
11454+
#[case] expanded: &str,
11455+
#[values("css(hotpinkRules)", "css({}, hotpinkRules)")] source: &str,
11456+
) {
11457+
let compile = |body| {
11458+
reset_class_map();
11459+
reset_file_map();
11460+
extract(
11461+
"test.tsx",
11462+
&format!("import {{ css }} from '@devup-ui/react'; const hotpinkRules = {rules}; const hotpink = {source}; export const hover = css({{ {body} }});"),
11463+
ExtractOption::default(),
11464+
)
11465+
.unwrap()
11466+
};
11467+
let expected = compile(expanded);
11468+
let actual = compile(nested);
11469+
assert_eq!(actual.styles, expected.styles);
11470+
assert_eq!(actual.code, expected.code);
11471+
}
11472+
11473+
#[rstest]
11474+
#[case::unknown("")]
11475+
#[case::external_class("const hotpink = css('external', { color: 'red' });")]
11476+
#[serial]
11477+
fn test_css_nested_static_result_unknown_error(#[case] binding: &str) {
11478+
reset_class_map();
11479+
reset_file_map();
11480+
let error = extract(
11481+
"test.tsx",
11482+
&format!("import {{ css }} from '@devup-ui/react'\n{binding}\nexport const hover = css({{ '&:hover,&:focus': hotpink }})"),
11483+
ExtractOption {
11484+
package: "@devup-ui/react".to_string(),
11485+
css_dir: "@devup-ui/react".to_string(),
11486+
single_css: true,
11487+
import_main_css: false,
11488+
import_aliases: HashMap::new(),
11489+
},
11490+
)
11491+
.unwrap_err()
11492+
.to_string();
11493+
assert!(
11494+
error.starts_with("test.tsx:3:47: `css()` cannot use `hotpink` at build time:")
11495+
&& error.contains("its values must be literals, theme tokens or constants"),
11496+
"{error}"
11497+
);
11498+
}
11499+
11500+
#[rstest]
11501+
#[case::composed(
11502+
"const hotpink = css(base, { p: 2 });",
11503+
"hotpink",
11504+
"{ color: 'hotpink', p: 2 }"
11505+
)]
11506+
#[case::chained(
11507+
"const hotpink = css({ _active: base });",
11508+
"hotpink",
11509+
"{ _active: { color: 'hotpink' } }"
11510+
)]
11511+
#[case::keyframes(
11512+
"const hotpink = css({ animationName: spin });",
11513+
"hotpink",
11514+
"{ animationName: spin }"
11515+
)]
11516+
#[case::alias("const hotpink = base;", "hotpink", "{ color: 'hotpink' }")]
11517+
#[case::inline("", "css({ color: 'hotpink' })", "{ color: 'hotpink' }")]
11518+
#[case::inline_known_composition(
11519+
"const hotpink = css(base, { color: 'blue', _active: base });",
11520+
"css(base, { color: 'blue', _active: base })",
11521+
"{ color: 'blue', _active: { color: 'hotpink' } }"
11522+
)]
11523+
#[case::composed_later_wins(
11524+
"const hotpink = css(base, { color: 'blue', p: 2 });",
11525+
"hotpink",
11526+
"{ color: 'blue', p: 2 }"
11527+
)]
11528+
#[case::alias_chain(
11529+
"const warm = base; const hotpink = warm;",
11530+
"hotpink",
11531+
"{ color: 'hotpink' }"
11532+
)]
11533+
#[case::selector_record(
11534+
"const hotpink = css({ selectors: { '&:active': base } });",
11535+
"hotpink",
11536+
"{ selectors: { '&:active': { color: 'hotpink' } } }"
11537+
)]
11538+
#[case::declaration_string(
11539+
"const hotpink = css({ content: base });",
11540+
"hotpink",
11541+
"{ content: `${base}` }"
11542+
)]
11543+
#[case::spread_composed(
11544+
"const hotpink = css(...[base, { p: 2 }]);",
11545+
"hotpink",
11546+
"{ color: 'hotpink', p: 2 }"
11547+
)]
11548+
#[case::adjacent_spreads(
11549+
"const hotpink = css(...[base], ...[{ color: 'blue', p: 2 }]);",
11550+
"hotpink",
11551+
"{ color: 'blue', p: 2 }"
11552+
)]
11553+
#[case::nested_array_spread(
11554+
"const hotpink = css(...[...[base], { p: 2 }]);",
11555+
"hotpink",
11556+
"{ color: 'hotpink', p: 2 }"
11557+
)]
11558+
#[case::direct_spread_source(
11559+
"const hotpink = css(...{ _active: base });",
11560+
"hotpink",
11561+
"{ _active: { color: 'hotpink' } }"
11562+
)]
11563+
#[case::spread_declaration_string(
11564+
"const hotpink = css(...[{ content: base }, { p: 2 }]);",
11565+
"hotpink",
11566+
"{ content: `${base}`, p: 2 }"
11567+
)]
11568+
#[case::layer_record(
11569+
"",
11570+
"{ '@layer': { colors: base } }",
11571+
"{ '@layer': { colors: { color: 'hotpink' } } }"
11572+
)]
11573+
#[case::media_record(
11574+
"",
11575+
"{ '@media': { print: base } }",
11576+
"{ '@media': { print: { color: 'hotpink' } } }"
11577+
)]
11578+
#[case::supports_record(
11579+
"",
11580+
"{ '@supports': { '(display: grid)': base } }",
11581+
"{ '@supports': { '(display: grid)': { color: 'hotpink' } } }"
11582+
)]
11583+
#[case::container_record(
11584+
"",
11585+
"{ '@container': { '(min-width: 10px)': base } }",
11586+
"{ '@container': { '(min-width: 10px)': { color: 'hotpink' } } }"
11587+
)]
11588+
#[case::underscore_media_record(
11589+
"",
11590+
"{ _media: { print: base } }",
11591+
"{ _media: { print: { color: 'hotpink' } } }"
11592+
)]
11593+
#[case::record_declaration_string(
11594+
"",
11595+
"{ '@layer': { colors: { content: base } } }",
11596+
"{ '@layer': { colors: { content: `${base}` } } }"
11597+
)]
11598+
#[serial]
11599+
fn test_css_nested_static_source_references(
11600+
#[case] binding: &str,
11601+
#[case] value: &str,
11602+
#[case] expanded: &str,
11603+
) {
11604+
let compile = |nested: &str| {
11605+
reset_class_map();
11606+
reset_file_map();
11607+
extract(
11608+
"test.tsx",
11609+
&format!("import {{ css, keyframes }} from '@devup-ui/react'; const spin = keyframes({{ from: {{ opacity: 0 }}, to: {{ opacity: 1 }} }}); const base = css({{ color: 'hotpink' }}); {binding} export const hover = css({{ styleOrder: 3, '@layer': {{ base: {{ '&:hover,&:focus': {nested} }} }} }});"),
11610+
ExtractOption::default(),
11611+
).unwrap()
11612+
};
11613+
let expected = compile(expanded);
11614+
let actual = compile(value);
11615+
assert_eq!(actual.styles, expected.styles);
11616+
assert_eq!(actual.code, expected.code);
11617+
}
11618+
11619+
#[test]
11620+
#[serial]
11621+
fn test_css_nested_inline_without_binding() {
11622+
let compile = |source| {
11623+
reset_class_map();
11624+
reset_file_map();
11625+
extract("test.tsx", source, ExtractOption::default()).unwrap()
11626+
};
11627+
let expected = compile(
11628+
"import { css } from '@devup-ui/react'; css({ color: 'hotpink' }); export const hover = css({ '&:hover': { color: 'hotpink' }, '&:focus': { color: 'hotpink' } });",
11629+
);
11630+
let actual = compile(
11631+
"import { css } from '@devup-ui/react'; export const hover = css({ '&:hover,&:focus': css({ color: 'hotpink' }) });",
11632+
);
11633+
assert_eq!(actual.styles, expected.styles);
11634+
assert_eq!(
11635+
actual.code.split("export const").last(),
11636+
expected.code.split("export const").last()
11637+
);
11638+
}
11639+
1141911640
#[test]
1142011641
#[serial]
1142111642
fn test_keyframes_no_args() {

0 commit comments

Comments
 (0)