Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_class_names_bindings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Resolve Emotion ClassNames components, callback css/cx APIs and theme reads by lexical binding identity across compilation, numeric preprocessing, imported constants and mutation analysis. Ordinary shadowed callbacks, components and values retain their authored behavior while genuine outer APIs still compile without styling runtime. No new build-error categories.",
"date": "2026-10-20T00:00:00.000Z"
}
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_emotion_class_names.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch",
"packages/react/package.json": "Patch"
},
"note": "Emotion's <ClassNames> now compiles at build time with no runtime: the element becomes what its child function ({ css, cx, theme }) => ... renders, each css and cx call becomes the classes it composes (later styles replace earlier ones; css reads strings as CSS text and its tagged templates split around mixins; cx keeps unknown classes, reads { name: condition } objects as conditional classes and composes nested css and cx calls), and theme.a.b in their values becomes var(--a-b). Numbers in css rules are px lengths, as Emotion reads them, and top-level constants are inlined. New build errors, each at file:line:col: a <ClassNames> without exactly one child function taking { css, cx, theme } that gives what it renders at once (attributes, other children, rest or default parameters, async or generator functions, several statements); reading the theme other than as theme.a.b in a css value; using css or cx other than by calling them; a cx object with a spread, getter or method; parts css and cx cannot compose (runtime calls, spreads, a condition choosing between styles and classes). @devup-ui/react/compat exports ClassNames with the ClassNamesContent and ClassNamesArg types, and the @emotion/react declarations include them",
"date": "2026-10-01T00:00:00.000Z"
}
14 changes: 14 additions & 0 deletions .changepacks/changepack_log_emotion_jsx_import_source.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch",
"packages/plugin-utils/package.json": "Patch",
"packages/react/package.json": "Patch",
"packages/vite-plugin/package.json": "Patch",
"packages/next-plugin/package.json": "Patch",
"packages/webpack-plugin/package.json": "Patch",
"packages/rsbuild-plugin/package.json": "Patch",
"packages/bun-plugin/package.json": "Patch"
},
"note": "A project whose tsconfig.json (or jsconfig.json) builds JSX with Emotion (compilerOptions.jsxImportSource of @emotion/react, through extends and project references too) now compiles the css prop on every element of every file, and its .tsx and .jsx files are built with React's JSX runtime through a /** @jsxImportSource react */ pragma, which a file's own JSX pragma overrides, so the bundler no longer imports Emotion's runtime. The plugins read the setting into a new @emotion/react/jsx-runtime alias (importAliases can turn it off with false, and it is left out when @emotion/react is not aliased); @devup-ui/react/compat/emotion-jsx-runtime declares Emotion's JSX runtimes as React's so the project type-checks without Emotion installed. plugin-utils exports readJsxImportSource, and mergeImportAliases takes the JSX import source",
"date": "2026-10-01T00:00:00.000Z"
}
246 changes: 246 additions & 0 deletions libs/extractor/src/class_names_binding_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
use crate::{ExtractOption, ExtractOutput, ImportAlias, ResolvedModule, extract_with_modules};
use boa_engine::{Context, Source};
use css::{class_map::reset_class_map, file_map::reset_file_map};
use oxc_allocator::Allocator;
use oxc_ast::ast::Statement;
use oxc_codegen::Codegen;
use oxc_parser::Parser;
use oxc_span::SourceType;
use rstest::rstest;
use serial_test::serial;
use std::collections::HashMap;

#[path = "class_names_nested_binding_tests.rs"]
mod nested;

fn compile(code: &str) -> ExtractOutput {
reset_class_map();
reset_file_map();
extract_with_modules(
"binding.tsx",
code,
ExtractOption {
single_css: true,
import_main_css: false,
import_aliases: HashMap::from([(
"@emotion/react".to_string(),
ImportAlias::NamedToNamed,
)]),
..ExtractOption::default()
},
false,
&|specifier, _| {
(specifier == "./data").then(|| ResolvedModule {
path: "data.ts".to_string(),
code: "export const data = { padding: 7 }; export const space = 3;".to_string(),
})
},
)
.unwrap_or_else(|error| panic!("{code}\n{error}"))
}

