Skip to content

Commit 68ebd4e

Browse files
committed
Fix selector variable issue
1 parent a458c89 commit 68ebd4e

File tree

11 files changed

+270
-49
lines changed

11 files changed

+270
-49
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"changes":{"bindings/devup-ui-wasm/package.json":"Patch"},"note":"Fix selector variable issue","date":"2026-04-08T12:05:26.100136600Z"}

libs/extractor/src/extractor/extract_style_from_expression.rs

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -397,31 +397,46 @@ pub fn extract_style_from_expression<'a>(
397397
} else {
398398
match expression {
399399
Expression::UnaryExpression(un) => ExtractResult {
400+
// `name` is None only when this was reached through the `_xxx`
401+
// selector recursion (see line 324). In that case the value
402+
// cannot be statically extracted as a dynamic style because
403+
// the pseudo-selector has no CSS property slot to bind a
404+
// CSS variable to, so we return an empty result and let the
405+
// caller drop the attribute (see issue with `_hover={var}`).
400406
styles: if un.operator == UnaryOperator::Void {
401407
vec![]
402-
} else {
408+
} else if let Some(name) = name {
403409
vec![dynamic_style(
404410
ast_builder,
405-
name.unwrap(),
411+
name,
406412
expression,
407413
level,
408414
selector,
409415
)]
416+
} else {
417+
vec![]
410418
},
411419
..ExtractResult::default()
412420
},
413421
Expression::BinaryExpression(_)
414422
| Expression::StaticMemberExpression(_)
415-
| Expression::CallExpression(_) => ExtractResult {
416-
styles: vec![dynamic_style(
417-
ast_builder,
418-
name.unwrap(),
419-
expression,
420-
level,
421-
selector,
422-
)],
423-
..ExtractResult::default()
424-
},
423+
| Expression::CallExpression(_) => {
424+
if let Some(name) = name {
425+
ExtractResult {
426+
styles: vec![dynamic_style(
427+
ast_builder,
428+
name,
429+
expression,
430+
level,
431+
selector,
432+
)],
433+
..ExtractResult::default()
434+
}
435+
} else {
436+
// See comment on UnaryExpression arm above.
437+
ExtractResult::default()
438+
}
439+
}
425440
Expression::TSAsExpression(exp) => extract_style_from_expression(
426441
ast_builder,
427442
name,
@@ -434,6 +449,11 @@ pub fn extract_style_from_expression<'a>(
434449
extract_style_from_member_expression(ast_builder, name, mem, level, selector)
435450
}
436451
Expression::TemplateLiteral(_) => ExtractResult {
452+
// `typo == true` implies `name == Some("typography")` (set at
453+
// line 337 inside an `if let Some(name) = name` block), so the
454+
// typo branch is safe. The non-typo branch must handle the
455+
// `name.is_none()` case (pseudo-selector recursion) by
456+
// returning empty styles.
437457
styles: if typo {
438458
vec![ExtractStyleProp::Expression {
439459
expression: ast_builder.expression_template_literal(
@@ -463,22 +483,30 @@ pub fn extract_style_from_expression<'a>(
463483
),
464484
styles: vec![],
465485
}]
466-
} else {
486+
} else if let Some(name) = name {
467487
vec![dynamic_style(
468488
ast_builder,
469-
name.unwrap(),
489+
name,
470490
expression,
471491
level,
472492
selector,
473493
)]
494+
} else {
495+
vec![]
474496
},
475497
..ExtractResult::default()
476498
},
477499
Expression::Identifier(identifier) => {
500+
// When `name` is `None` we are inside a pseudo-selector
501+
// recursion (e.g. `_hover={someIdentifier}`). In that case
502+
// the identifier is a black box (it may come from another
503+
// module) and we cannot statically extract a style from it,
504+
// so we skip extraction gracefully instead of panicking. The
505+
// pseudo-selector attribute will be stripped by the visitor
506+
// like any other non-extracted style prop.
478507
if IGNORED_IDENTIFIERS.contains(&identifier.name.as_str()) {
479508
ExtractResult::default()
480-
} else {
481-
let name = name.unwrap();
509+
} else if let Some(name) = name {
482510
if typo {
483511
ExtractResult {
484512
styles: vec![ExtractStyleProp::Expression {
@@ -530,6 +558,8 @@ pub fn extract_style_from_expression<'a>(
530558
..ExtractResult::default()
531559
}
532560
}
561+
} else {
562+
ExtractResult::default()
533563
}
534564
}
535565
Expression::LogicalExpression(logical) => {

libs/extractor/src/extractor/extract_style_from_member_expression.rs

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,17 @@ pub(super) fn extract_style_from_member_expression<'a>(
6565
);
6666
}
6767
}
68+
// If `name` is None (pseudo-selector recursion) we cannot emit a
69+
// dynamic_style because there is no CSS property slot to bind to.
70+
// Fall back to an empty result in that case.
6871
return ExtractResult {
6972
props: None,
7073
styles: etc
71-
.map(|etc| {
74+
.zip(name)
75+
.map(|(etc, name)| {
7276
vec![dynamic_style(
7377
ast_builder,
74-
name.unwrap(),
78+
name,
7579
&Expression::ComputedMemberExpression(
7680
ast_builder.alloc_computed_member_expression(
7781
SPAN,
@@ -94,23 +98,27 @@ pub(super) fn extract_style_from_member_expression<'a>(
9498
let mut map = BTreeMap::new();
9599
for (idx, p) in array.elements.iter_mut().enumerate() {
96100
if let ArrayExpressionElement::SpreadElement(sp) = p {
97-
map.insert(
98-
idx.to_string(),
99-
Box::new(dynamic_style(
100-
ast_builder,
101-
name.unwrap(),
102-
&Expression::ComputedMemberExpression(
103-
ast_builder.alloc_computed_member_expression(
104-
SPAN,
105-
sp.argument.clone_in(ast_builder.allocator),
106-
mem_expression.clone_in(ast_builder.allocator),
107-
false,
101+
// Skip spread elements entirely when `name` is None — we
102+
// can't synthesize a dynamic style without a prop name.
103+
if let Some(name) = name {
104+
map.insert(
105+
idx.to_string(),
106+
Box::new(dynamic_style(
107+
ast_builder,
108+
name,
109+
&Expression::ComputedMemberExpression(
110+
ast_builder.alloc_computed_member_expression(
111+
SPAN,
112+
sp.argument.clone_in(ast_builder.allocator),
113+
mem_expression.clone_in(ast_builder.allocator),
114+
false,
115+
),
108116
),
109-
),
110-
level,
111-
&selector.clone(),
112-
)),
113-
);
117+
level,
118+
&selector.clone(),
119+
)),
120+
);
121+
}
114122
} else if let Some(p) = p.as_expression_mut() {
115123
map.insert(
116124
idx.to_string(),
@@ -162,11 +170,10 @@ pub(super) fn extract_style_from_member_expression<'a>(
162170
}
163171
}
164172

165-
match etc {
166-
None => return ExtractResult::default(),
167-
Some(etc) => ret.push(dynamic_style(
173+
match (etc, name) {
174+
(Some(etc), Some(name)) => ret.push(dynamic_style(
168175
ast_builder,
169-
name.unwrap(),
176+
name,
170177
&Expression::ComputedMemberExpression(
171178
ast_builder.alloc_computed_member_expression(
172179
SPAN,
@@ -178,6 +185,9 @@ pub(super) fn extract_style_from_member_expression<'a>(
178185
level,
179186
selector,
180187
)),
188+
// No spread fallback, or no prop name (pseudo-selector
189+
// recursion): return empty instead of panicking.
190+
_ => return ExtractResult::default(),
181191
}
182192
}
183193

@@ -205,10 +215,14 @@ pub(super) fn extract_style_from_member_expression<'a>(
205215
expression: mem_expression.clone_in(ast_builder.allocator),
206216
map,
207217
});
208-
} else if let Expression::Identifier(_) = &mut mem.object {
218+
} else if let Expression::Identifier(_) = &mut mem.object
219+
&& let Some(name) = name
220+
{
221+
// When `name` is None we are in a pseudo-selector recursion and
222+
// cannot emit a dynamic_style — skip gracefully.
209223
ret.push(dynamic_style(
210224
ast_builder,
211-
name.unwrap(),
225+
name,
212226
&Expression::ComputedMemberExpression(ast_builder.alloc_computed_member_expression(
213227
SPAN,
214228
mem.object.clone_in(ast_builder.allocator),

libs/extractor/src/lib.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16724,4 +16724,142 @@ const composed = stylex.create({ combined: { ...stylex.include(base.root) } });"
1672416724
.unwrap()
1672516725
));
1672616726
}
16727+
16728+
#[test]
16729+
#[serial]
16730+
fn extract_pseudo_selector_with_non_literal_value_graceful() {
16731+
// Minimal regression tests for the panic that ERROR.tsx surfaced.
16732+
//
16733+
// When a pseudo-selector prop (`_hover`, `_active`, `_focus`, ...)
16734+
// receives anything other than an inline object literal, the
16735+
// extractor used to `unwrap()` a None prop-name inside the
16736+
// `_xxx` selector recursion path and panic. The fix is to return
16737+
// an empty ExtractResult from the non-literal branches of
16738+
// `extract_style_from_expression` and
16739+
// `extract_style_from_member_expression` when `name` is `None`.
16740+
//
16741+
// Each case below must:
16742+
// 1. NOT panic (used to panic before the fix)
16743+
// 2. Return Ok from `extract()`
16744+
// 3. Drop the un-extractable pseudo-selector attribute from the
16745+
// generated code (no class, no runtime style — devup-ui is
16746+
// fully static, so there is no runtime fallback to fall back
16747+
// to)
16748+
let cases: &[(&str, &str)] = &[
16749+
(
16750+
"identifier",
16751+
r#"import {Box} from '@devup-ui/react'
16752+
const hoverStyle = { opacity: 1 };
16753+
export const A = () => <Box _hover={hoverStyle} />;
16754+
"#,
16755+
),
16756+
(
16757+
"call expression",
16758+
r#"import {Box} from '@devup-ui/react'
16759+
declare const getHover: () => object;
16760+
export const A = () => <Box _hover={getHover()} />;
16761+
"#,
16762+
),
16763+
(
16764+
"member expression",
16765+
r#"import {Box} from '@devup-ui/react'
16766+
declare const styles: { hover: object };
16767+
export const A = () => <Box _hover={styles.hover} />;
16768+
"#,
16769+
),
16770+
(
16771+
"binary expression",
16772+
r#"import {Box} from '@devup-ui/react'
16773+
declare const a: any; declare const b: any;
16774+
export const A = () => <Box _hover={a || b} />;
16775+
"#,
16776+
),
16777+
(
16778+
"template literal",
16779+
r#"import {Box} from '@devup-ui/react'
16780+
declare const x: string;
16781+
export const A = () => <Box _hover={`${x}`} />;
16782+
"#,
16783+
),
16784+
(
16785+
"unary expression",
16786+
r#"import {Box} from '@devup-ui/react'
16787+
declare const v: any;
16788+
export const A = () => <Box _hover={!v} />;
16789+
"#,
16790+
),
16791+
(
16792+
"computed member expression (array index)",
16793+
r#"import {Box} from '@devup-ui/react'
16794+
declare const arr: any[];
16795+
export const A = () => <Box _hover={arr[0]} />;
16796+
"#,
16797+
),
16798+
];
16799+
16800+
for (label, src) in cases {
16801+
reset_class_map();
16802+
reset_file_map();
16803+
let result = std::panic::catch_unwind(|| {
16804+
extract(
16805+
"test.tsx",
16806+
src,
16807+
ExtractOption {
16808+
package: "@devup-ui/react".to_string(),
16809+
css_dir: "@devup-ui/react".to_string(),
16810+
single_css: true,
16811+
import_main_css: false,
16812+
import_aliases: HashMap::new(),
16813+
},
16814+
)
16815+
});
16816+
match result {
16817+
Ok(Ok(output)) => {
16818+
println!(
16819+
"[OK] {label}: extract succeeded, {} styles",
16820+
output.styles.len()
16821+
);
16822+
}
16823+
Ok(Err(e)) => {
16824+
panic!("[FAIL] {label}: extract returned Err: {e}");
16825+
}
16826+
Err(panic_payload) => {
16827+
let msg = panic_payload
16828+
.downcast_ref::<&'static str>()
16829+
.map(|s| (*s).to_string())
16830+
.or_else(|| panic_payload.downcast_ref::<String>().cloned())
16831+
.unwrap_or_else(|| "<non-string panic>".to_string());
16832+
panic!("[FAIL] {label}: extract panicked: {msg}");
16833+
}
16834+
}
16835+
}
16836+
}
16837+
16838+
#[test]
16839+
#[serial]
16840+
fn extract_pseudo_selector_with_identifier_snapshot() {
16841+
// Snapshot-locks the current behavior for the minimal ERROR.tsx
16842+
// reduction so future refactors can't silently regress either
16843+
// direction (re-introducing the panic, or over-eagerly turning the
16844+
// `_hover={ident}` attribute into something surprising).
16845+
reset_class_map();
16846+
reset_file_map();
16847+
assert_debug_snapshot!(ToBTreeSet::from(
16848+
extract(
16849+
"test.tsx",
16850+
r#"import {Box} from '@devup-ui/react'
16851+
const hoverStyle = { opacity: 1 };
16852+
export const A = () => <Box _hover={hoverStyle} bg="red" />;
16853+
"#,
16854+
ExtractOption {
16855+
package: "@devup-ui/react".to_string(),
16856+
css_dir: "@devup-ui/react".to_string(),
16857+
single_css: true,
16858+
import_main_css: false,
16859+
import_aliases: HashMap::new(),
16860+
},
16861+
)
16862+
.unwrap()
16863+
));
16864+
}
1672716865
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
source: libs/extractor/src/lib.rs
3+
assertion_line: 16905
4+
expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/react'\nconst hoverStyle = { opacity: 1 };\nexport const A = () => <Box _hover={hoverStyle} bg=\"red\" />;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new(),\n},).unwrap())"
5+
---
6+
ToBTreeSet {
7+
styles: {
8+
Static(
9+
ExtractStaticStyle {
10+
property: "background",
11+
value: "red",
12+
level: 0,
13+
selector: None,
14+
style_order: None,
15+
layer: None,
16+
},
17+
),
18+
},
19+
code: "import \"@devup-ui/react/devup-ui.css\";\nconst hoverStyle = { opacity: 1 };\nexport const A = () => <div className=\"a\" />;\n",
20+
}

0 commit comments

Comments
 (0)