Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0faa84e
fix(plugin-utils): compile the css prop where tsconfig builds JSX wit…
owjs3901 Oct 2, 2026
1a6a31e
fix(extractor): compile Emotion's ClassNames at build time
owjs3901 Oct 2, 2026
4f88bd4
test(extractor): cover ClassNames child functions taking rest or nest…
owjs3901 Oct 2, 2026
d44950f
fix(extractor): compile Emotion component selectors at build time
owjs3901 Oct 3, 2026
9f94748
merge: update PR #728 with main
owjs3901 Oct 8, 2026
7a8d96b
merge: update PR #729 with PR #728
owjs3901 Oct 8, 2026
a355130
merge: update PR #730 with PR #729
owjs3901 Oct 8, 2026
3e6c24c
fix(plugins): merge updated css prop parent into project JSX support
owjs3901 Oct 15, 2026
4aa0b56
fix(compat): merge updated JSX parent and unify class argument types
owjs3901 Oct 15, 2026
b28816c
fix(extractor): preserve inherited selector identities with css compo…
owjs3901 Oct 15, 2026
716b484
Merge commit '76a1796a4c2276a2e9865b49bdea518dd485d5c8' into HEAD
owjs3901 Oct 16, 2026
744b145
merge: update PR #729 with PR #728
owjs3901 Oct 16, 2026
291f1fd
merge: update PR #730 with PR #729
owjs3901 Oct 16, 2026
b312482
test(extractor): cover unsupported numeric unary class guard
owjs3901 Oct 16, 2026
c3bfe03
Merge commit 'b312482b45f8d08bdb262eb20325ad051f980d85' into HEAD
owjs3901 Oct 16, 2026
4fb846f
merge: update PR #728 with PR #727
owjs3901 Oct 19, 2026
9794828
merge: update PR #728 with settled main
owjs3901 Oct 19, 2026
ba1b044
merge: update PR #729 with PR #728
owjs3901 Oct 19, 2026
18e496f
merge: update PR #730 with PR #729
owjs3901 Oct 19, 2026
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"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_emotion_component_selectors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Emotion and styled-components component selectors now compile at build time: a styled component the file defines that another style selects (${Child} { ... } in CSS text, [Child] or [`&:hover ${Child}`] as a rule key) gets a short marker class, which only such components get, and the selector reads it as .marker; a component extending it, or an element inlining it for its css prop, keeps the marker. Selectors written without & now nest under the component as stylis nests them (ul { ... } is & ul, a selector list nests each selector, :hover applies to the component itself) instead of becoming global rules, and rule-object keys starting with ., #, [, *, >, + or ~ are read as nested selectors instead of properties",
"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"
}
38 changes: 38 additions & 0 deletions libs/css/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,25 @@ pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> S
})
}

/// The class marking the component `name` defined in `filename`, which other
/// styles select it by
#[must_use]
pub fn component_marker(name: &str, filename: &str) -> String {
with_prefix(|prefix| {
if is_debug() {
format!("{prefix}c-{name}")
} else {
let class_num = class_num_for_key("", |key| {
key.push_str("c-");
key.push_str(filename);
key.push('-');
key.push_str(name);
});
format!("{prefix}{class_num}")
}
})
}

/// ASCII lookup table for selector encoding. `None` means pass through (alphanumeric, `-`, `_`)
/// or fall through to the Unicode escape path.
const SELECTOR_ENCODE: [Option<&str>; 128] = {
Expand Down Expand Up @@ -835,6 +854,25 @@ mod tests {
);
}

#[test]
#[serial]
fn test_component_marker() {
set_debug(false);
reset_class_map();
let child = component_marker("Child", "a.tsx");
assert_eq!(child, "a");
assert_eq!(component_marker("Child", "a.tsx"), child);
assert_eq!(component_marker("Child", "b.tsx"), "b");
assert_eq!(component_marker("Other", "a.tsx"), "c");
assert_eq!(
sheet_to_classname("color", 0, Some("red"), None, None, None),
"d"
);
set_debug(true);
assert_eq!(component_marker("Child", "a.tsx"), "c-Child");
set_debug(false);
}

#[test]
#[serial]
fn test_sheet_to_classname() {
Expand Down
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:?}"
);
}
Loading
Loading