fn execute(output: &ExtractOutput) -> String {
let arena = Allocator::default();
let mut parsed = Parser::new(&arena, &output.code, SourceType::tsx()).parse();
assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code);
parsed
.program
.body
.retain(|statement| !matches!(statement, Statement::ImportDeclaration(_)));
let code = Codegen::new().build(&parsed.program).code;
let script = format!("const data={{padding:7}};{code}\nJSON.stringify(App());");
Context::default()
.eval(Source::from_bytes(&script))
.unwrap_or_else(|error| panic!("{script}\n{error}"))
.as_string()
.unwrap_or_else(|| panic!("expected JSON: {script}"))
.to_std_string_escaped()
}

fn styles(output: &ExtractOutput) -> Vec<(&str, &str)> {
let mut styles: Vec<_> = output
.styles
.iter()
.map(|style| match style {
crate::ExtractStyleValue::Static(style) => {
(style.property.as_str(), style.value.as_str())
}
other => panic!("unexpected style: {other:?}"),
})
.collect();
styles.sort_unstable();
styles
}

#[rstest]
#[case("((css)=>css('color:red;'))(()=>{trace.push('shadow-call');return 'kept';})")]
#[case(
"(()=>{const css=()=>{trace.push('shadow-call');return 'kept';};return css('color:red;');})()"
)]
#[case(
"(()=>{let css=()=>{trace.push('shadow-call');return 'kept';};return css('color:red;');})()"
)]
#[case(
"(()=>{var css=()=>{trace.push('shadow-call');return 'kept';};return css('color:red;');})()"
)]
#[case(
"(()=>{function css(){trace.push('shadow-call');return 'kept';}return css('color:red;');})()"
)]
#[case(
"(()=>{try{throw ()=>{trace.push('shadow-call');return 'kept';};}catch(css){return css('color:red;');}})()"
)]
#[serial]
fn class_names_binding_shadow_calls_keep_effects(#[case] title: &str) {
// Given: an ordinary shadow beside an actual outer API read in a nested function.
let source = format!(
"import {{ClassNames}} from '@emotion/react';const trace=[];const App=()=> <ClassNames>{{({{css}})=> ({{title:{title},trace,className:(()=>css({{color:'blue'}}))()}})}}</ClassNames>;"
);
// When: compile and execute the generated render function.
let output = compile(&source);
let value = execute(&output);
// Then: only the real API emits CSS; ordinary application effects survive.
assert_eq!(styles(&output), [("color", "blue")]);
assert_eq!(
value,
r#"{"title":"kept","trace":["shadow-call"],"className":"a"}"#
);
}

#[test]
#[serial]
fn class_names_binding_numeric_shadow_keeps_values() {
// Given: aliases and independent css/cx calls taking numbers and arrays.
let output = compile(
"import {ClassNames as Names} from '@emotion/react';const App=()=> <Names>{({css:style,cx:join})=> ({local:((style,join)=>[style({padding:[1,2]}),join({kept:2})])(x=>x,x=>x),className:join(style({padding:[3,4]}),'caller')})}</Names>;",
);
// When: execute the actual output, not a string-token approximation of the pass.
let value = execute(&output);
// Then: only genuine styles receive px/fallback conversion and class-map lowering.
assert_eq!(styles(&output), [("padding", "3px;padding:4px")]);
assert_eq!(
value,
r#"{"local":[{"padding":[1,2]},{"kept":2}],"className":"caller a"}"#
);
}

#[test]
#[serial]
fn class_names_binding_theme_shadow_keeps_value() {
// Given: the outer theme is read from nested code; an independent local theme is ordinary.
let output = compile(
"import {ClassNames} from '@emotion/react';const App=()=> <ClassNames>{({css,theme})=> ({local:((theme)=>theme.spacing(2))({spacing:x=>x+5}),className:(()=>css({color:theme.colors.text}))()})}</ClassNames>;",
);
// When: compile and execute the application value.
let value = execute(&output);
// Then: real theme reads lower to variables without rejecting the ordinary local call.
assert_eq!(styles(&output), [("color", "var(--colors-text)")]);
assert_eq!(value, r#"{"local":7,"className":"a"}"#);
}

#[test]
#[serial]
fn class_names_binding_shadow_import_stays_ordinary() {
// Given: imported data is used only by an ordinary shadow, while a real API reads space.
let output = compile(
"import {ClassNames} from '@emotion/react';import {data,space} from './data';const App=()=> <ClassNames>{({css})=> ({local:((css)=>css(data))(x=>x),same:((css)=>css(data))(x=>x)===data,className:css({padding:space})})}</ClassNames>;",
);
// When: execute with the runtime import's original object.
let value = execute(&output);
// Then: inlining/pixelification does not copy or change ordinary imported data.
assert_eq!(styles(&output), [("padding", "3px")]);
assert_eq!(
value,
r#"{"local":{"padding":7},"same":true,"className":"a"}"#
);
}

#[test]
#[serial]
fn class_names_binding_shadow_does_not_fall_back_to_imported_api() {
// Given: the file also imports css, whose spelling cannot identify the nested local.
let output = compile(
"import {ClassNames,css} from '@emotion/react';const App=()=> <ClassNames>{({css})=> ({local:((css)=>css({padding:[1,2]}))(x=>x),className:css({color:'blue'})})}</ClassNames>;",
);
// When: execute the ordinary call beside the genuine callback API.
let value = execute(&output);
// Then: neither the number pass, constant pass nor visitor reclassifies it as the import.
assert_eq!(styles(&output), [("color", "blue")]);
assert_eq!(value, r#"{"local":{"padding":[1,2]},"className":"a"}"#);
}

#[test]
#[serial]
fn class_names_binding_shadow_only_import_is_not_loaded() {
// Given: an unresolved runtime import is not a style dependency.
let output = compile(
"import {ClassNames} from '@emotion/react';import {data} from './runtime';const App=()=> <ClassNames>{({css})=> ((css)=>css(data))(x=>x)}</ClassNames>;",
);
// When: execute the emitted ordinary callback.
let value = execute(&output);
// Then: no false style read, dependency, or unsupported diagnostic is introduced.
assert_eq!(output.dependencies, Vec::<String>::new());
assert_eq!(value, r#"{"padding":7}"#);
assert_eq!(output.styles.len(), 0);
}

#[test]
#[serial]
fn class_names_binding_component_shadow_is_not_compiled() {
// Given: the component parameter shares only the imported API's spelling.
let output = compile(
"import {ClassNames} from '@emotion/react';const App=ClassNames=><ClassNames marker={7}>{({css})=>css({padding:[1,2]})}</ClassNames>;",
);
// When: inspect the surviving ordinary component's public props and child values.
// Then: neither compilation nor preprocessing treats it as the imported component.
assert!(
output.code.contains("<ClassNames marker={7}>"),
"{}",
output.code
);
assert!(output.code.contains("padding: [1, 2]"), "{}", output.code);
assert_eq!(output.styles.len(), 0);
}

#[rstest]
#[case(
"const App=()=> <ClassNames>{({css})=>((css)=>css(data))(mutate)}</ClassNames>;",
true
)]
#[case(
"const App=ClassNames=> <ClassNames ref={data}>{({css})=>css({color:'blue'})}</ClassNames>;",
true
)]
#[case("const App=()=> <ClassNames>{({css})=>css(data)}</ClassNames>;", false)]
#[case(
"const App=()=> <ClassNames>{({css:style})=>(()=>style(data))()}</ClassNames>;",
false
)]
#[case(
"const App=()=> <ClassNames>{({css})=>css`${data}`}</ClassNames>;",
false
)]
fn class_names_binding_mutation_shadow_is_runtime_escape(
#[case] body: &str,
#[case] escapes: bool,
) {
// Given: the mutation classifier owns this program's semantic analysis.
let arena = Allocator::default();
let source =
format!("import {{ClassNames}} from '@emotion/react';const data={{padding:1}};{body}");
let parsed = Parser::new(&arena, &source, SourceType::tsx()).parse();
let takers = crate::css_prop::CssTakers::new(
&parsed.program,
crate::css_prop::CssProp::Off,
"@devup-ui/react/compat",
);
// When: classify whether the ordinary shadow may change data.
let uses = crate::mutations::uses(&parsed.program, &|name| name == "ClassNames", Some(&takers));
// Then: data escapes to runtime code; it must not be treated as compile-only use.
assert_eq!(
uses.get("data").is_some_and(|uses| uses
.iter()
.any(|usage| matches!(usage, crate::mutations::Use::Escapes { .. }))),
escapes,
"{body}\n{uses:?}"
);
}
102 changes: 102 additions & 0 deletions libs/extractor/src/class_names_nested_binding_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
use super::{compile, execute, styles};
use rstest::rstest;
use serial_test::serial;

#[rstest]
#[case(
"css:outerCss",
"theme:innerTheme",
"outerCss({color:innerTheme.brand})"
)]
#[case(
"theme:outerTheme",
"css:innerCss",
"innerCss({color:outerTheme.brand})"
)]
#[case(
"css:outerCss",
"theme:innerTheme",
"outerCss`color:${innerTheme.brand};`"
)]
#[case(
"theme:outerTheme",
"css:innerCss",
"innerCss`color:${outerTheme.brand};`"
)]
#[serial]
fn class_names_nested_binding_crosses_callback_when_api_and_theme_have_different_owners(
#[case] outer: &str,
#[case] inner: &str,
#[case] call: &str,
) {
// Given: genuine APIs and themes belong to different active callback bindings.
let source = format!(
"import {{ClassNames as Names}} from '@emotion/react';const App=()=> <Names>{{({{{outer}}})=><Names>{{({{{inner}}})=>({{styled:!!({call})}})}}</Names>}}</Names>;"
);
// When: compile and execute the emitted render function.
let output = compile(&source);
let value = execute(&output);
// Then: both wrappers/APIs disappear and the crossing theme becomes a CSS variable.
assert_eq!(styles(&output), [("color", "var(--brand)")]);
assert_eq!(value, r#"{"styled":true}"#);
assert!(!output.code.contains("Names"), "{}", output.code);
}

#[test]
#[serial]
fn class_names_nested_binding_keeps_ordinary_shadows_when_both_themes_cross() {
// Given: both callbacks contribute APIs/themes alongside independent ordinary bindings.
let source = "import {ClassNames} from '@emotion/react';const App=()=> <ClassNames>{({css:outerCss,theme:outerTheme})=><ClassNames>{({css:innerCss,cx:innerCx,theme:innerTheme})=>({title:((innerTheme)=>innerTheme.brand)({brand:'kept'}),local:((outerCss,innerCx)=>[outerCss({padding:[1,2]}),innerCx({local:2})])(v=>v,v=>v),styled:!!innerCx({picked:true},outerCss({color:innerTheme.inner}),innerCss({backgroundColor:outerTheme.outer}))})}</ClassNames>}</ClassNames>;";
// When: compile and execute the combined crossing/shadow case.
let output = compile(source);
let value = execute(&output);
// Then: genuine crossed styles compile while ordinary values retain their exact behavior.
assert_eq!(
styles(&output),
[
("background-color", "var(--outer)"),
("color", "var(--inner)")
]
);
assert_eq!(
value,
r#"{"title":"kept","local":[{"padding":[1,2]},{"local":2}],"styled":true}"#
);
}

#[test]
#[serial]
fn class_names_nested_binding_prepares_all_themes_when_one_call_reads_multiple_callbacks() {
// Given: one real API reads two active themes through a nested ordinary function.
let source = "import {ClassNames} from '@emotion/react';const App=()=> <ClassNames>{({css,theme:outer})=><ClassNames>{({theme:inner})=>({styled:!!(()=>css({color:inner.brand,backgroundColor:outer.brand}))()})}</ClassNames>}</ClassNames>;";
// When: compile and execute the captured real API call.
let output = compile(source);
let value = execute(&output);
// Then: neither active theme remains an unsupported read or runtime dependency.
assert_eq!(
styles(&output),
[
("background-color", "var(--brand)"),
("color", "var(--brand)")
]
);
assert_eq!(value, r#"{"styled":true}"#);
}

#[test]
#[serial]
fn class_names_nested_binding_keeps_component_shadow_when_preparing_genuine_outer_calls() {
// Given: a genuine outer API is captured inside an ordinary same-spelling component.
let source = "import {ClassNames as Names} from '@emotion/react';const App=()=> <Names>{({css:outerCss})=>((Names)=><Names marker={7}>{({css,theme})=>({local:css({padding:[1,2]}),theme:theme.brand,styled:outerCss({color:'blue'})})}</Names>)(Local)}</Names>;";
// When: prepare and compile the real outer wrapper.
let output = compile(source);
// Then: only the actual API emits styles; ordinary component callbacks stay intact.
assert_eq!(styles(&output), [("color", "blue")]);
assert!(
output.code.contains("<Names marker={7}>"),
"{}",
output.code
);
assert!(output.code.contains("padding: [1, 2]"), "{}", output.code);
assert!(output.code.contains("theme.brand"), "{}", output.code);
}
Loading
Loading