diff --git a/.changepacks/changepack_log_class_names_bindings.json b/.changepacks/changepack_log_class_names_bindings.json new file mode 100644 index 000000000..4b4069705 --- /dev/null +++ b/.changepacks/changepack_log_class_names_bindings.json @@ -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" +} diff --git a/.changepacks/changepack_log_emotion_class_names.json b/.changepacks/changepack_log_emotion_class_names.json new file mode 100644 index 000000000..9317e2ecf --- /dev/null +++ b/.changepacks/changepack_log_emotion_class_names.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Emotion's 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 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" +} diff --git a/.changepacks/changepack_log_emotion_component_selectors.json b/.changepacks/changepack_log_emotion_component_selectors.json new file mode 100644 index 000000000..98c3f7259 --- /dev/null +++ b/.changepacks/changepack_log_emotion_component_selectors.json @@ -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" +} diff --git a/.changepacks/changepack_log_emotion_jsx_import_source.json b/.changepacks/changepack_log_emotion_jsx_import_source.json new file mode 100644 index 000000000..ceb232995 --- /dev/null +++ b/.changepacks/changepack_log_emotion_jsx_import_source.json @@ -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" +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 328a6646a..e25266677 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -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] = { @@ -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() { diff --git a/libs/extractor/src/class_names_binding_tests.rs b/libs/extractor/src/class_names_binding_tests.rs new file mode 100644 index 000000000..41f50ee3b --- /dev/null +++ b/libs/extractor/src/class_names_binding_tests.rs @@ -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=()=> {{({{css}})=> ({{title:{title},trace,className:(()=>css({{color:'blue'}}))()}})}};" + ); + // 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=()=> {({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')})};", + ); + // 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=()=> {({css,theme})=> ({local:((theme)=>theme.spacing(2))({spacing:x=>x+5}),className:(()=>css({color:theme.colors.text}))()})};", + ); + // 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=()=> {({css})=> ({local:((css)=>css(data))(x=>x),same:((css)=>css(data))(x=>x)===data,className:css({padding:space})})};", + ); + // 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=()=> {({css})=> ({local:((css)=>css({padding:[1,2]}))(x=>x),className:css({color:'blue'})})};", + ); + // 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=()=> {({css})=> ((css)=>css(data))(x=>x)};", + ); + // 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::::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=>{({css})=>css({padding:[1,2]})};", + ); + // 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(""), + "{}", + output.code + ); + assert!(output.code.contains("padding: [1, 2]"), "{}", output.code); + assert_eq!(output.styles.len(), 0); +} + +#[rstest] +#[case( + "const App=()=> {({css})=>((css)=>css(data))(mutate)};", + true +)] +#[case( + "const App=ClassNames=> {({css})=>css({color:'blue'})};", + true +)] +#[case("const App=()=> {({css})=>css(data)};", false)] +#[case( + "const App=()=> {({css:style})=>(()=>style(data))()};", + false +)] +#[case( + "const App=()=> {({css})=>css`${data}`};", + 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:?}" + ); +} diff --git a/libs/extractor/src/class_names_nested_binding_tests.rs b/libs/extractor/src/class_names_nested_binding_tests.rs new file mode 100644 index 000000000..a82eac0a6 --- /dev/null +++ b/libs/extractor/src/class_names_nested_binding_tests.rs @@ -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=()=> {{({{{outer}}})=>{{({{{inner}}})=>({{styled:!!({call})}})}}}};" + ); + // 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=()=> {({css:outerCss,theme:outerTheme})=>{({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}))})}};"; + // 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=()=> {({css,theme:outer})=>{({theme:inner})=>({styled:!!(()=>css({color:inner.brand,backgroundColor:outer.brand}))()})}};"; + // 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=()=> {({css:outerCss})=>((Names)=>{({css,theme})=>({local:css({padding:[1,2]}),theme:theme.brand,styled:outerCss({color:'blue'})})})(Local)};"; + // 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(""), + "{}", + output.code + ); + assert!(output.code.contains("padding: [1, 2]"), "{}", output.code); + assert!(output.code.contains("theme.brand"), "{}", output.code); +} diff --git a/libs/extractor/src/class_names_numeric_tests.rs b/libs/extractor/src/class_names_numeric_tests.rs new file mode 100644 index 000000000..83b5d9a02 --- /dev/null +++ b/libs/extractor/src/class_names_numeric_tests.rs @@ -0,0 +1,180 @@ +use crate::{ExtractOption, ExtractOutput, ImportAlias, extract}; +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; + +fn compile(expression: &str) -> Result { + reset_class_map(); + reset_file_map(); + extract( + "numeric.tsx", + &format!( + "import {{ ClassNames }} from '@emotion/react';const App = on => {{({{css,cx}}) => {expression}}};" + ), + ExtractOption { + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + ) + .map_err(|error| error.to_string()) +} + +fn execute(code: &str) -> String { + let arena = Allocator::default(); + let mut parsed = Parser::new(&arena, code, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{code}"); + parsed + .program + .body + .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + let code = Codegen::new().build(&parsed.program).code; + let script = format!( + "{code}\nJSON.stringify([App(true), App(false)].map(classes => classes.split(/\\s+/).filter(Boolean).join(' ')));" + ); + Context::default() + .eval(Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("{script}\n{error}")) + .as_string() + .unwrap_or_else(|| panic!("expected serialized class tokens from {script}")) + .to_std_string_escaped() +} + +#[rstest] +#[case("cx(1)", r#"["1","1"]"#)] +#[case("cx(0)", r#"["",""]"#)] +#[case("cx(['caller',[1,0,[2.5]]])", r#"["caller 1 2.5","caller 1 2.5"]"#)] +#[case("cx(1e21,1e-7,0x10)", r#"["1e+21 1e-7 16","1e+21 1e-7 16"]"#)] +#[case("cx(on ? 1 : 0)", r#"["1",""]"#)] +#[case("cx(on ? [0,2] : [3,0])", r#"["2","3"]"#)] +#[case("cx(on && 4)", r#"["4",""]"#)] +#[case("cx(on && 0)", r#"["",""]"#)] +#[case("cx(0 || 5)", r#"["5","5"]"#)] +#[case("cx(6 || 7)", r#"["6","6"]"#)] +#[case("cx(0 ?? 8)", r#"["",""]"#)] +#[case("cx(null ?? 9)", r#"["9","9"]"#)] +#[case("cx(-1)", r#"["-1","-1"]"#)] +#[case("cx(-0)", r#"["",""]"#)] +#[case("cx(+2)", r#"["2","2"]"#)] +#[case("cx(+0)", r#"["",""]"#)] +#[case("cx(['caller',[-1,-0,[+2]]])", r#"["caller -1 2","caller -1 2"]"#)] +#[case("cx(on ? -1 : +2)", r#"["-1","2"]"#)] +#[case("cx(on ? [-0,+2] : [-1,+0])", r#"["2","-1"]"#)] +#[case("cx(on && -1)", r#"["-1",""]"#)] +#[case("cx(on && -0)", r#"["",""]"#)] +#[case("cx(-0 || +2)", r#"["2","2"]"#)] +#[case("cx(-0 ?? +2)", r#"["",""]"#)] +#[case("cx(-1 || +2)", r#"["-1","-1"]"#)] +#[serial] +fn class_names_numeric_classes_execute_when_static( + #[case] expression: &str, + #[case] expected: &str, +) { + // Given: static numeric class leaves, with runtime branch controllers where relevant. + let output = compile(expression).unwrap_or_else(|error| panic!("{expression}\n{error}")); + // When: execute the actual emitted render function for both branch states. + let result = execute(&output.code); + // Then: nonzero numbers are class text and zero contributes no class. + assert_eq!(result, expected); + assert_eq!(output.styles.len(), 0); +} + +#[test] +#[serial] +fn class_names_numeric_map_conditions_keep_truthiness() { + // Given: numbers are conditions, not classes, inside class-map values. + let output = compile("cx({ kept: 2, omitted: 0, chosen: on })") + .unwrap_or_else(|error| panic!("{error}")); + // When: execute both runtime choices. + let result = execute(&output.code); + // Then: keys, not the numeric condition values, become classes. + assert_eq!(result, r#"["kept chosen","kept"]"#); + assert_eq!(output.styles.len(), 0); +} + +#[rstest] +#[case("cx(css({padding:[1,2],opacity:[0,0.5]}),'caller')")] +#[case("cx([css({padding:[1,2],opacity:[0,0.5]}),['caller']])")] +#[case("cx(css({padding:[1,2],opacity:[0,0.5]}),'caller',1,0)")] +#[serial] +fn class_names_numeric_rules_keep_fallbacks(#[case] expression: &str) { + // Given: numeric CSS fallback arrays alongside ordinary class arguments. + let output = compile(expression).unwrap_or_else(|error| panic!("{expression}\n{error}")); + let mut styles: Vec<_> = output + .styles + .iter() + .map(|style| match style { + crate::extract_style::extract_style_value::ExtractStyleValue::Static(style) => { + (style.property.as_str(), style.value.as_str(), style.level) + } + other => panic!("unexpected style: {other:?}"), + }) + .collect(); + styles.sort_unstable(); + // When: execute the emitted class expression and inspect the extracted declarations. + let result = execute(&output.code); + // Then: numeric rules retain px/unitless fallback semantics at breakpoint zero. + assert!(result.contains("caller"), "{result}"); + assert_eq!( + styles, + [ + ("opacity", "0;opacity:.5", 0), + ("padding", "1px;padding:2px", 0) + ] + ); +} + +#[rstest] +#[case("cx(getClass(),1)")] +#[case("cx([1,[getClass()]])")] +#[case("cx(on ? 1 : getClass())")] +#[serial] +fn class_names_numeric_arguments_keep_unknown_call_guards(#[case] expression: &str) { + // Given: a numeric class does not make an unknown call statically readable. + // When: extract a class expression containing such a call. + let error = compile(expression) + .err() + .unwrap_or_else(|| panic!("expected unknown-call rejection for {expression}")); + // Then: the existing located build-time rejection is retained. + assert!(error.starts_with("numeric.tsx:1:"), "{error}"); + assert!(error.contains("getClass()"), "{error}"); + assert!(error.contains("at build time"), "{error}"); +} + +#[rstest] +#[case("cx(+'2')")] +#[case("cx(-'2')")] +#[case("cx(+true)")] +#[case("cx(-false)")] +#[case("cx(~1)")] +#[case("cx(-on)")] +#[case("cx(+on)")] +#[case("cx(-getClass())")] +#[case("cx(+getClass())")] +#[case("cx(1 + 2)")] +#[case("cx(-(1 + 2))")] +#[case("cx(-(-1))")] +#[case("cx([+2,[-getClass()]])")] +#[case("cx(on ? -1 : +getClass())")] +#[serial] +fn class_names_numeric_arguments_reject_nonliteral_operands(#[case] expression: &str) { + // Given: only exact signed numeric literal leaves are authorized. + // When: extract a coercion, computed operand, or unknown call. + let error = compile(expression) + .err() + .unwrap_or_else(|| panic!("expected nonliteral rejection for {expression}")); + // Then: the existing located build-time rejection remains in force. + assert!(error.starts_with("numeric.tsx:1:"), "{error}"); + assert!(error.contains("at build time"), "{error}"); +} diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs index d8cead515..be1213ee2 100644 --- a/libs/extractor/src/css_prop.rs +++ b/libs/extractor/src/css_prop.rs @@ -7,13 +7,15 @@ use std::collections::HashMap; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::ast::{ Argument, ArrowFunctionBody, BinaryOperator, BindingPattern, CallExpression, Expression, - FormalParameters, FunctionBody, ImportDeclarationSpecifier, JSXAttributeItem, JSXElementName, - ObjectPropertyKind, Program, Statement, Str, TemplateElement, TemplateElementValue, - TemplateLiteral, + FormalParameters, FunctionBody, ImportDeclarationSpecifier, JSXAttributeItem, JSXChild, + JSXElement, JSXElementName, ObjectPropertyKind, Program, Statement, Str, TemplateElement, + TemplateElementValue, TemplateLiteral, }; use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{VisitMut, walk_mut}; +use oxc_semantic::Scoping; use oxc_span::SPAN; +use oxc_syntax::symbol::SymbolId; use rustc_hash::FxHashSet; use crate::ImportAlias; @@ -23,9 +25,31 @@ use crate::utils::{ unwrap_syntax_only, }; +type Resolve<'r> = &'r dyn Fn(&oxc_ast::ast::IdentifierReference<'_>) -> Option; + /// The package whose alias makes elements take the `css` prop pub(crate) const EMOTION_REACT: &str = "@emotion/react"; +/// Emotion's JSX runtime, whose alias tells that the project builds JSX with +/// it, as a `jsxImportSource` of `@emotion/react` in tsconfig does +pub(crate) const EMOTION_JSX_RUNTIME: &str = "@emotion/react/jsx-runtime"; + +/// The pragma building a file's JSX with React, as its `css` props compile +pub(crate) const REACT_JSX_PRAGMA: &str = "/** @jsxImportSource react */\n"; + +/// Whether the project builds JSX with Emotion's runtime, which then builds it +/// with React's as every `css` prop compiles +pub(crate) fn builds_jsx_with_emotion(import_aliases: &HashMap) -> bool { + import_aliases.contains_key(EMOTION_REACT) && import_aliases.contains_key(EMOTION_JSX_RUNTIME) +} + +/// Whether `filename` is written in JSX, which the project's JSX settings build +pub(crate) fn is_jsx_file(filename: &str) -> bool { + filename + .rsplit_once('.') + .is_some_and(|(_, extension)| matches!(extension, "tsx" | "jsx")) +} + /// Which elements of a file take Emotion's `css` prop #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum CssProp { @@ -87,6 +111,8 @@ pub(crate) struct CssTakers<'s> { pub css_prop: CssProp, /// Local names of the functions building elements from a type and props jsx: FxHashSet<&'s str>, + /// Local names of Emotion's `ClassNames` + class_names: FxHashSet<&'s str>, } impl<'s> CssTakers<'s> { @@ -94,18 +120,53 @@ impl<'s> CssTakers<'s> { /// `compat` is the entry absorbing Emotion's own `jsx` pub(crate) fn new(program: &Program<'s>, css_prop: CssProp, compat: &str) -> Self { let mut jsx = FxHashSet::default(); + let mut class_names = FxHashSet::default(); for statement in &program.body { if let Statement::ImportDeclaration(import) = statement { + let source = import.source.value.as_str(); for specifier in import.specifiers.iter().flatten() { - if let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier - && is_jsx_function(&import.source.value, &specifier.imported.name(), compat) - { + let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else { + continue; + }; + let imported = specifier.imported.name(); + if is_jsx_function(source, &imported, compat) { jsx.insert(specifier.local.name.as_str()); + } else if imported == "ClassNames" + && (source == compat || source == EMOTION_REACT) + { + class_names.insert(specifier.local.name.as_str()); } } } } - Self { css_prop, jsx } + Self { + css_prop, + jsx, + class_names, + } + } + + pub(crate) fn has_class_names(&self) -> bool { + !self.class_names.is_empty() + } + + /// The child callback's API bindings, only when the element reads the import. + pub(crate) fn class_names_calls( + &self, + element: &JSXElement<'_>, + scoping: &Scoping, + ) -> Vec { + match &element.opening_element.name { + JSXElementName::IdentifierReference(name) + if self.class_names.contains(name.name.as_str()) + && binding_of(scoping, name).is_some_and(|symbol| { + scoping.get_root_binding(name.name.as_str().into()) == Some(symbol) + }) => + { + class_names_calls(element) + } + _ => Vec::new(), + } } /// Whether `attribute` of the element `name` is a `css` prop it takes; @@ -256,6 +317,7 @@ pub(crate) fn theme_rules<'a>( let mut reads = ThemeReads { ast, roots: &roots, + binding: None, unread: None, }; reads.visit(&mut rules, false); @@ -273,6 +335,144 @@ pub(crate) fn returned<'b, 'a>(body: &'b FunctionBody<'a>) -> Option<&'b Express } } +fn returned_mut<'b, 'a>(body: &'b mut FunctionBody<'a>) -> Option<&'b mut Expression<'a>> { + match body.statements.as_mut_slice() { + [Statement::ReturnStatement(statement)] => statement.argument.as_mut(), + _ => None, + } +} + +/// The parameters of `function` and what it gives at once, when it is a +/// plain function giving one value +pub(crate) fn render_function<'b, 'a>( + function: &'b mut Expression<'a>, +) -> Option<(&'b FormalParameters<'a>, &'b mut Expression<'a>)> { + match function { + Expression::ArrowFunctionExpression(arrow) if !arrow.r#async => { + let arrow = &mut **arrow; + let body = match &mut arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned_mut(body)?, + body => body.as_expression_mut()?, + }; + Some((&arrow.params, body)) + } + Expression::FunctionExpression(function) if !function.r#async && !function.generator => { + let function = &mut **function; + Some(( + &function.params, + returned_mut(function.body.as_deref_mut()?)?, + )) + } + _ => None, + } +} + +/// The lexical bindings of a `` child function's APIs and theme. +#[derive(Default, Clone, Copy)] +pub(crate) struct ClassNamesParams { + pub css: Option, + pub cx: Option, + pub theme: Option, +} + +impl ClassNamesParams { + pub(crate) fn is(slot: Option, symbol: Option) -> bool { + slot.is_some() && slot == symbol + } +} + +pub(crate) fn binding_of( + scoping: &Scoping, + identifier: &oxc_ast::ast::IdentifierReference<'_>, +) -> Option { + scoping + .get_reference(identifier.reference_id.get()?) + .symbol_id() +} + +/// What `params` take of `{ css, cx, theme }`; `None` when they take it in a +/// way the build cannot follow +pub(crate) fn class_names_params(params: &FormalParameters<'_>) -> Option { + let mut names = ClassNamesParams::default(); + if params.rest.is_some() { + return None; + } + match params.items.as_slice() { + [] => {} + [param] if param.initializer.is_none() => { + let BindingPattern::ObjectPattern(object) = ¶m.pattern else { + return None; + }; + if object.rest.is_some() { + return None; + } + for property in &object.properties { + let (BindingPattern::BindingIdentifier(local), Some(key), false) = ( + &property.value, + property.key.static_name(), + property.computed, + ) else { + return None; + }; + let slot = match key.as_ref() { + "css" => &mut names.css, + "cx" => &mut names.cx, + "theme" => &mut names.theme, + _ => return None, + }; + *slot = local.symbol_id.get(); + } + } + _ => return None, + } + Some(names) +} + +/// What the child function of the `` element `element` takes +pub(crate) fn class_names_child(element: &JSXElement<'_>) -> Option { + let mut children = element + .children + .iter() + .filter(|child| !matches!(child, JSXChild::Text(text) if text.value.trim().is_empty())); + let (Some(JSXChild::ExpressionContainer(container)), None) = (children.next(), children.next()) + else { + return None; + }; + let params = match container.expression.as_expression()? { + Expression::ArrowFunctionExpression(arrow) => &arrow.params, + Expression::FunctionExpression(function) => &function.params, + _ => return None, + }; + class_names_params(params) +} + +/// The child function's `css` and `cx` bindings, which read styles. +pub(crate) fn class_names_calls(element: &JSXElement<'_>) -> Vec { + class_names_child(element) + .map(|names| [names.css, names.cx].into_iter().flatten().collect()) + .unwrap_or_default() +} + +/// Write the reads of the active themes in `expression`, a CSS value +/// when `value`, as the CSS variables the `ThemeProvider` sets; `Err` holds +/// the first read the build cannot write so +pub(crate) fn read_theme<'a>( + ast: &AstBuilder<'a>, + expression: &mut Expression<'a>, + names: &[ClassNamesParams], + value: bool, + resolve: Resolve<'_>, +) -> Result<(), Expression<'a>> { + let mut reads = ThemeReads { + ast, + roots: &[], + binding: Some((names, resolve)), + unread: None, + }; + reads.visit(expression, value); + reads.unread.map_or(Ok(()), Err) +} + /// The names a function of the theme reads it by, each with the key of the /// theme it holds when destructured; `None` for parameters the build cannot /// follow @@ -311,6 +511,7 @@ fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option { ast: &'r AstBuilder<'a>, roots: &'r [(&'a str, Option)], + binding: Option<(&'r [ClassNamesParams], Resolve<'r>)>, unread: Option>, } @@ -347,11 +548,21 @@ impl<'a> ThemeReads<'_, 'a> { } Expression::ParenthesizedExpression(inner) => cursor = &inner.expression, Expression::Identifier(identifier) => { - let (_, key) = self - .roots - .iter() - .find(|(local, _)| *local == identifier.name.as_str())?; - path.extend(key.clone()); + if let Some((names, resolve)) = self.binding { + let symbol = resolve(identifier); + if !names + .iter() + .any(|names| ClassNamesParams::is(names.theme, symbol)) + { + return None; + } + } else { + let (_, key) = self + .roots + .iter() + .find(|(local, _)| *local == identifier.name.as_str())?; + path.extend(key.clone()); + } path.reverse(); return Some(if exact && !path.is_empty() { ThemeRead::Path(path) @@ -610,6 +821,17 @@ mod tests { ); assert_eq!(react_runtime("@emotion/react"), None); + assert!(is_jsx_file("src/App.tsx")); + assert!(is_jsx_file("App.jsx")); + assert!(!is_jsx_file("App.ts")); + assert!(!is_jsx_file("Makefile")); + + let mut aliases = + HashMap::from([(EMOTION_JSX_RUNTIME.to_string(), ImportAlias::NamedToNamed)]); + assert!(!builds_jsx_with_emotion(&aliases)); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + assert!(builds_jsx_with_emotion(&aliases)); + let compat = "@devup-ui/react/compat"; assert!(is_jsx_function("react/jsx-runtime", "jsxs", compat)); assert!(is_jsx_function( diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index 6ec1ec89e..15b1ebafc 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -668,12 +668,17 @@ fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option String { + split_selector_list(selectors) .into_iter() .map(relative_selector) .collect::>() - .join(","); - Some(StyleSelector::nest_selector(parent, &template)) + .join(",") } /// A selector of a nested rule made relative to the rule it is in, as @@ -1708,6 +1713,15 @@ mod tests { ("background", "blue", Some(StyleSelector::Selector("& div".to_string()))), ] )] + // A selector written without `&` selects within the element, each one of + // a list on its own, and a pseudo-class applies to the element itself + #[case( + "a, :is(b, c) d { color: red; } :hover { color: blue; }", + vec![ + ("color", "red", Some(StyleSelector::Selector("& a,&:is(b,c) d".to_string()))), + ("color", "blue", Some(StyleSelector::Selector("&:hover".to_string()))), + ] + )] // As in CSS nesting, only the text after the last `;` is the nested rule's // selector; everything before it is declarations. #[case( diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 94fcb9dab..01e3a0f14 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -1341,9 +1341,10 @@ fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Nested { } /// Emotion/styled-components style object keys that are nested selectors -/// (`'&:hover'`, `':hover'`, `'.parent &'`) rather than CSS properties. -fn is_nested_selector_key(key: &str) -> bool { - key.starts_with(':') || key.contains('&') +/// (`'&:hover'`, `':hover'`, `'.parent &'`, `'.child'`, `'> li'`) rather +/// than CSS properties. +pub(crate) fn is_nested_selector_key(key: &str) -> bool { + key.starts_with([':', '.', '#', '[', '*', '>', '+', '~']) || key.contains('&') } /// A key without `&` is relative to the element: a pseudo-class, a diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 547a551db..fc436b2ea 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -67,10 +67,32 @@ pub struct StyledDefinition<'a> { reads: Reads, /// Its `shouldForwardProp`, as the build evaluates it forward: Option, + /// The class other styles select it by, which a component extending it + /// gives too, as it renders this one + marker: Option, + inherited_markers: Vec, bindings: Vec<(String, Expression<'a>)>, css_slot: Option, } +/// `marker` as a class among those a component gives +fn marker_class<'a>(ast_builder: &AstBuilder<'a>, marker: &str) -> Expression<'a> { + Expression::new_string_literal( + SPAN, + Str::from_in(marker, ast_builder.allocator()), + None, + ast_builder, + ) +} + +/// How the classes of a styled component are named: the file its atoms are +/// split into, and the class other styles select it by +#[derive(Clone, Copy, Default)] +pub struct Naming<'s> { + pub split_filename: Option<&'s str>, + pub marker: Option<&'s str>, +} + /// Whether a styled component renders a tag, which takes only valid /// attributes, rather than a component fn renders_tag(name: &str, bound: Option<&Expression<'_>>) -> bool { @@ -146,6 +168,8 @@ impl<'a> StyledDefinition<'a> { attrs: clone_all(builder, &self.attrs).collect(), reads: self.reads.clone(), forward: self.forward.clone(), + marker: self.marker.clone(), + inherited_markers: self.inherited_markers.clone(), css_slot: self.css_slot.clone(), bindings: self .bindings @@ -258,6 +282,37 @@ impl<'a> StyledDefinition<'a> { self.css_slot.as_ref() } + /// The class other styles select the component by + #[must_use] + pub fn marker(&self) -> Option<&str> { + self.marker.as_deref() + } + + fn with_markers(mut self, marker: Option<&str>, inherited: Option<&Self>) -> Self { + self.marker = marker.map(str::to_string); + self.inherited_markers = inherited + .into_iter() + .flat_map(|definition| { + definition + .marker() + .into_iter() + .chain(definition.inherited_markers.iter().map(String::as_str)) + }) + .map(str::to_string) + .collect(); + self + } + + pub(crate) fn marker_classes(&self, ast: &AstBuilder<'a>) -> Option> { + merge_expression_for_class_name( + ast, + self.marker() + .into_iter() + .chain(self.inherited_markers.iter().map(String::as_str)) + .map(|marker| marker_class(ast, marker)), + ) + } + /// The styles the component gives what it renders #[must_use] pub fn styles(&self) -> &[ExtractStyleProp<'a>] { @@ -281,7 +336,7 @@ pub fn with_component<'a>( ast_builder: &AstBuilder<'a>, definition: &StyledDefinition<'a>, target: &Expression<'a>, - split_filename: Option<&str>, + naming: Naming<'_>, context: StyledContext<'_>, ) -> Option<(Expression<'a>, StyledDefinition<'a>)> { let name = match unwrap_syntax_only(target) { @@ -310,7 +365,7 @@ pub fn with_component<'a>( ast_builder, &mut styles, None, - split_filename, + naming.split_filename, )), ); let withheld = withheld( @@ -327,7 +382,8 @@ pub fn with_component<'a>( (definition.reads.clone(), definition.forward.clone()), &definition.bindings, ) - .with_css_slot(context.css_slot); + .with_css_slot(context.css_slot) + .with_markers(naming.marker, Some(&definition)); let component = create_styled_component( ast_builder, &base, @@ -341,6 +397,7 @@ pub fn with_component<'a>( .collect(), withheld: &withheld, css_slot: new_definition.css_slot(), + markers: new_definition.marker_classes(ast_builder), }, context.jsx, ); @@ -397,11 +454,7 @@ fn inherited_parts<'a>( || (Vec::new(), Vec::new()), |inherited| { ( - inherited - .classes - .iter() - .map(|c| c.clone_in(allocator)) - .collect(), + clone_all(ast_builder, &inherited.classes).collect(), inherited .attrs .iter() @@ -465,6 +518,8 @@ impl<'a> Base<'a> { attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(), reads, forward, + marker: None, + inherited_markers: Vec::new(), css_slot: None, bindings: bindings .iter() @@ -640,12 +695,16 @@ fn resolve_styled_call_target<'a>( pub fn extract_style_from_styled<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, - split_filename: Option<&str>, + naming: Naming<'_>, imports: &FxHashMap, modifiers: (&[Expression<'a>], Option), inherited: Option<&StyledDefinition<'a>>, context: StyledContext<'_>, ) -> StyledExtraction<'a> { + let Naming { + split_filename, + marker, + } = naming; let (attrs, forward) = modifiers; let mut original_scope = expression.clone_in(ast_builder.allocator()); let inherited = inherited.map(|definition| definition.rebind(ast_builder, &mut original_scope)); @@ -791,7 +850,8 @@ pub fn extract_style_from_styled<'a>( (reads, forward), &bindings, ) - .with_css_slot(context.css_slot); + .with_css_slot(context.css_slot) + .with_markers(marker, inherited); let component = create_styled_component( ast_builder, &base, @@ -801,6 +861,7 @@ pub fn extract_style_from_styled<'a>( bindings, withheld: &withheld, css_slot: definition.css_slot(), + markers: definition.marker_classes(ast_builder), }, context.jsx, ); @@ -904,7 +965,8 @@ pub fn extract_style_from_styled<'a>( (reads, forward), &bindings, ) - .with_css_slot(context.css_slot); + .with_css_slot(context.css_slot) + .with_markers(marker, inherited); let component = create_styled_component( ast_builder, &base, @@ -914,6 +976,7 @@ pub fn extract_style_from_styled<'a>( bindings, withheld: &withheld, css_slot: definition.css_slot(), + markers: definition.marker_classes(ast_builder), }, context.jsx, ); @@ -1489,6 +1552,7 @@ struct StyledRender<'b, 'a> { bindings: Vec<(String, Expression<'a>)>, withheld: &'b [String], css_slot: Option<&'b StyledCssSlot>, + markers: Option>, } fn create_styled_component<'a>( @@ -1503,6 +1567,7 @@ fn create_styled_component<'a>( bindings, withheld, css_slot, + markers, } = render; let params = FormalParameters::boxed( SPAN, @@ -1602,7 +1667,16 @@ fn create_styled_component<'a>( ast_builder, ); let replacement = css_slot.map(|slot| slot.classes(ast_builder, class_name.as_ref())); - let class_name = replacement.as_ref().or(class_name.as_ref()).map_or_else( + let own_classes = merge_expression_for_class_name( + ast_builder, + markers.into_iter().chain( + replacement + .as_ref() + .or(class_name.as_ref()) + .map(|classes| classes.clone_in(ast_builder.allocator())), + ), + ); + let class_name = own_classes.as_ref().map_or_else( || Expression::new_identifier(SPAN, "className", ast_builder), |name| { wrap_array_filter( diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 12edf0526..dff94ddf3 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -9,7 +9,8 @@ use crate::ImportAlias; use crate::css_prop::{ - CssProp, EMOTION_REACT, is_emotion, is_jsx_function, react_runtime, returned, + CssProp, EMOTION_REACT, builds_jsx_with_emotion, class_names_child, is_emotion, is_jsx_file, + is_jsx_function, react_runtime, returned, }; use crate::utils::{ get_str_by_property_key, is_vanilla_extract_file, js_number_literal, keeps_bare_number, @@ -17,12 +18,12 @@ use crate::utils::{ use oxc_allocator::Allocator; use oxc_ast::ast::{ Argument, ArrowFunctionBody, CallExpression, Expression, ImportDeclarationSpecifier, - JSXAttributeItem, JSXAttributeValue, JSXElementName, JSXOpeningElement, LogicalOperator, - ModuleExportName, ObjectPropertyKind, Statement, + JSXAttributeItem, JSXAttributeValue, JSXElement, JSXElementName, JSXOpeningElement, + LogicalOperator, ModuleExportName, ObjectPropertyKind, Statement, }; use oxc_ast_visit::{ Visit, - walk::{walk_call_expression, walk_jsx_opening_element}, + walk::{walk_call_expression, walk_jsx_element, walk_jsx_opening_element}, }; use oxc_parser::Parser; use oxc_span::GetSpan; @@ -55,6 +56,11 @@ struct LibraryNumbers<'n> { devup: Vec<&'n str>, /// Local names of the functions building elements from a type and props jsx: Vec<&'n str>, + /// Local names of Emotion's `ClassNames`, whose child function takes a + /// `css` taking rules + class_names: Vec<&'n str>, + class_names_calls: Vec, + scoping: Option, replacements: Vec<(usize, usize, String)>, } @@ -255,14 +261,53 @@ fn kebab_property(key: &str) -> String { property } -impl<'a> Visit<'a> for LibraryNumbers<'_> { +impl<'a> Visit<'a> for LibraryNumbers<'a> { + fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { + let css = match &element.opening_element.name { + JSXElementName::IdentifierReference(name) + if self.class_names.contains(&name.name.as_str()) + && self.scoping.as_ref().is_some_and(|scoping| { + crate::css_prop::binding_of(scoping, name).is_some_and(|symbol| { + scoping.get_root_binding(name.name.as_str().into()) == Some(symbol) + }) + }) => + { + class_names_child(element).and_then(|names| names.css) + } + _ => None, + }; + if let Some(css) = css { + self.class_names_calls.push(css); + } + walk_jsx_element(self, element); + if css.is_some() { + self.class_names_calls.pop(); + } + } + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { let rules_at = match &call.callee { - Expression::Identifier(callee) => self - .calls - .iter() - .find(|(name, _)| *name == callee.name.as_str()) - .map(|(_, rules_at)| *rules_at), + Expression::Identifier(callee) => { + let symbol = self + .scoping + .as_ref() + .and_then(|scoping| crate::css_prop::binding_of(scoping, callee)); + if symbol.is_some_and(|symbol| self.class_names_calls.contains(&symbol)) { + Some(RulesAt::EveryArgument) + } else { + self.calls + .iter() + .find(|(name, _)| { + *name == callee.name.as_str() + && self.scoping.as_ref().is_none_or(|scoping| { + symbol.is_some() + && scoping.get_root_binding(callee.name.as_str().into()) + == symbol + }) + }) + .map(|(_, rules_at)| *rules_at) + } + } callee => self .is_styled_factory(callee) .then_some(RulesAt::EveryArgument), @@ -354,6 +399,7 @@ fn devup_equivalent(source: &str, imported: &str) -> Option (_, "styled") => Some(DevupTarget::Main("styled")), // The `css` props it builds compile away, leaving React's own element ("@emotion/react", "jsx" | "createElement") => Some(DevupTarget::Compat("jsx")), + ("@emotion/react", "ClassNames") => Some(DevupTarget::Compat("ClassNames")), (_, "createGlobalStyle") => Some(DevupTarget::Compat("createGlobalStyle")), (_, "Global") => Some(DevupTarget::Compat("Global")), (_, "ThemeProvider") => Some(DevupTarget::Compat("ThemeProvider")), @@ -456,9 +502,11 @@ pub fn transform_import_aliases_with_edits<'a>( css_prop, }; let emotion_aliased = import_aliases.contains_key(EMOTION_REACT); - // Quick check: if no aliases match and no element takes a `css` prop, - // return original code - if CssProp::of(import_aliases, code, false) == CssProp::Off + let emotion_jsx = builds_jsx_with_emotion(import_aliases); + // Quick check: if no aliases match, no element takes a `css` prop and the + // project builds no JSX with Emotion, return original code + if !(emotion_jsx && is_jsx_file(filename)) + && CssProp::of(import_aliases, code, false) == CssProp::Off && (import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias))) { return unchanged(CssProp::Off); @@ -475,12 +523,18 @@ pub fn transform_import_aliases_with_edits<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); - if import_aliases.contains_key("@emotion/react") { + if emotion_aliased { transformations.extend(crate::jsx_import_source::emotion_replacements(&program)); } let mut numbers = LibraryNumbers::default(); - let mut uses_emotion = !transformations.is_empty(); + let mut uses_emotion = emotion_jsx || !transformations.is_empty(); let compat = format!("{package}/compat"); + if emotion_jsx + && is_jsx_file(filename) + && let Some(replacement) = crate::jsx_import_source::project_replacement(&program) + { + transformations.push(replacement); + } for stmt in &program.body { if let Statement::ImportDeclaration(import_decl) = stmt { @@ -510,9 +564,7 @@ pub fn transform_import_aliases_with_edits<'a>( span.end as usize, format!("\"{runtime}\""), )); - } - - if let Some(alias) = import_aliases.get(source_value) + } else if let Some(alias) = import_aliases.get(source_value) && !import_decl.import_kind.is_type() { let span = import_decl.span; @@ -539,6 +591,9 @@ pub fn transform_import_aliases_with_edits<'a>( numbers.calls.push((local, RulesAt::EveryArgument)); } ("@emotion/react", "Global") => numbers.components.push(local), + ("@emotion/react", "ClassNames") => { + numbers.class_names.push(local); + } _ => {} } } @@ -566,13 +621,27 @@ pub fn transform_import_aliases_with_edits<'a>( } } numbers.css_prop = CssProp::of(import_aliases, code, uses_emotion); - if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) + if !numbers.class_names.is_empty() + && (code.bytes().any(|byte| byte.is_ascii_digit()) || code.contains('[')) + { + numbers.scoping = Some( + oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(), + ); + } + if !(numbers.calls.is_empty() + && numbers.styled.is_empty() + && numbers.components.is_empty() + && numbers.class_names.is_empty()) || numbers.css_prop != CssProp::Off { numbers.visit_program(&program); transformations.append(&mut numbers.replacements); } - transformations.sort_unstable_by_key(|(start, ..)| *start); + // An insertion comes before a replacement starting where it does + transformations.sort_unstable_by_key(|(start, end, _)| (*start, *end)); // Apply transformations in reverse order to preserve positions if transformations.is_empty() { @@ -1519,4 +1588,51 @@ const x = 1;", assert_eq!(without_css_prop.css_prop, CssProp::Off); assert!(matches!(without_css_prop.code, Cow::Borrowed(_))); } + + #[test] + fn test_project_building_jsx_with_emotion() { + let mut aliases = emotion_alias(); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + aliases.insert( + crate::css_prop::EMOTION_JSX_RUNTIME.to_string(), + ImportAlias::NamedToNamed, + ); + let outputs: Vec<(String, CssProp, Vec)> = [ + ("test.tsx", "export const a = ;"), + ( + "test.jsx", + "import styled from '@emotion/styled';\nexport const a =
;", + ), + ( + "test.tsx", + "/** @jsxImportSource @emotion/react */\nexport const a =
;", + ), + ("test.tsx", "/** @jsx h */\nexport const a =
;"), + ("test.ts", "export const a = 1;"), + ( + "test.ts", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nexport const a = _jsx(Custom, { css: { top: 1 } });", + ), + ] + .iter() + .map(|(filename, code)| { + let aliased = + transform_import_aliases_with_edits(code, filename, "@devup-ui/react", &aliases); + (aliased.code.into_owned(), aliased.css_prop, aliased.edits) + }) + .collect(); + insta::assert_debug_snapshot!(outputs); + + let runtime_alone = transform_import_aliases_with_edits( + "export const a =
;", + "test.tsx", + "@devup-ui/react", + &HashMap::from([( + crate::css_prop::EMOTION_JSX_RUNTIME.to_string(), + ImportAlias::NamedToNamed, + )]), + ); + assert_eq!(runtime_alone.css_prop, CssProp::Off); + assert!(matches!(runtime_alone.code, Cow::Borrowed(_))); + } } diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 1c2f85933..df9bc4205 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -22,7 +22,7 @@ use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::composition::{Composition, set_prop_order}; -use crate::css_prop::{CssProp, CssTakers}; +use crate::css_prop::{CssProp, CssTakers, binding_of}; use crate::extractor::ExtractResult; use crate::extractor::extract_style_from_expression::{ LiteralHandling, extract_style_from_expression, @@ -214,6 +214,7 @@ pub(crate) struct Inlined { pub css_failures: Vec, pub unknown: Unknown, pub changed: Changed, + pub scoping: Option>, } /// Bindings styles read that hold an object or array code changes, whole or @@ -376,7 +377,8 @@ pub(crate) fn inline_constants<'a>( |source: &str| source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE; let mut style_roots = FxHashSet::default(); let mut apis = StyleApis::default(); - let css_props = CssTakers::new(program, css_prop, &format!("{}/compat", option.package)); + let compat = format!("{}/compat", option.package); + let css_props = CssTakers::new(program, css_prop, &compat); for statement in &program.body { if let Statement::ImportDeclaration(import) = statement && is_style_package(&import.source.value) @@ -384,6 +386,11 @@ pub(crate) fn inline_constants<'a>( let stylex = import.source.value == crate::STYLEX_PACKAGE; for specifier in import.specifiers.iter().flatten() { let local = specifier.local().name.as_str(); + if matches!(specifier, ImportDeclarationSpecifier::ImportSpecifier(specifier) + if specifier.imported.name() == "ClassNames" && import.source.value == compat.as_str()) + { + continue; + } style_roots.insert(local); match specifier { ImportDeclarationSpecifier::ImportSpecifier(specifier) => { @@ -398,19 +405,33 @@ pub(crate) fn inline_constants<'a>( } } } - if style_roots.is_empty() && css_prop == CssProp::Off { + if style_roots.is_empty() && css_prop == CssProp::Off && !css_props.has_class_names() { return Inlined::default(); } + let mut shared_scoping = css_props.has_class_names().then(|| { + Rc::new( + SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(), + ) + }); let mut read = StyleReads { style_roots: &style_roots, css_props: &css_props, names: FxHashSet::default(), depth: 0, + class_names: Vec::new(), + scoping: shared_scoping.as_deref(), }; read.visit_program(program); if read.names.is_empty() { - return Inlined::default(); + return Inlined { + scoping: shared_scoping, + ..Inlined::default() + }; } + let names = read.names; let mut modules = Modules { resolver, option, @@ -485,15 +506,24 @@ pub(crate) fn inline_constants<'a>( } // Scoping is only worth building when a style reads a name that may // hold a constant - let reads_math = read.names.contains("Math") && !scope.binds("Math"); - if !reads_math && !read.names.iter().any(|name| scope.binds(name)) { - return Inlined::default(); - } - let scoping = SemanticBuilder::new() - .build(program) - .semantic - .into_scoping(); - for name in &read.names { + let reads_math = names.contains("Math") && !scope.binds("Math"); + if !reads_math && !names.iter().any(|name| scope.binds(name)) { + return Inlined { + scoping: shared_scoping, + ..Inlined::default() + }; + } + let scoping = shared_scoping + .get_or_insert_with(|| { + Rc::new( + SemanticBuilder::new() + .build(program) + .semantic + .into_scoping(), + ) + }) + .clone(); + for name in &names { let bound = scope.binds(name); let constant = scope.lookup(&mut modules, name); match &constant { @@ -559,9 +589,13 @@ pub(crate) fn inline_constants<'a>( objects: false, styles: false, px: false, + class_names: Vec::new(), } .visit_program(program); } + if css_props.has_class_names() { + inlined.scoping = shared_scoping; + } inlined } @@ -693,22 +727,38 @@ struct StyleReads<'s> { css_props: &'s CssTakers<'s>, names: FxHashSet, depth: usize, + /// The API bindings of the surrounding `` child functions. + class_names: Vec, + scoping: Option<&'s Scoping>, } /// Whether `expression` is a style API: a root the package gives, or a member /// or call of one -fn is_style_root(style_roots: &FxHashSet<&str>, expression: &Expression<'_>) -> bool { +fn is_style_root( + style_roots: &FxHashSet<&str>, + expression: &Expression<'_>, + scoping: Option<&Scoping>, +) -> bool { match expression { - Expression::Identifier(identifier) => style_roots.contains(identifier.name.as_str()), - Expression::StaticMemberExpression(member) => is_style_root(style_roots, &member.object), - Expression::CallExpression(call) => is_style_root(style_roots, &call.callee), + Expression::Identifier(identifier) => { + style_roots.contains(identifier.name.as_str()) + && scoping.is_none_or(|scoping| { + binding_of(scoping, identifier).is_some_and(|symbol| { + scoping.get_root_binding(identifier.name.as_str().into()) == Some(symbol) + }) + }) + } + Expression::StaticMemberExpression(member) => { + is_style_root(style_roots, &member.object, scoping) + } + Expression::CallExpression(call) => is_style_root(style_roots, &call.callee, scoping), _ => false, } } impl StyleReads<'_> { fn is_style_root(&self, expression: &Expression<'_>) -> bool { - is_style_root(self.style_roots, expression) + is_style_root(self.style_roots, expression, self.scoping) } fn reading(&mut self, style: bool, walk: impl FnOnce(&mut Self) -> T) -> T { @@ -726,9 +776,21 @@ impl<'a> Visit<'a> for StyleReads<'_> { } } + fn visit_jsx_element(&mut self, element: &oxc_ast::ast::JSXElement<'a>) { + let calls = self + .scoping + .map(|scoping| self.css_props.class_names_calls(element, scoping)) + .unwrap_or_default(); + let taken = calls.len(); + self.class_names.extend(calls); + oxc_ast_visit::walk::walk_jsx_element(self, element); + self.class_names.truncate(self.class_names.len() - taken); + } + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&call.callee); - let style = self.is_style_root(&call.callee); + let style = self.is_style_root(&call.callee) + || calls_one_of(self.scoping, &self.class_names, &call.callee); let css = self .css_props .property(call, |root| self.style_roots.contains(root)); @@ -753,7 +815,8 @@ impl<'a> Visit<'a> for StyleReads<'_> { tagged: &oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&tagged.tag); - let style = self.is_style_root(&tagged.tag); + let style = self.is_style_root(&tagged.tag) + || calls_one_of(self.scoping, &self.class_names, &tagged.tag); self.reading(style, |reads| reads.visit_template_literal(&tagged.quasi)); } @@ -778,6 +841,11 @@ impl<'a> Visit<'a> for StyleReads<'_> { } } +/// Whether `callee` is one of `names` +fn calls_one_of(scoping: Option<&Scoping>, symbols: &[SymbolId], callee: &Expression<'_>) -> bool { + matches!(callee, Expression::Identifier(callee) if scoping.and_then(|scoping| binding_of(scoping, callee)).is_some_and(|symbol| symbols.contains(&symbol))) +} + /// The name `` or `` starts with pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { match name { @@ -1915,6 +1983,8 @@ struct Inline<'s, 'a> { styles: bool, /// Inside a `css` prop, whose numbers Emotion reads as `px` lengths px: bool, + /// The API bindings of the surrounding `` child functions. + class_names: Vec, } impl<'a> Inline<'_, 'a> { @@ -2209,7 +2279,17 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { tagged: &mut oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&mut tagged.tag); - let styles = is_style_root(self.style_roots, &tagged.tag); + if calls_one_of(Some(self.scoping), &self.class_names, &tagged.tag) { + self.reading_css(true, |inline| { + inline.visit_template_literal(&mut tagged.quasi); + }); + return; + } + let styles = is_style_root( + self.style_roots, + &tagged.tag, + self.css_props.has_class_names().then_some(self.scoping), + ); self.reading_styles(styles, |inline| { inline.visit_template_literal(&mut tagged.quasi); }); @@ -2221,10 +2301,30 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { }); } + fn visit_jsx_element(&mut self, element: &mut oxc_ast::ast::JSXElement<'a>) { + let calls = self.css_props.class_names_calls(element, self.scoping); + let taken = calls.len(); + self.class_names.extend(calls); + walk_mut::walk_jsx_element(self, element); + self.class_names.truncate(self.class_names.len() - taken); + } + fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&mut call.callee); + if calls_one_of(Some(self.scoping), &self.class_names, &call.callee) { + self.reading_css(true, |inline| { + for argument in &mut call.arguments { + inline.visit_argument(argument); + } + }); + return; + } let objects = self.apis.reads(&call.callee); - let styles = is_style_root(self.style_roots, &call.callee); + let styles = is_style_root( + self.style_roots, + &call.callee, + self.css_props.has_class_names().then_some(self.scoping), + ); let css = self .css_props .property(call, |root| self.style_roots.contains(root)); diff --git a/libs/extractor/src/jsx_import_source.rs b/libs/extractor/src/jsx_import_source.rs index 6b14f8600..f90c69e27 100644 --- a/libs/extractor/src/jsx_import_source.rs +++ b/libs/extractor/src/jsx_import_source.rs @@ -39,12 +39,202 @@ pub(crate) fn emotion_replacements(program: &Program<'_>) -> Vec<(usize, usize, replacements } +/// Redirect project-wide Emotion JSX unless an exact header instruction overrides it. +pub(crate) fn project_replacement(program: &Program<'_>) -> Option<(usize, usize, String)> { + let header_end = program + .body + .first() + .map_or(program.span.end, |body| body.span().start); + for comment in &program.comments { + if comment.span.end > header_end { + continue; + } + let mut tokens = comment + .content_span() + .source_text(program.source_text) + .trim_start_matches('*') + .split_whitespace() + .peekable(); + while let Some(token) = tokens.next() { + if matches!( + token, + "@jsx" | "@jsxFrag" | "@jsxRuntime" | "@jsxImportSource" + ) && tokens.peek().is_some_and(|value| *value != "*") + { + return None; + } + } + } + let pragma = crate::css_prop::REACT_JSX_PRAGMA; + let (start, text) = match &program.hashbang { + Some(hashbang) => { + let end = hashbang.span.end as usize; + let rest = &program.source_text[end..]; + if rest.starts_with("\r\n") { + (end + 2, pragma.to_string()) + } else { + match rest.chars().next() { + Some(terminator @ ('\r' | '\n' | '\u{2028}' | '\u{2029}')) => { + (end + terminator.len_utf8(), pragma.to_string()) + } + Some(_) | None => (end, format!("\n{pragma}")), + } + } + } + None => (0, pragma.to_string()), + }; + Some((start, start, text)) +} + +#[cfg(test)] +#[path = "jsx_import_source_terminator_tests.rs"] +mod terminator_tests; + #[cfg(test)] mod tests { use crate::{ImportAlias, import_alias_visit::transform_import_aliases_with_edits}; use rstest::rstest; use std::collections::HashMap; + #[rstest] + #[case::plain("", true)] + #[case::embedded("/* not@jsxImportSource custom */\n", true)] + #[case::joined("/* @jsxImportSourcecustom */\n", true)] + #[case::suffix("/* @jsxFactory h */\n", true)] + #[case::empty("/* @jsxImportSource */\n", true)] + #[case::doc_empty("/** @jsxImportSource\n * documentation */\n", true)] + #[case::post_body("export const anchor=1;\n/* @jsxImportSource custom */\n", true)] + #[case::source("/** @jsxImportSource custom */\n", false)] + #[case::factory("/**@jsx h*/\n", false)] + #[case::fragment("/** @jsxFrag Fragment */\n", false)] + #[case::runtime("/** @jsxRuntime classic */\n", false)] + #[case::directive("'use client';\n/* @jsxImportSource custom */\n", false)] + fn project_jsx_uses_only_exact_header_overrides(#[case] header: &str, #[case] inserted: bool) { + // Given: project-wide Emotion JSX plus a real or lookalike file override. + let source = format!("{header}export const view=;"); + let aliases = HashMap::from([ + ("@emotion/react".into(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".into(), + ImportAlias::NamedToNamed, + ), + ]); + + // When: aliases compile JSX's css prop before the bundler lowers JSX. + let rewritten = transform_import_aliases_with_edits( + &source, + "project.tsx", + "@devup-ui/react", + &aliases, + ); + + // Then: only an actual header instruction suppresses the React default. + assert_eq!( + rewritten + .code + .starts_with(crate::css_prop::REACT_JSX_PRAGMA), + inserted + ); + assert_eq!(rewritten.css_prop, crate::css_prop::CssProp::Everywhere); + assert!( + rewritten.code.contains("padding:\"2px\""), + "{}", + rewritten.code + ); + } + + #[test] + fn project_jsx_inserts_after_hashbang_and_maps_authored_offsets() -> Result<(), String> { + // Given: an executable JSX source with a directive and project-only Emotion. + let source = "#!/usr/bin/env node\n'use client';\nexport const view=
;"; + let aliases = HashMap::from([ + ("@emotion/react".into(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".into(), + ImportAlias::NamedToNamed, + ), + ]); + + // When: the project default is redirected to React. + let rewritten = + transform_import_aliases_with_edits(source, "project.jsx", "@devup-ui/react", &aliases); + + // Then: the hashbang stays first and every authored byte keeps its origin. + assert_eq!( + rewritten.code, + "#!/usr/bin/env node\n/** @jsxImportSource react */\n'use client';\nexport const view=
;" + ); + let authored = source.find("export").ok_or("missing authored export")?; + let emitted = rewritten + .code + .find("export") + .ok_or("missing emitted export")?; + assert_eq!( + crate::import_alias_visit::source_offset(&rewritten.edits, emitted), + authored + ); + Ok(()) + } + + #[test] + fn project_jsx_terminates_a_hashbang_at_end_of_file() { + // Given: a project JSX file containing only an unterminated hashbang line. + let source = "#!/usr/bin/env node"; + let aliases = HashMap::from([ + ("@emotion/react".into(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".into(), + ImportAlias::NamedToNamed, + ), + ]); + + // When: alias preprocessing redirects the project default. + let rewritten = + transform_import_aliases_with_edits(source, "project.jsx", "@devup-ui/react", &aliases); + + // Then: the pragma starts on its own line, outside the hashbang. + assert_eq!( + rewritten.code, + "#!/usr/bin/env node\n/** @jsxImportSource react */\n" + ); + } + + #[rstest] + #[case::plain("")] + #[case::hashbang("#!/usr/bin/env node\n")] + #[case::directive("'use client';\n")] + #[serial_test::serial] + fn project_jsx_preserves_original_parser_locations(#[case] header: &str) -> Result<(), String> { + // Given: project-only JSX with a recoverable invalid attribute. + let source = format!("{header}export const view=
;"); + let option = crate::ExtractOption { + import_aliases: HashMap::from([ + ("@emotion/react".into(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".into(), + ImportAlias::NamedToNamed, + ), + ]), + ..crate::ExtractOption::default() + }; + + // When: the public extractor inserts the project pragma before parsing. + let error = crate::extract("project.tsx", &source, option) + .err() + .ok_or("expected strict parsing error")? + .to_string(); + + // Then: strict parsing rejects it at its authored line, not the inserted line. + let line = header.lines().count() + 1; + assert_eq!( + error, + format!( + "project.tsx:{line}:28: JSX attributes must only be assigned a non-empty 'expression'." + ) + ); + Ok(()) + } + #[rstest] #[case( "/** @jsxImportSource @emotion/react */", diff --git a/libs/extractor/src/jsx_import_source_terminator_tests.rs b/libs/extractor/src/jsx_import_source_terminator_tests.rs new file mode 100644 index 000000000..7b3b61653 --- /dev/null +++ b/libs/extractor/src/jsx_import_source_terminator_tests.rs @@ -0,0 +1,88 @@ +use crate::{ExtractOption, ImportAlias, import_alias_visit::transform_import_aliases_with_edits}; +use rstest::rstest; +use std::collections::HashMap; + +#[rstest] +#[case::cr("\r")] +#[case::crlf("\r\n")] +#[case::lf("\n")] +#[case::line_separator("\u{2028}")] +#[case::paragraph_separator("\u{2029}")] +fn project_jsx_preserves_adjacent_hashbang_terminator_and_offsets( + #[case] terminator: &str, +) -> Result<(), String> { + // Given: a different later LF must not determine the insertion point. + let source = format!("#!/usr/bin/env node{terminator}export const view=
;\n"); + let aliases = HashMap::from([ + ("@emotion/react".into(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".into(), + ImportAlias::NamedToNamed, + ), + ]); + + // When: project-only JSX is redirected before bundler lowering. + let rewritten = + transform_import_aliases_with_edits(&source, "project.jsx", "@devup-ui/react", &aliases); + + // Then: insertion consumes only the adjacent terminator; authored bytes map back exactly. + assert_eq!( + rewritten.code, + format!( + "#!/usr/bin/env node{terminator}/** @jsxImportSource react */\nexport const view=
;\n" + ) + ); + let emitted = rewritten + .code + .find("export") + .ok_or("missing emitted export")?; + let authored = source.find("export").ok_or("missing authored export")?; + for offset in 0.."export const view=
;\n".len() { + assert_eq!( + crate::import_alias_visit::source_offset(&rewritten.edits, emitted + offset), + authored + offset + ); + } + Ok(()) +} + +#[rstest] +#[case::cr("\r")] +#[case::crlf("\r\n")] +#[case::lf("\n")] +#[case::line_separator("\u{2028}")] +#[case::paragraph_separator("\u{2029}")] +#[serial_test::serial] +fn project_jsx_keeps_public_pragma_in_header_after_mixed_terminators( + #[case] terminator: &str, +) -> Result<(), String> { + // Given: importless project JSX with a hashbang and a later LF. + let source = format!("#!/usr/bin/env node{terminator}export const view=
;\n"); + let option = ExtractOption { + import_aliases: HashMap::from([ + ("@emotion/react".into(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".into(), + ImportAlias::NamedToNamed, + ), + ]), + ..ExtractOption::default() + }; + + // When: the public extractor parses and regenerates the source. + let output = + crate::extract("project.jsx", &source, option).map_err(|error| error.to_string())?; + + // Then: a compiler-visible header instruction precedes the first body statement. + let pragma = output + .code + .find("/** @jsxImportSource react */") + .ok_or("missing project pragma")?; + let body = output + .code + .find("export const view") + .ok_or("missing export")?; + assert!(pragma < body, "{}", output.code); + assert!(output.code.starts_with("#!/usr/bin/env node")); + Ok(()) +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 8f6106253..d40ba4ea2 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,6 +1,10 @@ mod as_visit; mod build_time_values; mod class_arguments; +#[cfg(test)] +mod class_names_binding_tests; +#[cfg(test)] +mod class_names_numeric_tests; mod component; mod composition; mod css_prop; @@ -342,6 +346,8 @@ fn extract_with_source_map( ) -> Result> { if code.contains(&option.package) || code.contains(STYLEX_PACKAGE) + || (css_prop::builds_jsx_with_emotion(&option.import_aliases) + && css_prop::is_jsx_file(filename)) || option .import_aliases .keys() @@ -578,6 +584,9 @@ fn extract_source( ); visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); visitor.import_css(inlined.css_styles); + if let Some(scoping) = inlined.scoping { + visitor.reuse_scoping(scoping); + } visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); visitor.takes_css_prop(css_prop); @@ -23520,6 +23529,50 @@ export const App = () =>
;\nexport const App = () => ;", + option.clone(), + ) + .unwrap(); + assert_debug_snapshot!(ToBTreeSet::from(output)); + + reset_class_map(); + reset_file_map(); + let plain = extract( + "test.tsx", + "export const App = () =>
;", + option.clone(), + ) + .unwrap(); + assert_eq!( + plain.code, + "/** @jsxImportSource react */\nexport const App = () =>
;\n" + ); + + reset_class_map(); + reset_file_map(); + let error = extract( + "test.tsx", + "import { css } from '@emotion/react';\nexport const App = () =>
;", + option, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!(error.starts_with("test.tsx:2:36: "), "{error}"); + } + #[test] #[serial] fn test_emotion_css_prop_reports_what_it_cannot_compile() { @@ -23554,6 +23607,32 @@ export const App = () => {({ css, cx, theme }) =>
};", + "export const App = ({ on }) => {({ css, cx }) =>
};", + "export const App = ({ on }) => {function ({ css: c }) { return ; }};", + "const mixin = { margin: 4 };\nexport const App = () =>
{({ css }) =>

}

;", + "export const App = () => {() =>
}
;", + "export const App = (p) => {({ cx }) => p.on()} title={String.raw`t`} id={String(1)} className={cx(p.className, cx('a'), p.on ? { x: p.on } : 'y', p.a || { z: p.b }, p.c ? cx('k') : null, p.d && cx())} />};", + "const tone = 'red';\nconst gap = 3;\nexport const App = () => {({ css }) => };", + ]; + let outputs: Vec = codes + .iter() + .map(|code| { + reset_class_map(); + reset_file_map(); + let output = + extract("test.tsx", &format!("{import}{code}"), emotion_option()).unwrap(); + format!("{}\n{:?}", output.code, output.styles) + }) + .collect(); + assert_debug_snapshot!(outputs); + } + // `@emotion/css` compiles like Devup UI's own APIs, its numbers in px #[test] #[serial] @@ -23600,6 +23679,79 @@ injectGlobal`body { margin: 0; }`;", .map_err(|error| error.to_string()) } + #[test] + #[serial] + fn test_emotion_component_selectors() { + let import = "import styled from '@emotion/styled';\n"; + let codes = [ + "const Child = styled.div`color: red;`;\nexport const Parent = styled.div`${Child} { color: green; } &:hover ${Child}, ${Child} + a { color: blue; }`;\nexport const App = () => ;", + "const Child = styled.div({ color: 'red' });\nexport const Parent = styled.div({ [Child]: { color: 'green' }, [`&:hover ${Child}`]: { color: 'blue' } });", + "const Child = styled.div({ color: 'red' });\nconst Wide = styled(Child)({ width: 1 });\nexport const Parent = styled.div`${Child} { color: green; }`;\nexport const App = () => <>;", + "const Child = styled.div`color: red;`;\nexport const Parent = styled.div`${Child} { color: green; }`;\nexport const C = () => ;", + "const notStyled = 'x';\nexport const Parent = styled.div`${notStyled} { color: green; }`;", + "export const Parent = styled.div`a, :is(b, c) d { color: green; }`;", + ]; + let outputs: Vec = codes + .iter() + .map(|code| { + reset_class_map(); + reset_file_map(); + match extract("test.tsx", &format!("{import}{code}"), emotion_option()) { + Ok(output) => format!("{}\n{:?}", output.code, output.styles), + Err(error) => error.to_string(), + } + }) + .collect(); + assert_debug_snapshot!(outputs); + } + + #[test] + #[serial] + fn test_emotion_class_names_reports_what_it_cannot_compile() { + let import = "import { ClassNames } from '@emotion/react';\n"; + let errors: Vec = [ + "export const App = () => {render};", + "export const App = () => {() =>
}{() => };", + "export const App = () => ;", + "export const App = () => {};", + "export const App = () => text;", + "export const App = () => {() => };", + "export const App = () => {(content) => };", + "export const App = () => {({ css, ...rest }) => };", + "export const App = () => {({ css = f }) => };", + "export const App = () => {({ other }) => };", + "export const App = () => {({ [k]: css }) => };", + "export const App = () => {({ css }, more) => };", + "export const App = () => {(...content) => };", + "export const App = () => {({ css: { a } }) => };", + "export const App = () => {async ({ css }) => };", + "export const App = () => {function* ({ css }) { yield ; }};", + "export const App = () => {({ css }) => { const a = 1; return ; }};", + "export const App = () => {({ theme }) => };", + "export const App = () => {({ css, theme }) => };", + "export const App = () => {({ css }) => };", + "export const App = () => {({ cx }) => };", + "export const App = ({ s }) => {({ cx }) => };", + "export const App = () => {({ cx }) => };", + "export const App = ({ s }) => {({ css }) => };", + "export const App = ({ v }) => {({ css }) => };", + "export const App = ({ v }) => {({ css }) => };", + "export const App = ({ on }) => {({ css, cx }) => };", + "export const App = ({ on, off }) => {({ css, cx }) => };", + ] + .iter() + .map(|code| { + reset_class_map(); + reset_file_map(); + extract("test.tsx", &format!("{import}{code}"), emotion_option()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default() + }) + .collect(); + assert_debug_snapshot!(errors); + } + #[test] #[serial] fn test_emotion_cx_takes_class_names() { diff --git a/libs/extractor/src/mutations.rs b/libs/extractor/src/mutations.rs index dde3a1788..a9f33dea0 100644 --- a/libs/extractor/src/mutations.rs +++ b/libs/extractor/src/mutations.rs @@ -2,6 +2,7 @@ //! or hands them to code that may change them, so the build does not read //! them as the constants they are declared as. +use oxc_allocator::CloneIn; use oxc_ast::AstKind; use oxc_ast::ast::{ Expression, JSXAttributeName, JSXElementName, ObjectPropertyKind, Program, @@ -110,6 +111,13 @@ pub(crate) fn uses( style: &dyn Fn(&str) -> bool, css: Option<&crate::css_prop::CssTakers<'_>>, ) -> FxHashMap> { + let allocator = oxc_allocator::Allocator::default(); + // ClassNames callers retain semantic IDs for inlining and compilation. + // Analyze a separate tree so building mutation nodes cannot reset those IDs. + let isolated = css + .filter(|css| css.has_class_names()) + .map(|_| program.clone_in(&allocator)); + let program = isolated.as_ref().unwrap_or(program); let semantic = SemanticBuilder::new() .with_build_nodes(true) .build(program) @@ -125,6 +133,7 @@ pub(crate) fn uses( }; let context = Context { nodes, + scoping, style, css, is_global: &is_global, @@ -146,6 +155,7 @@ pub(crate) fn uses( struct Context<'s, 'a> { nodes: &'s AstNodes<'a>, + scoping: &'s oxc_semantic::Scoping, style: &'s dyn Fn(&str) -> bool, css: Option<&'s crate::css_prop::CssTakers<'s>>, is_global: &'s dyn Fn(&str) -> bool, @@ -390,8 +400,12 @@ impl Context<'_, '_> { std::iter::once(node) .chain(self.nodes.ancestor_ids(node)) .any(|id| match self.nodes.kind(id) { - AstKind::CallExpression(call) => self.is_style(&call.callee), - AstKind::TaggedTemplateExpression(tagged) => self.is_style(&tagged.tag), + AstKind::CallExpression(call) => { + self.is_style(&call.callee) || self.is_class_names_call(id, &call.callee) + } + AstKind::TaggedTemplateExpression(tagged) => { + self.is_style(&tagged.tag) || self.is_class_names_call(id, &tagged.tag) + } AstKind::JSXOpeningElement(element) => self.is_style_element(&element.name), AstKind::JSXAttribute(attribute) => self.is_css_attribute(id, attribute), AstKind::ObjectProperty(property) => self.is_css_property(id, property), @@ -399,6 +413,21 @@ impl Context<'_, '_> { }) } + /// Whether `callee`, called at `id`, is the `css` or `cx` a `` + /// child function around takes, whose calls the build compiles + fn is_class_names_call(&self, id: NodeId, callee: &Expression<'_>) -> bool { + let (Some(css), Expression::Identifier(callee)) = (self.css, callee) else { + return false; + }; + let Some(symbol) = crate::css_prop::binding_of(self.scoping, callee) else { + return false; + }; + self.nodes.ancestor_ids(id).any(|ancestor| { + matches!(self.nodes.kind(ancestor), AstKind::JSXElement(element) + if css.class_names_calls(element, self.scoping).contains(&symbol)) + }) + } + /// Whether the attribute `attribute` at `id` is a `css` prop the build /// compiles fn is_css_attribute(&self, id: NodeId, attribute: &oxc_ast::ast::JSXAttribute<'_>) -> bool { @@ -511,7 +540,18 @@ impl Context<'_, '_> { let mut expression = callee; loop { match crate::utils::unwrap_syntax_only(expression) { - Expression::Identifier(identifier) => return (self.style)(&identifier.name), + Expression::Identifier(identifier) => { + return (self.style)(&identifier.name) + && self.css.is_none_or(|css| { + !css.has_class_names() + || crate::css_prop::binding_of(self.scoping, identifier) + .is_some_and(|symbol| { + self.scoping + .get_root_binding(identifier.name.as_str().into()) + == Some(symbol) + }) + }); + } Expression::StaticMemberExpression(member) => expression = &member.object, Expression::CallExpression(call) => expression = &call.callee, _ => return false, @@ -520,6 +560,18 @@ impl Context<'_, '_> { } fn is_style_element(&self, name: &JSXElementName<'_>) -> bool { + if self + .css + .is_some_and(crate::css_prop::CssTakers::has_class_names) + && let JSXElementName::IdentifierReference(identifier) = name + && !crate::css_prop::binding_of(self.scoping, identifier).is_some_and(|symbol| { + self.scoping + .get_root_binding(identifier.name.as_str().into()) + == Some(symbol) + }) + { + return false; + } crate::imported_constants::jsx_root(name).is_some_and(|root| (self.style)(root)) } } diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__project_building_jsx_with_emotion.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__project_building_jsx_with_emotion.snap new file mode 100644 index 000000000..f6128ba1a --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__project_building_jsx_with_emotion.snap @@ -0,0 +1,75 @@ +--- +source: libs/extractor/src/import_alias_visit.rs +expression: outputs +--- +[ + ( + "/** @jsxImportSource react */\nexport const a = ;", + Everywhere, + [ + ( + 0, + 0, + 30, + ), + ( + 41, + 42, + 5, + ), + ], + ), + ( + "/** @jsxImportSource react */\nimport { styled } from '@devup-ui/react';\nexport const a =
;", + Off, + [ + ( + 0, + 0, + 30, + ), + ( + 0, + 37, + 41, + ), + ], + ), + ( + "/** @jsxImportSource react */\nexport const a =
;", + Off, + [ + ( + 21, + 35, + 5, + ), + ], + ), + ( + "/** @jsx h */\nexport const a =
;", + Off, + [], + ), + ( + "export const a = 1;", + Off, + [], + ), + ( + "import { jsx as _jsx } from \"react/jsx-runtime\";\nexport const a = _jsx(Custom, { css: { top: \"1px\" } });", + Everywhere, + [ + ( + 28, + 56, + 19, + ), + ( + 102, + 103, + 5, + ), + ], + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_in_every_form.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_in_every_form.snap new file mode 100644 index 000000000..d80cd14c5 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_in_every_form.snap @@ -0,0 +1,13 @@ +--- +source: libs/extractor/src/lib.rs +expression: outputs +--- +[ + "import \"@devup-ui/react/devup-ui.css\";\nexport const App = () =>
;\n\n{Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"var(--color)\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nexport const App = ({ on }) =>
;\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nexport const App = ({ on }) => ;\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nconst mixin = { margin: 4 };\nexport const App = () =>
{

}

;\n\n{Static(ExtractStaticStyle { property: \"margin\", value: \"4px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}", + "export const App = () =>
;\n\n{}", + "export const App = (p) => p.on()} title={String.raw`t`} id={String(1)} className={`a ${p.className} ${p.on ? p.on ? \"x\" : \"\" : \"y\"} ${p.a ? typeof p.a === \"string\" ? p.a : \"\" : p.b ? \"z\" : \"\"} ${p.c ? \"k\" : \"\"}`} />;\n\n{}", + "import \"@devup-ui/react/devup-ui.css\";\nconst tone = \"red\";\nconst gap = 3;\nexport const App = () => ;\n\n{Static(ExtractStaticStyle { property: \"margin\", value: \"2px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"3px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}", +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap new file mode 100644 index 000000000..796b3ae06 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap @@ -0,0 +1,34 @@ +--- +source: libs/extractor/src/lib.rs +expression: errors +--- +[ + "test.tsx:2:39: `` cannot use `render` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:26: `` cannot use `children` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:26: `` cannot use `children` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:38: `` cannot use `{}` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:26: `` cannot use `children` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:38: `` cannot use `an attribute` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `(content) =>
` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ css, ...rest }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ css = f }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ other }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ [k]: css }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ css }, more) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `(...content) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ css: { a } }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `async ({ css }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `(function* ({ css }) { yield ; })` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ css }) => { const a = 1; return ; }` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:64: `` cannot use `theme` at build time: it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets", + "test.tsx:2:73: `css()` cannot use `theme.space(2)` at build time: its values must be literals, theme tokens or constants, or be computed from them\ntest.tsx:2:87: `` cannot use `theme.space(2)` at build time: it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets", + "test.tsx:2:60: `` cannot use `css` at build time: the `css` and `cx` its child function takes can only be called, as the build compiles each call", + "test.tsx:2:65: `` cannot use `cx(getClass())` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "test.tsx:2:78: `` cannot use `s` at build time: an object `cx` takes must give each class a condition, as `{ name: condition }`", + "test.tsx:2:75: `` cannot use `(function() { return true; })` at build time: an object `cx` takes must give each class a condition, as `{ name: condition }`", + "test.tsx:2:71: `` cannot use `css(...s)` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "test.tsx:2:87: `` cannot use `v` at build time: a mixin must stand outside nested rules, where the parts it composes with can be split", + "test.tsx:2:71: `css()` cannot use `v` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "test.tsx:2:76: `` cannot use `cx(on && cx(css({ color: \"red\" }), \"x\"))` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "test.tsx:2:81: `` cannot use `cx(on && cx(off ? css({ color: \"red\" }) : null))` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_component_selectors.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_component_selectors.snap new file mode 100644 index 000000000..69a2d0719 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_component_selectors.snap @@ -0,0 +1,12 @@ +--- +source: libs/extractor/src/lib.rs +expression: outputs +--- +[ + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Child = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"c d\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = () => ;\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: Some(Selector(\"&:hover .a,& .a + a\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& .a\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Child = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"c d\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: Some(Selector(\"&:hover .a\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& .a\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Child = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nconst Wide = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a c b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"d\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = () => <>;\n\n{Static(ExtractStaticStyle { property: \"width\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& .a\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Child = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = () =>
;\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& .a\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst notStyled = \"x\";\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& x\")), style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& a,&:is(b,c) d\")), style_order: None, layer: None })}", +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_where_the_project_builds_jsx_with_emotion.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_where_the_project_builds_jsx_with_emotion.snap new file mode 100644 index 000000000..2d0c73a81 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_where_the_project_builds_jsx_with_emotion.snap @@ -0,0 +1,29 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(output)" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "2px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "/** @jsxImportSource react */\nimport \"@devup-ui/react/devup-ui.css\";\nconst Custom = ({ className }) =>
;\nexport const App = () => ;\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap index bcbb5986a..b3a068483 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap @@ -32,5 +32,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global, ThemeProvider, useTheme } from \"@devup-ui/react/compat\";\nimport { ClassNames } from \"@emotion/react\";\nconst S = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = () => <>;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global, ThemeProvider, useTheme } from \"@devup-ui/react/compat\";\nconst S = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = () => <>;\n", } diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 892efb73a..f99c38d35 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -9,7 +9,7 @@ use oxc_ast_visit::{VisitMut, walk_mut}; use oxc_semantic::Scoping; use oxc_span::SPAN; use oxc_syntax::symbol::SymbolId; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use crate::ExtractStyleValue; @@ -23,11 +23,14 @@ pub enum StyleValue { Class(String, Option>), /// A name `keyframes()` gives Keyframes(String), + /// The selector of a styled component other styles select, read in CSS + /// text and rule keys alike + Component(String), } #[derive(Default)] pub struct StyleValues<'a> { - scoping: Option, + scoping: Option>, values: FxHashMap, /// Known imported styles, including top-level constant aliases, by binding imported: FxHashMap>, @@ -39,7 +42,7 @@ pub struct StyleValues<'a> { } impl<'a> StyleValues<'a> { - pub fn new(scoping: Scoping) -> Self { + pub fn new(scoping: std::rc::Rc) -> Self { Self { scoping: Some(scoping), values: FxHashMap::default(), @@ -50,6 +53,18 @@ impl<'a> StyleValues<'a> { } } + pub const fn has_scoping(&self) -> bool { + self.scoping.is_some() + } + + pub fn reads_root_binding(&self, identifier: &oxc_ast::ast::IdentifierReference<'_>) -> bool { + self.scoping.as_ref().is_none_or(|scoping| { + scoping + .get_root_binding(identifier.name.as_str().into()) + .is_none_or(|root| self.reference_symbol(identifier) == Some(root)) + }) + } + pub fn import(&mut self, imported: FxHashMap>) { self.imported = imported; } @@ -76,6 +91,10 @@ impl<'a> StyleValues<'a> { self.values.insert(symbol, value); } + pub fn remove(&mut self, symbol: SymbolId) { + self.values.remove(&symbol); + } + /// The binding `expression` reads, when it reads one pub fn symbol(&self, expression: &Expression<'_>) -> Option { let Expression::Identifier(identifier) = expression else { @@ -249,6 +268,7 @@ impl<'a> StyleValues<'a> { ast, scoping, values: &self.values, + in_key: false, context: if in_rules { ReadContext::Rules } else { @@ -262,6 +282,58 @@ impl<'a> StyleValues<'a> { } } +/// The bindings styles read as selectors: interpolated in CSS text where a +/// value cannot stand, or as the computed key of a rule object +pub fn selected(program: &oxc_ast::ast::Program<'_>, values: &StyleValues) -> FxHashSet { + let mut selected = Selected { + values, + symbols: FxHashSet::default(), + }; + oxc_ast_visit::Visit::visit_program(&mut selected, program); + selected.symbols +} + +struct Selected<'s, 'a> { + values: &'s StyleValues<'a>, + symbols: FxHashSet, +} + +impl<'a> oxc_ast_visit::Visit<'a> for Selected<'_, '_> { + fn visit_tagged_template_expression( + &mut self, + it: &oxc_ast::ast::TaggedTemplateExpression<'a>, + ) { + let quasi = &it.quasi; + let mut text = String::new(); + for (index, expression) in quasi.expressions.iter().enumerate() { + text.push_str(quasi.quasis[index].value.raw.as_str()); + if matches!( + crate::css_utils::interpolation_place(&text, &quasi.quasis[index + 1..]), + crate::css_utils::Place::Other + ) { + self.symbols.extend(self.values.symbol(expression)); + } + } + oxc_ast_visit::walk::walk_tagged_template_expression(self, it); + } + + fn visit_object_property(&mut self, it: &oxc_ast::ast::ObjectProperty<'a>) { + if it.computed + && let Some(key) = it.key.as_expression() + { + match key { + Expression::TemplateLiteral(template) => { + for expression in &template.expressions { + self.symbols.extend(self.values.symbol(expression)); + } + } + key => self.symbols.extend(self.values.symbol(key)), + } + } + oxc_ast_visit::walk::walk_object_property(self, it); + } +} + struct Names<'s, 'a> { ast: &'s AstBuilder<'a>, scoping: &'s Scoping, @@ -305,6 +377,8 @@ struct Reads<'s, 'a> { ast: &'s AstBuilder<'a>, scoping: &'s Scoping, values: &'s FxHashMap, + /// Inside the key of a rule, which a component is read in as its selector + in_key: bool, context: ReadContext, rules: &'s FxHashMap>, calls: &'s FxHashMap>, @@ -313,6 +387,28 @@ struct Reads<'s, 'a> { } impl<'a> VisitMut<'a> for Reads<'_, 'a> { + fn visit_property_key(&mut self, it: &mut oxc_ast::ast::PropertyKey<'a>) { + // A component standing for the whole key selects it within the rule + if let Some(Expression::Identifier(identifier)) = it.as_expression() + && let Some(StyleValue::Component(selector)) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| self.values.get(&symbol)) + { + *it = oxc_ast::ast::PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed( + SPAN, + Str::from_in(format!("& {selector}").as_str(), self.ast.allocator()), + None, + self.ast, + )); + return; + } + let outer = std::mem::replace(&mut self.in_key, true); + walk_mut::walk_property_key(self, it); + self.in_key = outer; + } + fn visit_expression(&mut self, it: &mut Expression<'a>) { if self.source && let Expression::Identifier(identifier) = it @@ -341,7 +437,15 @@ impl<'a> VisitMut<'a> for Reads<'_, 'a> { { let value = match value { StyleValue::Class(..) if self.context == ReadContext::CssText => return, - StyleValue::Class(value, _) | StyleValue::Keyframes(value) => value, + // Elsewhere it is the component itself + StyleValue::Component(_) + if self.context != ReadContext::CssText && !self.in_key => + { + return; + } + StyleValue::Class(value, _) + | StyleValue::Keyframes(value) + | StyleValue::Component(value) => value, }; *it = Expression::new_string_literal( SPAN, @@ -380,14 +484,17 @@ impl<'a> VisitMut<'a> for Reads<'_, 'a> { } fn visit_object_property(&mut self, it: &mut oxc_ast::ast::ObjectProperty<'a>) { - let key = it.key.static_name(); + self.visit_property_key(&mut it.key); + let key = crate::utils::get_str_by_property_key(&it.key); let nested = self.nested_rules && (self.context == ReadContext::RuleRecord || key.as_deref().is_some_and(|key| { - key.contains('&') || key.starts_with([':', '_', '@']) || key == "selectors" + crate::extractor::extract_style_from_expression::is_nested_selector_key(key) + || key.starts_with(['_', '@']) + || key == "selectors" })); if !nested { - walk_mut::walk_object_property(self, it); + self.visit_expression(&mut it.value); return; } if let Expression::Identifier(identifier) = &it.value @@ -417,7 +524,6 @@ impl<'a> VisitMut<'a> for Reads<'_, 'a> { self.visit_expression(&mut it.value); self.context = context; self.source = outer; - self.visit_property_key(&mut it.key); } } diff --git a/libs/extractor/src/styled_source_tests/css_prop_captures.rs b/libs/extractor/src/styled_source_tests/css_prop_captures.rs index 2cb482ec9..c852ba670 100644 --- a/libs/extractor/src/styled_source_tests/css_prop_captures.rs +++ b/libs/extractor/src/styled_source_tests/css_prop_captures.rs @@ -61,3 +61,120 @@ fn authored_captures_survive_when_css_prop_renders_fixed_prepared_styled( let expected = serde_json::json!([[], "article", "article", 7, 8, "child", "child", trace]); assert_eq!(result, expected.to_string(), "css_prop={css_prop}\n{code}"); } + +#[rstest] +#[case("test.jsx")] +#[case("test.ts")] +#[serial] +fn selected_component_keeps_identity_when_css_prop_composes( + #[case] filename: &str, + #[values(false, true)] captured: bool, + #[values(false, true)] selected: bool, +) { + use super::css_prop_execution::{CAPTURED, css_props, element, execute}; + + // Given: selected Card with fixed styles, plus captured and capture-free controls. + let card = if captured { + CAPTURED + } else { + "const trace=[];let selected=true;const Card=styled.article({color:'green'});" + }; + let extra = if filename.ends_with('x') { + "ref={ref} children='child' className='external'" + } else { + "ref,children:'child',className:'external'" + }; + let plain = element(filename, extra); + let composed = element( + filename, + &css_props(filename, "{color:'blue',padding:'4px'}", extra), + ); + let source = format!( + "{card}selected={selected};const Parent=styled.section({{[Card]:{{outlineColor:'orange'}}}});const Plain=(trace,selected,ref)=>{plain};const App=(trace,selected,ref)=>{composed};" + ); + + // When: mixed callers render through emitted code, with caller-scope shadowing. + let result = execute( + filename, + &source, + "const selector=Object.values(rules).find(rule=>rule[0]==='outline-color'&&rule[1]==='orange')[3];const marker=selector.slice(selector.lastIndexOf('.')+1);const before=trace.slice();const first=render(Plain([],false,7));const raw=App([],false,8);const second=render(raw);selected=!selected;const third=render(Plain([],false,9));JSON.stringify([before,raw.target===Card,slots(raw).length,[first,second,third].map(e=>[e.target,e.props.ref,e.props.children,slots(e),e.props.className.split(/\\s+/).includes(marker),e.props.className.includes('external')]),values(first,'color'),values(second,'color'),values(second,'padding'),values(third,'color'),trace]);", + ); + + // Then: selected identity survives both Inline and Slot, without losing later-wins or effects. + let mut trace = Vec::new(); + if captured { + for active in [selected, selected, !selected] { + trace.push("controller"); + if active { + trace.push("leaf"); + } + } + } + assert_eq!( + result, + serde_json::json!([ + [], + captured, + usize::from(captured), + [ + ["article", 7, "child", [], true, true], + ["article", 8, "child", [], true, true], + ["article", 9, "child", [], true, true] + ], + ["green"], + ["blue"], + ["4px"], + ["green"], + trace + ]) + ); +} + +#[rstest] +#[case("test.jsx")] +#[case("test.ts")] +#[serial] +fn css_prop_composes_extension_when_base_is_selected( + #[case] filename: &str, + #[values(false, true)] selected_base: bool, +) { + use super::css_prop_execution::{CAPTURED, css_props, element, execute}; + + // Given: selecting a base must not change an extension's fixed native styles or captures. + let base = CAPTURED.replace("Card", "Base"); + let selector = if selected_base { + "const Parent=styled.section({[Base]:{outlineColor:'orange'}});" + } else { + "const Parent=styled.section({outlineColor:'orange'});" + }; + let extra = if filename.ends_with('x') { + "ref={7} children='child'" + } else { + "ref:7,children:'child'" + }; + let app = element(filename, &css_props(filename, "{color:'blue'}", extra)); + let source = + format!("{base}const Card=styled(Base)({{padding:'2px'}});{selector}const App=()=>{app};"); + + // When: the extension composes an overlapping CSS prop through its original renderer. + let result = execute( + filename, + &source, + "const raw=App();const final=render(raw);JSON.stringify([raw.target===Card,final.target,final.props.ref,final.props.children,values(final,'color'),values(final,'padding'),slots(final),trace]);", + ); + + // Then: adding selector identity preserves parent composition capability and authored effects. + assert_eq!( + result, + serde_json::json!([ + true, + "article", + 7, + "child", + ["blue"], + ["2px"], + [], + ["controller", "leaf"] + ]) + ); +} diff --git a/libs/extractor/src/styled_source_tests/css_prop_markers.rs b/libs/extractor/src/styled_source_tests/css_prop_markers.rs new file mode 100644 index 000000000..f92ac37dd --- /dev/null +++ b/libs/extractor/src/styled_source_tests/css_prop_markers.rs @@ -0,0 +1,290 @@ +use super::css_prop_execution::{CAPTURED, css_props, element, execute}; +use super::*; + +#[rstest] +#[case( + "{({css})=>
}
" +)] +#[case("
")] +#[case( + "{({css})=>
}
" +)] +#[case( + "{({css:outerCss})=>{({theme})=>
}
}
" +)] +#[serial] +fn selector_edges_match_when_template_entrypoints_read_component_markers(#[case] app: &str) { + // Given: selected components occur before template classification at either entrypoint. + let source = format!( + "import {{ClassNames}} from '@emotion/react';const Card=styled.article({{color:'green'}});const App=()=>{app};" + ); + + // When: the emitted parent and child render through the native JSX harness. + let result = execute( + "test.jsx", + &source, + "const outer=render(App());const child=render(outer.props.children);const entry=Object.entries(rules).find(([,rule])=>rule[0]==='color'&&rule[3]);const [name,rule]=entry;const marker=rule[3].slice(rule[3].lastIndexOf('.')+1);JSON.stringify([outer.target,child.target,outer.props.className.split(/\\s+/).includes(name),child.props.className.split(/\\s+/).filter(name=>name===marker).length,values(child,'color'),rule[1]]);", + ); + + // Then: the descendant declaration selects the rendered marker exactly once. + let color = if app.contains("theme.brand") { + "var(--brand)" + } else { + "red" + }; + assert_eq!( + result, + serde_json::json!(["section", "article", true, 1, ["green"], color]) + ); +} + +#[rstest] +#[case(".child", "& .child")] +#[case("#target", "& #target")] +#[case("[data-card]", "&[data-card]")] +#[case("*", "& *")] +#[case("> article", "& > article")] +#[case("+ article", "& + article")] +#[case("~ article", "& ~ article")] +#[case(":hover", "&:hover")] +#[case("&:hover", "&:hover")] +#[serial] +fn selector_edges_restore_alias_when_key_uses_shared_selector_policy( + #[case] key: &str, + #[case] selector: &str, +) { + // Given: every existing selector prefix reads an alias of retained CSS source. + let source = format!( + "const leaf=css({{outlineColor:'orange'}});const alias=leaf;const Parent=styled.section({{'{key}':alias}});const App=()=> ;" + ); + + // When: the emitted parent renders with its restored nested declaration. + let result = execute( + "test.jsx", + &source, + "const parent=render(App());const nested=atoms(parent).filter(rule=>rule[0]==='outline-color');JSON.stringify(nested.map(rule=>[rule[1],rule[3]]));", + ); + + // Then: the alias is rules, not a generated class string, under that selector. + assert_eq!(result, serde_json::json!([["orange", selector]])); +} + +#[rstest] +#[case("[Card]")] +#[case("[`&:hover ${Card}`]")] +#[case("[`${Card}`]")] +#[serial] +fn nested_css_source_survives_when_component_selector_reads_alias(#[case] key: &str) { + // Given: a nested rule reads the retained source of an aliased static CSS result. + let source = format!( + "const Card=styled.article({{color:'green'}});const leaf=css({{outlineColor:'orange'}});const alias=leaf;const Parent=styled.section({{{key}:alias}});const App=()=> ;" + ); + + // When: the selector and its component render from the combined extraction. + let result = execute( + "test.jsx", + &source, + "const rule=Object.values(rules).find(rule=>rule[0]==='outline-color'&&rule[3]);const marker=rule[3].slice(rule[3].lastIndexOf('.')+1);const child=render(App());JSON.stringify([rule[1],child.props.className.split(/\\s+/).includes(marker),values(child,'color')]);", + ); + + // Then: the nested declaration is restored rather than treating a class as a rule. + assert_eq!(result, serde_json::json!(["orange", true, ["green"]])); +} + +#[rstest] +#[case("styled.section({[Base]:{outlineColor:'orange'},[`&:hover ${Card}`]:{borderColor:'cyan'}})")] +#[case("styled.section`${Base}{outline-color:orange;} &:hover ${Card}{border-color:cyan;}`")] +#[serial] +fn component_selectors_survive_when_class_names_supplies_scoping( + #[case] parent: &str, + #[values(false, true)] captured: bool, +) { + // Given: ClassNames shares semantic IDs with selected, retargeted styled components. + let base = if captured { + CAPTURED.replace("Card", "Base") + } else { + "const trace=[];const Base=styled.article({color:'green'});".into() + }; + let source = format!( + "import {{ClassNames}} from '@emotion/react';{base}const Retarget=Base.withComponent('aside');const Card=styled(Retarget)({{padding:'2px'}});const Parent={parent};const Names=()=> {{({{css:outerCss}})=>{{({{css,theme}})=>({{title:((css)=>css())(()=>{{trace.push('shadow');return 'kept';}}),className:outerCss({{color:theme.brand}})}})}}}};const App=()=> ;" + ); + + // When: emitted callbacks render the component and the nested binding crossing. + let result = execute( + "test.jsx", + &source, + "const markers=['outline-color','border-color'].map(property=>Object.values(rules).find(rule=>rule[0]===property)[3]).map(selector=>selector.slice(selector.lastIndexOf('.')+1));const final=render(App());const names=Names();JSON.stringify([final.target,final.props.ref,final.props.children,markers.map(marker=>final.props.className.split(/\\s+/).filter(name=>name===marker).length),values(final,'color'),values(final,'padding'),slots(final),names.title,trace]);", + ); + + // Then: selector identities, later-wins styles, refs, captures and ordinary calls survive. + let trace = if captured { + vec!["controller", "leaf", "shadow"] + } else { + vec!["shadow"] + }; + assert_eq!( + result, + serde_json::json!([ + "aside", + 7, + "child", + [1, 1], + ["blue"], + ["2px"], + [], + "kept", + trace + ]) + ); +} + +#[rstest] +#[case("test.jsx")] +#[case("test.ts")] +#[serial] +fn identities_survive_when_native_chain_composes_and_retargets( + #[case] filename: &str, + #[values(false, true)] captured: bool, + #[values(false, true)] retarget: bool, +) { + // Given: four selected identities across tagged/object extensions and native retargeting. + let base = if captured { + CAPTURED.replace("Card", "Base") + } else { + "const trace=[];let selected=true;const Base=styled.article({color:'green'});".into() + }; + let retarget = if retarget { + "Middle.withComponent('aside')" + } else { + "styled(Middle)({})" + }; + let extra = if filename.ends_with('x') { + "ref={ref} children='child' className='external'" + } else { + "ref,children:'child',className:'external'" + }; + let plain = element(filename, extra); + let app = element( + filename, + &css_props(filename, "{color:'blue',padding:'4px'}", extra), + ); + let covered_extra = if filename.ends_with('x') { + "ref={ref} children='child' className={override+' external'}" + } else { + "ref,children:'child',className:override+' external'" + }; + let covered = element( + filename, + &css_props(filename, "{color:'blue',padding:'4px'}", covered_extra), + ); + let source = format!( + "{base}const Middle=styled(Base)`padding:2px;`;const Retarget={retarget};const Card=styled(Retarget)({{color:'green'}});const Parent=styled.section({{[Base]:{{outlineColor:'orange'}},[Middle]:{{borderColor:'cyan'}},[Retarget]:{{caretColor:'pink'}},[Card]:{{accentColor:'yellow'}}}});const override=css({{color:'purple',padding:'6px'}});const Plain=(trace,selected,ref)=>{plain};const App=(trace,selected,ref)=>{app};const Covered=(trace,selected,ref)=>{covered};" + ); + + // When: alternating no-CSS, replacement, covered-empty and no-CSS calls execute emitted code. + let result = execute( + filename, + &source, + "const identities=['outline-color','border-color','caret-color','accent-color'].map(property=>Object.values(rules).find(rule=>rule[0]===property)[3]).map(selector=>selector.slice(selector.lastIndexOf('.')+1));const before=trace.slice();const first=render(Plain([],false,7));const raw=App([],false,8);const second=render(raw);const covered=Covered([],false,9);const keys=slots(covered);const third=render(covered);selected=false;const fourth=render(Plain([],true,10));JSON.stringify([before,raw.target===Card,keys.length,keys.length===1&&covered.props[keys[0]]==='',identities.length,[first,second,third,fourth].map(e=>[e.target,e.props.ref,e.props.children,slots(e),identities.map(marker=>(e.props.className||'').split(/\\s+/).filter(name=>name===marker).length),e.props.className.includes('external'),values(e,'color'),values(e,'padding')]),trace]);", + ); + + // Then: all identities survive replacement exactly once, while later atoms and captures win. + let tag = if retarget.contains("withComponent") { + "aside" + } else { + "article" + }; + let trace = if captured { + vec![ + "controller", + "leaf", + "controller", + "leaf", + "controller", + "leaf", + "controller", + ] + } else { + vec![] + }; + assert_eq!( + result, + serde_json::json!([ + [], + captured, + usize::from(captured), + captured, + 4, + [ + [tag, 7, "child", [], [1, 1, 1, 1], true, ["green"], ["2px"]], + [tag, 8, "child", [], [1, 1, 1, 1], true, ["blue"], ["4px"]], + [tag, 9, "child", [], [1, 1, 1, 1], true, ["purple"], ["6px"]], + [tag, 10, "child", [], [1, 1, 1, 1], true, ["green"], ["2px"]] + ], + trace + ]) + ); +} + +#[rstest] +#[case("test.jsx")] +#[case("test.ts")] +#[serial] +fn opaque_classes_remain_when_nonoverlapping_css_prop_is_applied(#[case] filename: &str) { + // Given: a selected base carries an authored styling class, not a compiler identity. + let app = element(filename, &css_props(filename, "{padding:'4px'}", "")); + let source = format!( + "const Base=styled.article({{color:'green'}},'opaque');const Card=styled(Base)({{}});const Parent=styled.section({{[Base]:{{outlineColor:'orange'}}}});const App=()=>{app};" + ); + + // When: nonoverlapping CSS props render the opaque-class component without bypassing it. + let result = execute( + filename, + &source, + "const raw=App();const final=render(raw);JSON.stringify([raw.target===Card,slots(raw),final.props.className.split(/\\s+/).includes('opaque'),values(final,'color'),values(final,'padding')]);", + ); + + // Then: the original renderer and opaque class remain authoritative. + assert_eq!( + result, + serde_json::json!([true, [], true, ["green"], ["4px"]]) + ); +} + +#[rstest] +#[case("test.jsx")] +#[case("test.ts")] +#[serial] +fn opaque_classes_still_block_when_overlapping_css_prop_is_applied(#[case] filename: &str) { + // Given: known selector identity must not turn an opaque styling class into fixed styles. + let app = element(filename, &css_props(filename, "{color:'blue'}", "")); + let source = format!( + "import {{styled}} from '@devup-ui/react';import {{jsx}} from '@emotion/react';const Base=styled.article({{color:'green'}},'opaque');const Card=styled(Base)({{}});const Parent=styled.section({{[Base]:{{outlineColor:'orange'}}}});const App=()=>{app};" + ); + reset_class_map(); + reset_file_map(); + + // When: an overlapping CSS prop would require ordering opaque styling classes. + let error = extract( + filename, + &source, + ExtractOption { + single_css: true, + import_aliases: std::collections::HashMap::from([( + "@emotion/react".to_string(), + crate::ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + // Then: the existing located overlap error remains, rather than unchecked lowering. + assert!(error.starts_with(&format!("{filename}:1:")), "{error}"); + assert!( + error.contains("`css` on `` overrides styles `Card` sets"), + "{error}" + ); +} diff --git a/libs/extractor/src/styled_source_tests/mod.rs b/libs/extractor/src/styled_source_tests/mod.rs index 3d4bd4186..8830f0e6f 100644 --- a/libs/extractor/src/styled_source_tests/mod.rs +++ b/libs/extractor/src/styled_source_tests/mod.rs @@ -1,6 +1,7 @@ mod compatibility; mod css_prop_captures; mod css_prop_execution; +mod css_prop_markers; mod css_prop_slot_edges; mod css_prop_slot_members; mod css_prop_slot_wrapped_keys; diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index f59f332e1..570a48c39 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -989,6 +989,16 @@ pub(super) const CSS_PROP_VALUE: &str = "it must be a style object, CSS text, a pub(super) const LOCAL_STYLES: &str = "a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it"; +pub(super) const CLASS_NAMES_CHILD: &str = + "it takes only a child function of `{ css, cx, theme }` giving what it renders at once"; + +pub(super) const CLASS_NAMES_CALL: &str = "the `css` and `cx` its child function takes can only be called, as the build compiles each call"; + +pub(super) const CLASS_NAMES_PART: &str = "`css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these"; + +pub(super) const CLASS_NAMES_CLASS_MAP: &str = + "an object `cx` takes must give each class a condition, as `{ name: condition }`"; + /// The `css` prop of the styled component `element` sets what its own styles /// set, which the build cannot order there pub(super) fn css_prop_override_error(element: &str) -> String { diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 5c5816bad..498cb3983 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -2,7 +2,10 @@ use crate::as_visit::As; use crate::class_arguments::{class_arguments, flatten_classes}; use crate::component::ExportVariableKind; use crate::composition::{KnownPart, KnownSide, KnownStyles, overlaps, set_prop_order}; -use crate::css_prop::{CssProp, template_parts, theme_rules}; +use crate::css_prop::{ + ClassNamesParams, CssProp, THEME_READ, class_names_child, class_names_params, read_theme, + render_function, template_parts, theme_rules, +}; use crate::css_utils::{ TemplateStyles, css_to_style_template, keyframes_to_keyframes_style, optimize_css_block, template_css_text, @@ -24,8 +27,8 @@ use crate::extractor::{ }, extract_style_from_jsx::extract_style_from_jsx, extract_style_from_styled::{ - FORWARD_REF, StyledDefinition, StyledExtraction, extended, extract_style_from_styled, - read_forward, take_styled_modifiers, with_component, + FORWARD_REF, Naming, StyledDefinition, StyledExtraction, extended, + extract_style_from_styled, read_forward, take_styled_modifiers, with_component, }, styled_css_slot::{CssPropLowering, StyledContext, StyledCssSlot}, styled_jsx::StyledJsx, @@ -55,10 +58,12 @@ use oxc_ast::ast::{ ComputedMemberExpression, Expression, ExpressionStatement, FormalParameter, FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, ImportOrExportKind, JSXAttributeItem, JSXAttributeValue, JSXChild, JSXElement, JSXElementName, - JSXExpressionContainer, ObjectProperty, ObjectPropertyKind, Program, PropertyKey, PropertyKind, - Statement, StaticMemberExpression, Str, StringLiteral, UnaryOperator, VariableDeclarator, + JSXExpressionContainer, ObjectExpression, ObjectProperty, ObjectPropertyKind, Program, + PropertyKey, PropertyKind, Statement, StaticMemberExpression, Str, StringLiteral, + UnaryOperator, VariableDeclarator, }; use oxc_ast_visit::VisitMut; +use oxc_ast_visit::walk_mut; use oxc_ast_visit::walk_mut::{ walk_call_expression, walk_expression, walk_expression_statement, walk_import_declaration, walk_jsx_attribute_value, walk_jsx_child, walk_jsx_element, walk_program, @@ -69,10 +74,11 @@ use oxc_syntax::operator::LogicalOperator; use strum::IntoEnumIterator; use crate::utils::{ - CSS_PROP_VALUE, LOCAL_STYLES, ParsedStyleOrder, RUNTIME_VALUE, STYLE_OBJECT, StyleArguments, - Suspends, build_time_error, call_with_values, css_prop_error, css_prop_override_error, - element_error, expression_to_style_order, fixed_value, get_key_by_literal_expression, - get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, is_pure, + CLASS_NAMES_CALL, CLASS_NAMES_CHILD, CLASS_NAMES_CLASS_MAP, CLASS_NAMES_PART, CSS_PROP_VALUE, + LOCAL_STYLES, ParsedStyleOrder, RUNTIME_VALUE, STYLE_OBJECT, StyleArguments, Suspends, + build_time_error, call_with_values, css_prop_error, css_prop_override_error, element_error, + expression_to_style_order, fixed_value, get_key_by_literal_expression, get_str_by_property_key, + get_string_by_literal_expression, get_string_by_property_key, is_pure, jsx_expression_to_style_order, key_error, readable_argument, readable_code, reads_directly, reads_spreads_once, reads_unknown, runtime_classes, runtime_value, runtime_value_error, spread_error, stays_attribute, string_class, style_arguments, uncomposable_error, @@ -210,6 +216,11 @@ pub struct DevupVisitor<'a> { /// Local names bound to the `Global` component, whose `styles` prop declares /// global CSS instead of rendering markup. global_style_components: FxHashSet, + /// Import bindings of Emotion's `ClassNames`, whose element becomes + /// what its child function renders + class_names_components: FxHashSet, + /// What the `` child functions being compiled take + class_names_scope: Vec, /// `defineVars` members flattened to `"vars.key"` -> `"var(--x)"`, so a /// `stylex.create()` value referencing one resolves to a static CSS value. @@ -249,6 +260,11 @@ pub struct DevupVisitor<'a> { /// The styled component just built, by where it starts, for the `const` /// it initializes pending_styled: Option<(u32, StyledDefinition<'a>)>, + /// The styled bindings other styles select + selected_components: FxHashSet, + /// The marker class of the selected styled component the declaration + /// being visited defines, with where its definition starts + pending_marker: Option<(u32, String)>, /// Whether a generated styled component forwards refs through React's /// `forwardRef`, which the program then imports forwards_refs: bool, @@ -398,6 +414,9 @@ fn reads_binding( } impl<'a> DevupVisitor<'a> { + pub(crate) fn reuse_scoping(&mut self, scoping: Rc) { + self.style_values = crate::style_values::StyleValues::new(scoping); + } pub fn changed_bindings(&mut self, changed: crate::imported_constants::Changed) { self.changed_bindings = changed; } @@ -466,13 +485,26 @@ impl<'a> DevupVisitor<'a> { } self.unknown_arguments("css", &call.arguments); self.changed_arguments("css", &call.arguments); + let (result, known) = self.composed_class(call.span.start, parts); + if let Some(known) = known { + self.css_styles = Some((call.span.start, known)); + } + Some(result) + } + /// The classes `parts`, composed at `offset`, give, with the styles they + /// always set when no class the build does not know joins them + fn composed_class( + &mut self, + offset: u32, + parts: Vec>, + ) -> (Expression<'a>, Option>) { let mut composition = crate::composition::Composition::default(); let mut classes = Vec::new(); for part in parts { match part { KnownPart::Styles(side) => { - let props = self.part_props(call.span.start, side, None); + let props = self.part_props(offset, side, None); composition.apply(&self.ast, props); } KnownPart::Conditional { @@ -480,8 +512,8 @@ impl<'a> DevupVisitor<'a> { consequent, alternate, } => { - let consequent = self.part_props(call.span.start, consequent, None); - let alternate = self.part_props(call.span.start, alternate, None); + let consequent = self.part_props(offset, consequent, None); + let alternate = self.part_props(offset, alternate, None); composition.apply_conditional(&self.ast, &test, consequent, alternate); } KnownPart::Class(mut class) => { @@ -490,7 +522,7 @@ impl<'a> DevupVisitor<'a> { } } } - let known = composition.unconditional(); + let known = composition.unconditional().filter(|_| classes.is_empty()); let mut props = composition.into_props(); // Class names come out in reverse, so they read in composing order props.reverse(); @@ -498,14 +530,10 @@ impl<'a> DevupVisitor<'a> { gen_class_names(&self.ast, &mut props, None, self.split_filename.as_deref()); self.styles .extend(props.into_iter().flat_map(ExtractStyleProp::into_extract)); - let composed_class = classes.is_empty(); let result = merge_expression_for_class_name(&self.ast, classes.into_iter().chain(class_name)) .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)); - if composed_class && let Some(known) = known { - self.css_styles = Some((call.span.start, known)); - } - Some(result) + (result, known) } /// Whether `expression`, or a part of it, is a class whose styles the @@ -535,7 +563,9 @@ impl<'a> DevupVisitor<'a> { parts: &mut Vec>, text: Text, ) -> Option<()> { - let clone = |expression: &Expression<'a>| expression.clone_in(self.ast.allocator()); + let clone = |expression: &Expression<'a>| { + expression.clone_in_with_semantic_ids(self.ast.allocator()) + }; let (test, consequent, alternate) = match unwrap_syntax_only(expression) { Expression::ArrayExpression(array) => { for element in &array.elements { @@ -584,6 +614,13 @@ impl<'a> DevupVisitor<'a> { &conditional.consequent, Some(&conditional.alternate), ), + Expression::CallExpression(call) if self.class_names_text(&call.callee).is_some() => { + let text = self.class_names_text(&call.callee)?; + for argument in &call.arguments { + self.known_parts(argument.as_expression()?, parts, text)?; + } + return Some(()); + } expression => { match self.known_side(expression, text)? { KnownSide::Styles(side) => parts.push(KnownPart::Styles(side)), @@ -610,7 +647,9 @@ impl<'a> DevupVisitor<'a> { consequent: KnownSide<'a>, alternate: KnownSide<'a>, ) { - let clone = |expression: &Expression<'a>| expression.clone_in(self.ast.allocator()); + let clone = |expression: &Expression<'a>| { + expression.clone_in_with_semantic_ids(self.ast.allocator()) + }; let mut classes = [None, None]; let mut styles = [None, None]; for (index, side) in [consequent, alternate].into_iter().enumerate() { @@ -672,10 +711,40 @@ impl<'a> DevupVisitor<'a> { | Expression::ComputedMemberExpression(_) | Expression::StringLiteral(_) | Expression::TemplateLiteral(_) => Some(KnownSide::Class(code())), + Expression::CallExpression(call) if self.class_names_text(&call.callee).is_some() => { + self.folded_side(expression, text) + } + Expression::ArrayExpression(_) if !self.class_names_scope.is_empty() => { + self.folded_side(expression, text) + } _ => None, } } + /// The parts `expression` composes as one side of a condition: only + /// styles, or only classes, which join + fn folded_side(&self, expression: &Expression<'a>, text: Text) -> Option> { + let mut parts = Vec::new(); + self.known_parts(expression, &mut parts, text)?; + let mut styles = Vec::new(); + let mut classes = Vec::new(); + for part in parts { + match part { + KnownPart::Styles(side) => styles.extend(side), + KnownPart::Class(class) => classes.push(class), + KnownPart::Conditional { .. } => return None, + } + } + match (styles.is_empty(), classes.is_empty()) { + (true, true) => Some(KnownSide::Empty), + (false, true) => Some(KnownSide::Styles(styles)), + (true, false) => { + merge_expression_for_class_name(&self.ast, classes).map(KnownSide::Class) + } + (false, false) => None, + } + } + /// The styles of a part, each at the order the part gives it; `element` /// names the element taking them as its `css` prop, which sets a value /// only the runtime gives as a CSS variable @@ -756,6 +825,8 @@ impl<'a> DevupVisitor<'a> { stylex_imports: FxHashSet::default(), stylex_named_imports: FxHashMap::default(), global_style_components: FxHashSet::default(), + class_names_components: FxHashSet::default(), + class_names_scope: Vec::new(), stylex_var_refs: FxHashMap::default(), stylex_var_names: FxHashMap::default(), stylex_theme_classes: FxHashMap::default(), @@ -774,6 +845,8 @@ impl<'a> DevupVisitor<'a> { style_values: crate::style_values::StyleValues::default(), css_styles: None, pending_styled: None, + selected_components: FxHashSet::default(), + pending_marker: None, forwards_refs: false, styled_definitions: FxHashMap::default(), imported_css: FxHashMap::default(), @@ -799,7 +872,14 @@ impl<'a> DevupVisitor<'a> { /// The style function `callee` names: `css`, `Devup.keyframes`, ... fn util_type(&self, callee: &Expression<'a>) -> Option> { match callee { - Expression::Identifier(ident) => self.util_imports.get(ident.name.as_str()), + Expression::Identifier(ident) => { + if !self.class_names_components.is_empty() + && !self.style_values.reads_root_binding(ident) + { + return None; + } + self.util_imports.get(ident.name.as_str()) + } Expression::StaticMemberExpression(member) if !self.util_imports.is_empty() => { let Expression::Identifier(ident) = &member.object else { return None; @@ -911,6 +991,7 @@ impl<'a> DevupVisitor<'a> { && import.specifiers.iter().flatten().any(|specifier| match specifier { ImportSpecifier(specifier) => { matches!(specifier.imported.name().as_str(), "css" | "keyframes" | "styled") + || (import.source.value == self.compat_package.as_str() && specifier.imported.name() == "ClassNames") } _ => true, })) @@ -1076,6 +1157,7 @@ impl<'a> DevupVisitor<'a> { .util_type(&tag.tag) .is_some_and(|util| matches!(util.as_ref(), UtilType::Css)) => { + self.style_values.read_in_text(&self.ast, &mut tag.quasi); match template_parts(&self.ast, &tag.quasi, true) { Ok(parts) => Expression::new_array_expression( SPAN, @@ -1311,6 +1393,7 @@ impl<'a> DevupVisitor<'a> { .collect() }; let definition = symbol.and_then(|symbol| self.styled_definitions.get(&symbol)); + let markers = definition.and_then(|definition| definition.marker_classes(&self.ast)); let composed = definition.filter(|_| renders).and_then(|definition| { definition .inline() @@ -1325,10 +1408,16 @@ impl<'a> DevupVisitor<'a> { }) }); if let Some((lowering, styles)) = composed { - return Some(( - lowering, - self.compose_css_prop(element, css, styles, class_name), - )); + let markers = match &lowering { + CssPropLowering::Inline(_) => markers, + CssPropLowering::Retained | CssPropLowering::Slot(_) => None, + }; + let mut props = self.compose_css_prop(element, css, styles, class_name); + props.extend(markers.map(|expression| ExtractStyleProp::Expression { + expression, + styles: vec![], + })); + return Some((lowering, props)); } let own = definition.map(|definition| clone(definition.styles())); let offset = css.offset; @@ -1487,6 +1576,358 @@ impl<'a> DevupVisitor<'a> { add_class_and_style_to_object(&self.ast, &mut props.properties, class_name, style); } } + + /// What Emotion's `` element renders, its classes compiled; + /// `None` for any other element + fn class_names(&mut self, element: &mut JSXElement<'a>) -> Option> { + let JSXElementName::IdentifierReference(name) = &element.opening_element.name else { + return None; + }; + if !self + .style_values + .reference_symbol(name) + .is_some_and(|symbol| self.class_names_components.contains(&symbol)) + { + return None; + } + Some( + self.compile_class_names(element) + .unwrap_or_else(|(at, code)| { + self.errors + .push((at, element_error("ClassNames", &code, CLASS_NAMES_CHILD))); + Expression::new_null_literal(SPAN, &self.ast) + }), + ) + } + + /// `{({ css, cx, theme }) => rendered}` as + /// `rendered`: its `css` and `cx` calls compile as `css()`, and the theme + /// they read becomes the CSS variables the `ThemeProvider` sets. `Err` + /// holds where and what the build cannot compile. + fn compile_class_names( + &mut self, + element: &mut JSXElement<'a>, + ) -> Result, (u32, String)> { + if let Some(attribute) = element.opening_element.attributes.first() { + return Err((attribute.span().start, "an attribute".to_string())); + } + let span = element.span; + let mut children = element + .children + .iter_mut() + .filter(|child| !matches!(child, JSXChild::Text(text) if text.value.trim().is_empty())); + let (Some(JSXChild::ExpressionContainer(container)), None) = + (children.next(), children.next()) + else { + return Err((span.start, "children".to_string())); + }; + let Some(function) = container.expression.as_expression_mut() else { + return Err((container.span.start, "{}".to_string())); + }; + let function_at = function.span().start; + let function_code = readable_code(function); + let Some((names, body)) = render_function(function) + .and_then(|(params, body)| Some((class_names_params(params)?, body))) + else { + return Err((function_at, function_code)); + }; + let mut rendered = body.take_in(&self.ast); + // The outer preparation walks nested callbacks with their active bindings once. + if self.class_names_scope.is_empty() { + let mut calls = ClassNamesCalls { + ast: &self.ast, + names: vec![names], + components: &self.class_names_components, + style_values: &self.style_values, + unread: None, + }; + calls.visit_expression(&mut rendered); + if let Some((unread, requirement)) = calls.unread { + self.errors.push(( + unread.span().start, + element_error("ClassNames", &readable_code(&unread), requirement), + )); + } + } + self.class_names_scope.push(names); + self.visit_expression(&mut rendered); + self.class_names_scope.pop(); + Ok(rendered) + } + + /// What a call of the `css` or `cx` a `` child function + /// takes composes: `css` reads text as CSS, `cx` as classes + fn class_names_text(&self, callee: &Expression<'a>) -> Option { + let Expression::Identifier(callee) = callee else { + return None; + }; + let symbol = self.style_values.reference_symbol(callee); + self.class_names_scope.iter().rev().find_map(|names| { + if ClassNamesParams::is(names.css, symbol) { + Some(Text::Rules) + } else if ClassNamesParams::is(names.cx, symbol) { + Some(Text::Classes) + } else { + None + } + }) + } + + /// Replace `it`, when a call of the `css` or `cx` a `` child + /// function takes, with the classes it composes + fn compile_class_names_call(&mut self, it: &mut Expression<'a>) -> bool { + let Expression::CallExpression(call) = it else { + return false; + }; + if self.class_names_text(&call.callee).is_none() { + return false; + } + let offset = call.span.start; + self.unknown_arguments("css", &call.arguments); + self.changed_arguments("css", &call.arguments); + let mut parts = Vec::new(); + *it = if self.known_parts(it, &mut parts, Text::Classes).is_some() { + self.composed_class(offset, parts).0 + } else { + self.errors.push(( + offset, + element_error("ClassNames", &readable_code(it), CLASS_NAMES_PART), + )); + Expression::new_string_literal(SPAN, "", None, &self.ast) + }; + true + } +} + +/// The calls of the `css` and `cx` a `` child function takes, +/// readied to compose: the theme their rules read becomes the CSS variables +/// the `ThemeProvider` sets, an object `cx` takes the conditions of its +/// classes, and CSS text `css` tags the parts around its mixins. The first +/// code the build cannot ready so is kept. +struct ClassNamesCalls<'r, 'a> { + ast: &'r AstBuilder<'a>, + names: Vec, + components: &'r FxHashSet, + style_values: &'r crate::style_values::StyleValues<'a>, + unread: Option<(Expression<'a>, &'static str)>, +} + +impl<'a> ClassNamesCalls<'_, 'a> { + fn calls(&self, callee: &Expression<'a>) -> Option { + let symbol = self.style_values.symbol(callee); + self.names.iter().rev().find_map(|names| { + if ClassNamesParams::is(names.css, symbol) { + Some(Text::Rules) + } else if ClassNamesParams::is(names.cx, symbol) { + Some(Text::Classes) + } else { + None + } + }) + } + + fn read_theme(&mut self, expression: &mut Expression<'a>, value: bool) { + if let Err(unread) = read_theme(self.ast, expression, &self.names, value, &|identifier| { + self.style_values.reference_symbol(identifier) + }) { + self.unread.get_or_insert((unread, THEME_READ)); + } + } + + /// `part` of what `cx` composes with each object in it written as the + /// conditions of the classes it names + fn class_maps(&mut self, part: &mut Expression<'a>) { + let classes = match unwrap_syntax_only_mut(part) { + number @ (Expression::NumericLiteral(_) | Expression::UnaryExpression(_)) => { + if let Expression::UnaryExpression(unary) = number + && !matches!(unary.argument, Expression::NumericLiteral(_)) + { + return; + } + let Some(number) = crate::utils::js_number_literal(number) else { + return; + }; + let text = if number == 0.0 { + String::new() + } else { + crate::utils::js_number_string(number) + }; + Expression::new_string_literal( + SPAN, + Str::from_in(text.as_str(), self.ast.allocator()), + None, + self.ast, + ) + } + Expression::ArrayExpression(array) => { + for part in &mut array.elements { + if let Some(part) = part.as_expression_mut() { + self.class_maps(part); + } + } + return; + } + Expression::ConditionalExpression(conditional) => { + self.class_maps(&mut conditional.consequent); + self.class_maps(&mut conditional.alternate); + return; + } + Expression::LogicalExpression(logical) => { + if logical.operator != LogicalOperator::And { + self.class_maps(&mut logical.left); + } + self.class_maps(&mut logical.right); + return; + } + Expression::ObjectExpression(object) => self.class_map(object), + _ => return, + }; + *part = classes; + } + + /// `{ name: condition }` as `[condition && 'name']` + fn class_map(&mut self, object: &mut ObjectExpression<'a>) -> Expression<'a> { + let mut classes = oxc_allocator::Vec::new_in(self.ast); + for property in object.properties.drain(..) { + let mut property = match property { + ObjectPropertyKind::ObjectProperty(property) + if property.kind == PropertyKind::Init && !property.method => + { + property + } + ObjectPropertyKind::ObjectProperty(mut property) => { + let value = property.value.take_in(self.ast); + self.unread.get_or_insert((value, CLASS_NAMES_CLASS_MAP)); + continue; + } + ObjectPropertyKind::SpreadProperty(mut spread) => { + let argument = spread.argument.take_in(self.ast); + self.unread.get_or_insert((argument, CLASS_NAMES_CLASS_MAP)); + continue; + } + }; + let class = if property.computed + && let Some(key) = property.key.as_expression_mut() + { + key.take_in(self.ast) + } else { + let name = property.key.static_name().unwrap_or_default(); + Expression::new_string_literal( + SPAN, + Str::from_in(name.as_ref(), self.ast.allocator()), + None, + self.ast, + ) + }; + let condition = property.value.take_in(self.ast); + classes.push( + Expression::new_logical_expression( + SPAN, + condition, + LogicalOperator::And, + class, + self.ast, + ) + .into(), + ); + } + Expression::new_array_expression(SPAN, classes, self.ast) + } +} + +impl<'a> VisitMut<'a> for ClassNamesCalls<'_, 'a> { + fn visit_jsx_element(&mut self, element: &mut JSXElement<'a>) { + let names = match &element.opening_element.name { + JSXElementName::IdentifierReference(name) + if self + .style_values + .reference_symbol(name) + .is_some_and(|symbol| self.components.contains(&symbol)) => + { + class_names_child(element) + } + _ => None, + }; + if let Some(names) = names { + self.names.push(names); + } + walk_jsx_element(self, element); + if names.is_some() { + self.names.pop(); + } + } + + fn visit_expression(&mut self, it: &mut Expression<'a>) { + let Expression::TaggedTemplateExpression(tagged) = it else { + walk_mut::walk_expression(self, it); + return; + }; + if self.calls(&tagged.tag) != Some(Text::Rules) { + walk_mut::walk_expression(self, it); + return; + } + for expression in &mut tagged.quasi.expressions { + self.read_theme(expression, true); + self.visit_expression(expression); + } + self.style_values.read_in_text(self.ast, &mut tagged.quasi); + *it = match template_parts(self.ast, &tagged.quasi, true) { + Ok(parts) => Expression::new_call_expression( + SPAN, + tagged.tag.take_in(self.ast), + None::>>, + oxc_allocator::Vec::from_iter_in(parts.into_iter().map(Argument::from), self.ast), + false, + self.ast, + ), + Err(unplaced) => { + self.unread.get_or_insert(unplaced); + Expression::new_identifier(SPAN, "undefined", self.ast) + } + }; + } + + fn visit_call_expression(&mut self, call: &mut CallExpression<'a>) { + let Some(text) = self.calls(&call.callee) else { + walk_call_expression(self, call); + return; + }; + for argument in &mut call.arguments { + let part = match argument { + Argument::SpreadElement(spread) => &mut spread.argument, + argument => argument.to_expression_mut(), + }; + if text == Text::Classes { + self.class_maps(part); + } + self.read_theme(part, false); + self.visit_expression(part); + } + } + + /// A read of the theme or of `css` and `cx` the calls do not take + fn visit_identifier_reference(&mut self, it: &mut oxc_ast::ast::IdentifierReference<'a>) { + let symbol = self.style_values.reference_symbol(it); + let requirement = if self + .names + .iter() + .any(|names| ClassNamesParams::is(names.theme, symbol)) + { + THEME_READ + } else if self.names.iter().any(|names| { + ClassNamesParams::is(names.css, symbol) || ClassNamesParams::is(names.cx, symbol) + }) { + CLASS_NAMES_CALL + } else { + return; + }; + self.unread.get_or_insert_with(|| { + ( + Expression::new_identifier(it.span, it.name, self.ast), + requirement, + ) + }); + } } impl<'a> DevupVisitor<'a> { @@ -2220,15 +2661,20 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { fn visit_program(&mut self, it: &mut Program<'a>) { self.styled_jsx = StyledJsx::reserve(it); self.styled_css_slot = Some(StyledCssSlot::reserve(it)); - if self.binds_style_results(it) || self.css_prop != CssProp::Off { - self.style_values = crate::style_values::StyleValues::new( + if !self.style_values.has_scoping() + && (self.binds_style_results(it) || self.css_prop != CssProp::Off) + { + self.style_values = crate::style_values::StyleValues::new(Rc::new( oxc_semantic::SemanticBuilder::new() .build(it) .semantic .into_scoping(), - ); - self.style_values - .import(std::mem::take(&mut self.imported_css)); + )); + } + self.style_values + .import(std::mem::take(&mut self.imported_css)); + if self.style_values.has_scoping() { + self.selected_components = crate::style_values::selected(it, &self.style_values); } walk_program(self, it); self.style_values.read_names(&self.ast, it); @@ -2302,6 +2748,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } fn visit_expression(&mut self, it: &mut Expression<'a>) { + if !self.class_names_scope.is_empty() && self.compile_class_names_call(it) { + return; + } if !self.styled_imports.is_empty() { match it { Expression::CallExpression(call) => self.plain_styled(&mut call.callee), @@ -2390,6 +2839,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { .and_then(|symbol| self.styled_definitions.get(&symbol)) .filter(|definition| definition.extendable()); let start = it.span().start; + let marker = self + .pending_marker + .take_if(|(at, _)| *at == start) + .map(|(_, marker)| marker); let StyledExtraction { result, expression, @@ -2398,7 +2851,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } = extract_style_from_styled( &self.ast, it, - self.split_filename.as_deref(), + Naming { + split_filename: self.split_filename.as_deref(), + marker: marker.as_deref(), + }, &self.imports, (&attrs, forward), inherited, @@ -2440,7 +2896,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { &self.ast, definition, call.arguments[0].to_expression(), - self.split_filename.as_deref(), + Naming { + split_filename: self.split_filename.as_deref(), + marker: self + .pending_marker + .as_ref() + .filter(|(at, _)| *at == call.span.start) + .map(|(_, marker)| marker.as_str()), + }, StyledContext { jsx: &mut self.styled_jsx, css_slot: self.styled_css_slot.as_ref(), @@ -2448,6 +2911,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ) { let start = call.span.start; + self.pending_marker.take_if(|(at, _)| *at == start); self.forwards_refs = true; *it = component; self.pending_styled = Some((start, definition)); @@ -3591,6 +4055,19 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.local_styles.insert(symbol); } + let marked = styled_binding + .filter(|symbol| self.selected_components.contains(symbol)) + .zip(start) + .zip(it.id.get_binding_identifier().map(|id| id.name.to_string())); + if let Some(((symbol, start), name)) = &marked { + let (symbol, start) = (*symbol, *start); + let marker = css::component_marker(name, &self.filename); + self.style_values.insert( + symbol, + crate::style_values::StyleValue::Component(format!(".{marker}")), + ); + self.pending_marker = Some((start, marker)); + } let class_text = match &it.init { Some(Expression::StringLiteral(text)) => Some(text.value.to_string()), Some(Expression::TemplateLiteral(template)) if template.expressions.is_empty() => { @@ -3606,6 +4083,12 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { walk_variable_declarator(self, it); + // A binding selected but not to a styled component stays unread + if let Some(((symbol, _), _)) = marked + && self.pending_marker.take().is_some() + { + self.style_values.remove(symbol); + } if let Some(symbol) = self.style_values.constant(&it.id) && let Some(value) = &it.init { @@ -3730,6 +4213,13 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.jsx_imports.insert(local, imported_str); continue; } + if compat && imported_str == "ClassNames" { + self.class_names_components + .extend(import.local.symbol_id.get()); + self.compiled_names.insert(local); + specifiers.remove(i); + continue; + } if let Ok(kind) = imported_str.parse::() { self.imports.insert(local.clone(), kind); } else if let Some(kind) = UtilType::from_str_opt(&imported_str) { @@ -3781,6 +4271,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } #[allow(clippy::set_contains_or_insert)] fn visit_jsx_element(&mut self, elem: &mut JSXElement<'a>) { + if let Some(rendered) = self.class_names(elem) { + self.pending_replacement = Some(rendered); + return; + } let css = self.take_css_prop(elem); walk_jsx_element(self, elem); diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index af359f0e4..a742e2452 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -10,8 +10,10 @@ import { createSharedSlotReservation, createThemeInterfaceArgs, type CustomShorthands, + isProjectJsx, loadDevupConfig, mergeImportAliases, + readJsxImportSource, seedFileNumbers, tailwindCssFiles, withTailwindCss, @@ -39,7 +41,7 @@ const devupFile = 'devup.json' const distDir = 'df' const cssDir = resolve(distDir, cssDirName) const singleCss = true -const importAliases = mergeImportAliases() +const importAliases = mergeImportAliases(undefined, readJsxImportSource()) // The packages whose imports the extractor compiles: Devup UI, the packages it // takes the place of, and StyleX const compiledPackages = [ @@ -134,7 +136,10 @@ async function loadSourceFile(filePath: string, bundling: boolean) { : 'js' const contents = await Bun.file(filePath).text() - if (importsCompiledPackage(contents, loader)) { + if ( + isProjectJsx(filePath, importAliases) || + importsCompiledPackage(contents, loader) + ) { const code = codeExtract( filePath, contents, @@ -212,6 +217,7 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { collectNumberedFiles({ roots: [resolve('src')], needles: compiledPackages, + importAliases, toId: (path) => path, }), ) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index b088582cd..dd439be50 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -89,6 +89,7 @@ let codeExtractSpy: ReturnType let codeExtractWithoutSourceMapSpy: ReturnType let devupUIWebpackPluginSpy: ReturnType let startCoordinatorSpy: ReturnType +let readJsxImportSourceSpy: ReturnType let originalEnv: NodeJS.ProcessEnv let originalEnvValues: NodeJS.ProcessEnv @@ -143,6 +144,10 @@ beforeEach(() => { coordinatorModule, 'startCoordinator', ).mockReturnValue({ close: mock() as () => void }) + readJsxImportSourceSpy = spyOn( + importGraphModule, + 'readJsxImportSource', + ).mockReturnValue(undefined) setWasmForTesting(wasm) setWebpackPluginForTesting(webpackPluginModule) @@ -164,6 +169,7 @@ afterEach(() => { process.env = originalEnv global.fetch = originalFetch process.debugPort = originalDebugPort + readJsxImportSourceSpy.mockRestore() existsSyncSpy.mockRestore() mkdirSyncSpy.mockRestore() readFileSyncSpy.mockRestore() diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index b466605a4..435c73c2a 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -20,6 +20,7 @@ import { extractedNeedles, loadDevupConfigSync, mergeImportAliases, + readJsxImportSource, seedFileNumbers, type StaticImportGraph, withTailwindCss, @@ -159,7 +160,10 @@ export function DevupUI( atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 - const importAliases = mergeImportAliases(userImportAliases) + const importAliases = mergeImportAliases( + userImportAliases, + readJsxImportSource(), + ) const watch = process.env.NODE_ENV === 'development' const sourceMap = watch || config.productionBrowserSourceMaps === true const sheetFile = join(distDir, 'sheet.json') @@ -352,6 +356,7 @@ export function DevupUI( include, cwd, needles: extractedNeedles(libPackage, importAliases), + importAliases, toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), ) diff --git a/packages/plugin-utils/src/__tests__/create-compat-types.test.ts b/packages/plugin-utils/src/__tests__/create-compat-types.test.ts index 0255bc589..e796b6ada 100644 --- a/packages/plugin-utils/src/__tests__/create-compat-types.test.ts +++ b/packages/plugin-utils/src/__tests__/create-compat-types.test.ts @@ -47,6 +47,20 @@ describe('createCompatTypes', () => { ) }) + it("types Emotion's JSX runtime where the project builds JSX with it", () => { + expect( + createCompatTypes(mergeImportAliases(undefined, '@emotion/react')), + ).toBe( + [ + '/// ', + '/// ', + '/// ', + '/// ', + '/// ', + ].join('\n') + '\n', + ) + }) + it('still emits stylex when nothing is aliased', () => { expect(createCompatTypes({})).toBe( '/// \n', diff --git a/packages/plugin-utils/src/__tests__/jsx-import-source.test.ts b/packages/plugin-utils/src/__tests__/jsx-import-source.test.ts new file mode 100644 index 000000000..8b9c9d6d8 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/jsx-import-source.test.ts @@ -0,0 +1,119 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { readJsxImportSource } from '../jsx-import-source' + +describe('readJsxImportSource', () => { + let cwd: string + + function write(path: string, content: string) { + mkdirSync(dirname(join(cwd, path)), { recursive: true }) + writeFileSync(join(cwd, path), content) + } + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'devup-ui-jsx-import-source-')) + }) + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) + }) + + it('reads nothing without a config', () => { + expect(readJsxImportSource(cwd)).toBeUndefined() + }) + + it('reads the default directory when none is given', () => { + expect(readJsxImportSource()).toBe(readJsxImportSource(process.cwd())) + }) + + it('reads tsconfig, with the comments and trailing commas it allows', () => { + write( + 'tsconfig.json', + `{ + // the JSX runtime + "compilerOptions": { + /* Emotion's */ "jsxImportSource": "@emotion/react", + "paths": { "a\\"b": ["./src/*"], }, + }, +}`, + ) + write( + 'jsconfig.json', + '{ "compilerOptions": { "jsxImportSource": "preact" } }', + ) + expect(readJsxImportSource(cwd)).toBe('@emotion/react') + }) + + it('reads jsconfig when there is no tsconfig', () => { + write( + 'jsconfig.json', + '{ "compilerOptions": { "jsxImportSource": "preact" } }', + ) + expect(readJsxImportSource(cwd)).toBe('preact') + }) + + it('follows extends, a later entry overriding an earlier one', () => { + write( + 'tsconfig.json', + '{ "extends": ["./base/first", "@acme/config", "./base/second.json"], "compilerOptions": { "jsxImportSource": 1 } }', + ) + write( + 'base/first.json', + '{ "compilerOptions": { "jsxImportSource": "first" } }', + ) + write('base/second.json', '{ "compilerOptions": {} }') + write( + 'node_modules/@acme/config/tsconfig.json', + '{ "compilerOptions": { "jsxImportSource": "@emotion/react" } }', + ) + expect(readJsxImportSource(cwd)).toBe('@emotion/react') + }) + + it("follows a package's main config", () => { + write('tsconfig.json', '{ "extends": "@acme/main" }') + write( + 'node_modules/@acme/main/package.json', + '{ "name": "@acme/main", "main": "base.json" }', + ) + write( + 'node_modules/@acme/main/base.json', + '{ "compilerOptions": { "jsxImportSource": "@emotion/react" } }', + ) + expect(readJsxImportSource(cwd)).toBe('@emotion/react') + }) + + it('follows project references to a file or a directory', () => { + write( + 'tsconfig.json', + '{ "files": [], "extends": ["@acme/missing", "./missing"], "references": [1, { "path": 2 }, { "path": "./gone" }, { "path": "./tsconfig.node.json" }, { "path": "./app" }] }', + ) + write('tsconfig.node.json', '{ "compilerOptions": {} }') + write( + 'app/tsconfig.json', + '{ "compilerOptions": { "jsxImportSource": "@emotion/react" } }', + ) + expect(readJsxImportSource(cwd)).toBe('@emotion/react') + }) + + it('stops at configs extending each other', () => { + write('tsconfig.json', '{ "extends": "./other.json" }') + write('other.json', '{ "extends": "./tsconfig.json" }') + expect(readJsxImportSource(cwd)).toBeUndefined() + }) + + it('reads nothing from a config it cannot parse', () => { + write('tsconfig.json', '{ "compilerOptions": /* unterminated') + expect(readJsxImportSource(cwd)).toBeUndefined() + write('tsconfig.json', '{ "extends": "./base" } // last line') + write('base', '{ "compilerOptions": { "jsxImportSource": "base" } }') + expect(readJsxImportSource(cwd)).toBe('base') + write('tsconfig.json', '{ "compilerOptions": [1, 2,] }') + expect(readJsxImportSource(cwd)).toBeUndefined() + write('tsconfig.json', '{ "compilerOptions": {} },') + expect(readJsxImportSource(cwd)).toBeUndefined() + }) +}) diff --git a/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts b/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts index 34dca997b..d7d6cf7e2 100644 --- a/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts +++ b/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts @@ -82,6 +82,29 @@ describe('mergeImportAliases', () => { // null = named exports 1:1 mapping expect(result['named-export-lib']).toBeNull() }) + + it("aliases Emotion's JSX runtime where the project builds JSX with it", () => { + expect(mergeImportAliases(undefined, '@emotion/react')).toEqual({ + '@emotion/react': null, + '@emotion/react/jsx-runtime': null, + '@emotion/styled': 'styled', + '@emotion/css': null, + 'styled-components': 'styled', + '@vanilla-extract/css': null, + }) + expect( + mergeImportAliases({ '@emotion/react': false }, '@emotion/react'), + ).not.toHaveProperty('@emotion/react/jsx-runtime') + expect( + mergeImportAliases( + { '@emotion/react/jsx-runtime': false }, + '@emotion/react', + ), + ).not.toHaveProperty('@emotion/react/jsx-runtime') + expect(mergeImportAliases(undefined, 'preact')).not.toHaveProperty( + '@emotion/react/jsx-runtime', + ) + }) }) describe('DEFAULT_IMPORT_ALIASES', () => { diff --git a/packages/plugin-utils/src/__tests__/project-jsx.test.ts b/packages/plugin-utils/src/__tests__/project-jsx.test.ts new file mode 100644 index 000000000..5ce8fe44e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/project-jsx.test.ts @@ -0,0 +1,143 @@ +import { relative } from 'node:path' + +import { expect, it } from 'bun:test' + +import { collectNumberedFiles, extractedNeedles } from '../numbering' +import { collectReservationInputs } from '../reservation-discovery' +import { createSharedSlotReservation } from '../shared-slots' +import type { WasmImportAliases } from '../types' +import { fixture, flags, recorder, STYLE } from './reservation-fixture' + +const jsx = "export const view=" +const enabled = { + '@emotion/react': null, + '@emotion/react/jsx-runtime': null, +} satisfies WasmImportAliases + +for (const [name, importAliases, expected] of [ + ['off', {}, false], + ['react only', { '@emotion/react': null }, false], + ['runtime only', { '@emotion/react/jsx-runtime': null }, false], + ['both keys', enabled, true], +] satisfies readonly (readonly [string, WasmImportAliases, boolean])[]) { + it(`selects project JSX for numbering and reservation when ${name}`, () => { + // Given: package-backed source remains eligible independently of project JSX. + using fs = fixture() + fs.write('src/A.tsx', jsx) + fs.write('src/B.jsx', jsx) + fs.write('src/legacy.ts', STYLE) + fs.write('src/plain.ts', 'export const css=1') + fs.write('src/plain.js', 'export const css=1') + fs.write('src/ignored.test.tsx', jsx) + fs.write('src/__tests__/ignored.test.jsx', jsx) + fs.write('src/node_modules/ignored.jsx', jsx) + const options = { + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases, + needles: extractedNeedles('@devup-ui/react', importAliases), + toId: (path: string) => relative(fs.root, path).replaceAll('\\', '/'), + } + // When + const numbered = collectNumberedFiles({ + ...options, + roots: [fs.root + '/src'], + }) + const reserved = collectReservationInputs(options) + // Then + const files = expected + ? ['src/A.tsx', 'src/B.jsx', 'src/legacy.ts'] + : ['src/legacy.ts'] + expect(numbered).toEqual(files) + expect(reserved.inputs.map(({ filename }) => filename)).toEqual(files) + }) +} + +it('reserves importless entries and children with the same rows under reversed roots', () => { + // Given: neither the entry outside source roots nor its child mentions a package. + using fs = fixture() + fs.write( + 'entry/main.tsx', + "import '../children/view'; export const main=
", + ) + fs.write('children/view.jsx', jsx) + fs.write('src/Z.tsx', jsx) + fs.write('other/A.jsx', jsx) + fs.write('node_modules/@acme/ui/dist/Card.jsx', jsx) + fs.write('node_modules/@acme/ui/node_modules/ignored.jsx', jsx) + fs.write('node_modules/not-included/ignored.jsx', jsx) + const options = { + roots: ['src', 'other'], + entries: [{ request: './entry/main.tsx', context: fs.root }], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: enabled, + include: ['@acme/ui'], + toId: (path: string) => + `raw\\${relative(fs.root, path).replaceAll('/', '\\')}`, + } + const first = recorder() + const second = recorder() + // When: real shared managers discover and deliver the batch to the engine boundary. + createSharedSlotReservation(first.engine).reserve(options, flags) + createSharedSlotReservation(second.engine).reserve( + { ...options, roots: ['other', 'src'] }, + flags, + ) + // Then + expect(first.packets).toEqual(second.packets) + expect(first.packets[0]?.rows.map(([filename]) => filename)).toEqual([ + 'raw\\children\\view.jsx', + 'raw\\entry\\main.tsx', + 'raw\\node_modules\\@acme\\ui\\dist\\Card.jsx', + 'raw\\other\\A.jsx', + 'raw\\src\\Z.tsx', + ]) +}) + +it('numbers project JSX by engine filename without widening physical-source exclusions', () => { + // Given: a virtual engine JSX ID and a physical JSX file compiled under a non-JSX ID. + using fs = fixture() + fs.write('src/generated.ts', 'export const view=1') + fs.write('src/authored.tsx', jsx) + fs.write('src/ignored.spec.ts', jsx) + const options = { + roots: [fs.root + '/src'], + importAliases: enabled, + needles: extractedNeedles('@devup-ui/react', enabled), + toId: (path: string) => + path.endsWith('generated.ts') + ? 'owned\\generated.tsx' + : 'owned/authored.js', + } + // When + const files = collectNumberedFiles(options) + // Then + expect(files).toEqual(['owned\\generated.tsx']) +}) + +it('retains prepared-input ownership and its importless JSX dependencies', () => { + // Given: owned MDX compiled to a JSX engine filename, with a project-only child. + using fs = fixture() + fs.write('src/page.mdx', '# raw is not JavaScript') + fs.write('child/view.jsx', jsx) + const code = "import '../child/view'; export const page=
" + // When + const inventory = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: enabled, + prepared: [ + { physicalPath: 'src/page.mdx', filename: 'owned/page.tsx', code }, + ], + toId: (path) => relative(fs.root, path).replaceAll('\\', '/'), + }) + // Then + expect(inventory.inputs.map(({ filename }) => filename)).toEqual([ + 'child/view.jsx', + 'owned/page.tsx', + ]) + expect(inventory.inputs.map((input) => input.code)).toEqual([jsx, code]) +}) diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index b376c77b8..7c488a90d 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -20,12 +20,14 @@ export { type ResolvedModule, type StaticImportGraph, } from './import-graph' +export { readJsxImportSource } from './jsx-import-source' export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' export { collectNumberedFiles, type CollectNumberedFilesOptions, extractedNeedles, type FileNumbering, + isProjectJsx, seedFileNumbers, } from './numbering' export { diff --git a/packages/plugin-utils/src/jsx-import-source.ts b/packages/plugin-utils/src/jsx-import-source.ts new file mode 100644 index 000000000..0531c083e --- /dev/null +++ b/packages/plugin-utils/src/jsx-import-source.ts @@ -0,0 +1,156 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +interface TsConfig { + extends?: unknown + compilerOptions?: { jsxImportSource?: unknown } + references?: unknown +} + +/** + * Where the comment starting at `index` of `text` ends, or `index` when none + * starts there + */ +function skipComment(text: string, index: number): number { + if (text.startsWith('//', index)) { + const end = text.indexOf('\n', index) + return end < 0 ? text.length : end + } + if (text.startsWith('/*', index)) { + const end = text.indexOf('*/', index + 2) + return end < 0 ? text.length : end + 2 + } + return index +} + +/** + * The character after `index` of `text` that is neither space nor comment + */ +function nextToken(text: string, index: number): string | undefined { + let at = index + while (at < text.length) { + const skipped = skipComment(text, at) + if (skipped !== at) at = skipped + else if (/\s/.test(text[at])) at++ + else return text[at] + } + return undefined +} + +/** + * JSON with the comments and trailing commas tsconfig allows, as JSON + */ +function stripJsonc(text: string): string { + let json = '' + let index = 0 + while (index < text.length) { + const char = text[index] + const skipped = skipComment(text, index) + if (skipped !== index) { + index = skipped + } else if (char === '"') { + const start = index + index++ + while (index < text.length && text[index] !== '"') { + index += text[index] === '\\' ? 2 : 1 + } + index++ + json += text.slice(start, index) + } else { + const trailing = + char === ',' && /[}\]]/.test(nextToken(text, index + 1) ?? '') + if (!trailing) json += char + index++ + } + } + return json +} + +function readConfig(path: string): TsConfig | undefined { + try { + return JSON.parse(stripJsonc(readFileSync(path, 'utf-8'))) as TsConfig + } catch { + return undefined + } +} + +/** + * The file an `extends` entry of the config at `from` names: a path, with + * `.json` optional, or a package's config + */ +function resolveExtended(entry: string, from: string): string | undefined { + if (entry.startsWith('.') || isAbsolute(entry)) { + const path = resolve(dirname(from), entry) + return [path, `${path}.json`].find((candidate) => existsSync(candidate)) + } + const require = createRequire(from) + for (const candidate of [entry, `${entry}/tsconfig.json`]) { + try { + return require.resolve(candidate) + } catch { + // Not a module path; the package may keep its config elsewhere + } + } + return undefined +} + +/** + * The config a project reference names: a config file, or a directory + * holding `tsconfig.json` + */ +function resolveReference( + reference: unknown, + from: string, +): string | undefined { + const path = + typeof reference === 'object' && + reference !== null && + 'path' in reference && + typeof reference.path === 'string' + ? resolve(dirname(from), reference.path) + : undefined + if (!path || !existsSync(path)) return undefined + return statSync(path).isDirectory() ? join(path, 'tsconfig.json') : path +} + +function jsxImportSourceOf( + path: string, + seen: Set, +): string | undefined { + if (seen.has(path)) return undefined + seen.add(path) + const config = readConfig(path) + const own = config?.compilerOptions?.jsxImportSource + if (typeof own === 'string') return own + const extended = [config?.extends] + .flat() + .filter((entry): entry is string => typeof entry === 'string') + // A later entry of `extends` overrides an earlier one + for (const entry of extended.reverse()) { + const parent = resolveExtended(entry, path) + const source = parent && jsxImportSourceOf(parent, seen) + if (source) return source + } + const references = Array.isArray(config?.references) ? config.references : [] + for (const reference of references) { + const referenced = resolveReference(reference, path) + const source = referenced && jsxImportSourceOf(referenced, seen) + if (source) return source + } + return undefined +} + +/** + * The module the project builds JSX with, as its `tsconfig.json` (or, without + * one, `jsconfig.json`) in `cwd` sets `compilerOptions.jsxImportSource`, + * following `extends` and project references + */ +export function readJsxImportSource( + cwd: string = process.cwd(), +): string | undefined { + const config = ['tsconfig.json', 'jsconfig.json'] + .map((name) => join(cwd, name)) + .find((path) => existsSync(path)) + return config && jsxImportSourceOf(config, new Set()) +} diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts index f65d89ec9..b209165c6 100644 --- a/packages/plugin-utils/src/numbering.ts +++ b/packages/plugin-utils/src/numbering.ts @@ -14,6 +14,8 @@ export interface CollectNumberedFilesOptions { * build never extracts do not take numbers (and lengthen class prefixes) */ needles?: string[] + /** Alias keys enabling importless project JSX, matched against the engine ID. */ + readonly importAliases?: object /** The name the plugin extracts a file under, given its absolute path */ toId?: (path: string) => string } @@ -24,6 +26,15 @@ function usesAny(file: string, needles: string[]): boolean { return needles.some((needle) => text.includes(needle)) } +/** Match the extractor's project-wide Emotion JSX policy, including null alias values. */ +export function isProjectJsx(filename: string, importAliases: object): boolean { + return ( + (filename.endsWith('.tsx') || filename.endsWith('.jsx')) && + Object.hasOwn(importAliases, '@emotion/react') && + Object.hasOwn(importAliases, '@emotion/react/jsx-runtime') + ) +} + /** Where an included package lives, found the way the bundler finds it. */ export function findPackageDir(cwd: string, name: string): string | undefined { let dir = resolve(cwd) @@ -48,6 +59,7 @@ export function collectNumberedFiles({ include = [], cwd = process.cwd(), needles, + importAliases = {}, toId = (path) => path.replaceAll('\\', '/'), }: CollectNumberedFilesOptions): string[] { const directories = [ @@ -56,15 +68,19 @@ export function collectNumberedFiles({ .map((name) => findPackageDir(cwd, name)) .filter((dir): dir is string => dir !== undefined), ] - return [ - ...new Set( - directories.flatMap((dir) => - listSourceFiles(dir) - .filter((file) => !needles || usesAny(file, needles)) - .map(toId), - ), - ), - ].sort() + const files = new Set() + for (const directory of directories) { + for (const file of listSourceFiles(directory)) { + const filename = toId(file) + if ( + !needles || + isProjectJsx(filename, importAliases) || + usesAny(file, needles) + ) + files.add(filename) + } + } + return [...files].sort() } export interface FileNumbering { diff --git a/packages/plugin-utils/src/reservation-discovery.ts b/packages/plugin-utils/src/reservation-discovery.ts index 1140d1de4..a45205aab 100644 --- a/packages/plugin-utils/src/reservation-discovery.ts +++ b/packages/plugin-utils/src/reservation-discovery.ts @@ -7,7 +7,7 @@ import { isSourceFile, listSourceFiles, } from './import-graph' -import { extractedNeedles, findPackageDir } from './numbering' +import { extractedNeedles, findPackageDir, isProjectJsx } from './numbering' import { isReservationReadFailure, reservationSource, @@ -142,13 +142,18 @@ export function collectReservationInputs( sources.get(key) ?? reservationSource(() => readFileSync(physicalPath, 'utf-8')) if (code === undefined) continue + const filename = overlay?.filename ?? toId(physicalPath) sources.set(key, code) records.set(key, { physicalPath, - filename: overlay?.filename ?? toId(physicalPath), + filename, code, }) - if (needles.some((needle) => code.includes(needle))) seeds.add(key) + if ( + isProjectJsx(filename, options.importAliases) || + needles.some((needle) => code.includes(needle)) + ) + seeds.add(key) const targets = new Set() const resolutions = new Map() imports.set(key, resolutions) diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index 4bcc526d2..2a441a433 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -130,10 +130,18 @@ export const DEFAULT_IMPORT_ALIASES: ImportAliases = { */ export type WasmImportAliases = Record +/** Emotion's JSX runtime, aliased when the project builds JSX with it */ +const EMOTION_JSX_RUNTIME = '@emotion/react/jsx-runtime' + /** * Merge user import aliases with defaults and convert to WASM format * + * A project building JSX with Emotion (`jsxImportSource` of `@emotion/react`) + * also aliases `@emotion/react/jsx-runtime`: its `css` props then compile in + * every file, and its JSX is built with React's runtime. + * * @param userAliases - User-provided aliases (optional) + * @param jsxImportSource - The module the project builds JSX with (optional) * @returns WASM-compatible import aliases * * @example @@ -144,8 +152,16 @@ export type WasmImportAliases = Record */ export function mergeImportAliases( userAliases?: ImportAliases, + jsxImportSource?: string, ): WasmImportAliases { - const merged = { ...DEFAULT_IMPORT_ALIASES, ...userAliases } + const merged: ImportAliases = { ...DEFAULT_IMPORT_ALIASES, ...userAliases } + if ( + jsxImportSource === '@emotion/react' && + merged['@emotion/react'] !== false && + !(EMOTION_JSX_RUNTIME in merged) + ) { + merged[EMOTION_JSX_RUNTIME] = true + } return Object.fromEntries( Object.entries(merged) .filter((entry): entry is [string, string | true] => entry[1] !== false) @@ -159,6 +175,7 @@ export function mergeImportAliases( */ const COMPAT_TYPE_ENTRIES: Record = { '@emotion/react': 'emotion', + [EMOTION_JSX_RUNTIME]: 'emotion-jsx-runtime', '@emotion/styled': 'emotion', '@emotion/css': 'emotion', '@vanilla-extract/css': 'vanilla-extract', diff --git a/packages/react/package.json b/packages/react/package.json index fa0d51662..9422f7176 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -21,7 +21,7 @@ "scripts": { "lint": "eslint && bun ../../node_modules/@typescript/native/bin/tsc --noEmit && bun run test:types", "build": "bun run test:types && bun ../../node_modules/@typescript/native/bin/tsc && vite build", - "test:types": "bun ../../node_modules/@typescript/native/bin/tsc --ignoreConfig --noEmit --strict --jsx react-jsx --moduleResolution bundler --module esnext --target esnext --types bun --skipLibCheck type-tests/custom-shorthand.ts" + "test:types": "bun ../../node_modules/@typescript/native/bin/tsc --ignoreConfig --noEmit --strict --jsx react-jsx --moduleResolution bundler --module esnext --target esnext --types bun --skipLibCheck type-tests/custom-shorthand.ts type-tests/class-names.ts" }, "publishConfig": { "access": "public" @@ -52,6 +52,9 @@ "./compat/css-prop": { "types": "./dist/compat/css-prop.d.ts" }, + "./compat/emotion-jsx-runtime": { + "types": "./dist/compat/emotion-jsx-runtime.d.ts" + }, "./compat/stylex": { "types": "./dist/compat/stylex.d.ts" }, diff --git a/packages/react/src/compat/__tests__/index.test.ts b/packages/react/src/compat/__tests__/index.test.ts index 3a1ffa9ea..8484fc3c3 100644 --- a/packages/react/src/compat/__tests__/index.test.ts +++ b/packages/react/src/compat/__tests__/index.test.ts @@ -6,6 +6,7 @@ describe('compat entry', () => { const compat = await import('../index') expect({ ...compat }).toEqual({ + ClassNames: expect.any(Function), Global: expect.any(Function), ThemeProvider: expect.any(Function), ThemeConsumer: expect.any(Function), @@ -32,6 +33,14 @@ describe('compat entry', () => { expect(jsx).toBe(createElement) }) + it('leaves ClassNames for the build to compile', async () => { + const { ClassNames } = await import('../index') + + expect(() => ClassNames({ children: () => null })).toThrow( + 'Cannot run on the runtime', + ) + }) + it('keeps its useTheme distinct from the devup-ui one', async () => { const { useTheme: compatUseTheme } = await import('../index') const { useTheme: devupUseTheme } = await import('../../index') diff --git a/packages/react/src/compat/emotion-jsx-runtime.d.ts b/packages/react/src/compat/emotion-jsx-runtime.d.ts new file mode 100644 index 000000000..a06ee7c34 --- /dev/null +++ b/packages/react/src/compat/emotion-jsx-runtime.d.ts @@ -0,0 +1,7 @@ +declare module '@emotion/react/jsx-runtime' { + export * from 'react/jsx-runtime' +} + +declare module '@emotion/react/jsx-dev-runtime' { + export * from 'react/jsx-dev-runtime' +} diff --git a/packages/react/src/compat/emotion.d.ts b/packages/react/src/compat/emotion.d.ts index fafeb840f..591f8f076 100644 --- a/packages/react/src/compat/emotion.d.ts +++ b/packages/react/src/compat/emotion.d.ts @@ -15,8 +15,10 @@ declare module '@emotion/css' { declare module '@emotion/react' { export { css, keyframes } from '@devup-ui/react' export type { StyledTheme as Theme } from '@devup-ui/react/compat' + export type { ClassNamesArg, ClassNamesContent } from '@devup-ui/react/compat' export { CacheProvider, + ClassNames, jsx as createElement, Global, jsx, diff --git a/packages/react/src/compat/index.ts b/packages/react/src/compat/index.ts index a02d28c15..4d82fbeaa 100644 --- a/packages/react/src/compat/index.ts +++ b/packages/react/src/compat/index.ts @@ -7,7 +7,10 @@ * rewritten imports here automatically. */ +import type { ReactNode } from 'react' + import type { DevupPropsWithTheme } from '../types/props' +import type { ClassNamesArg } from '../utils/emotion-classes' import type { StyledTheme } from '../utils/theme-vars' export { Global } from '../components/Global' @@ -43,3 +46,21 @@ export type CssInterpolation = /** Emotion's `css` prop: styles, or a function of the theme giving them. */ export type CssProp = CssInterpolation | ((theme: StyledTheme) => CssInterpolation) + +/** What the child function of Emotion's `ClassNames` takes. */ +export interface ClassNamesContent { + css: (...styles: CssInterpolation[]) => string + cx: (...classes: ClassNamesArg[]) => string + theme: StyledTheme +} + +/** + * Emotion's `ClassNames`: the build renders what its child function gives in + * its place, each `css` and `cx` call compiled to classes. + */ +export function ClassNames( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + props: { children: (content: ClassNamesContent) => ReactNode }, +): ReactNode { + throw new Error('Cannot run on the runtime') +} diff --git a/packages/react/src/utils/emotion-classes.ts b/packages/react/src/utils/emotion-classes.ts index b85cb8a89..a8e00671a 100644 --- a/packages/react/src/utils/emotion-classes.ts +++ b/packages/react/src/utils/emotion-classes.ts @@ -9,7 +9,7 @@ export type ClassNamesArg = | null | undefined | { [name: string]: unknown } - | ClassNamesArg[] + | readonly ClassNamesArg[] /** * Emotion compatible class composition. The call is compiled away at build diff --git a/packages/react/type-tests/class-names.ts b/packages/react/type-tests/class-names.ts new file mode 100644 index 000000000..1fea99f21 --- /dev/null +++ b/packages/react/type-tests/class-names.ts @@ -0,0 +1,9 @@ +import { type ClassNamesContent, cx } from '../src/compat' + +const nested = [['nested', 7, true], { selected: true }] as const + +cx(nested, 1) + +declare const content: ClassNamesContent + +content.cx(nested, 1) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index ca57a479d..8bf710f2e 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -18,6 +18,7 @@ import { loadDevupConfig, mergeImportAliases, planAtomHoist, + readJsxImportSource, tailwindCssFiles, withTailwindCss, } from '@devup-ui/plugin-utils' @@ -141,7 +142,10 @@ export const DevupUI = ({ importAliases: userImportAliases, }: Partial = {}): RsbuildPlugin => { registerShorthands(shorthands ?? {}) - const importAliases = mergeImportAliases(userImportAliases) + const importAliases = mergeImportAliases( + userImportAliases, + readJsxImportSource(), + ) const stateWriter = createStateWriter((path, content, encoding) => encoding ? writeFile(path, content, encoding) : writeFile(path, content), ) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 51baddff9..ec5fc3081 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -494,6 +494,13 @@ describe('devupUIVitePlugin', () => { roots: [resolve('/p', 'src'), resolve('/p', 'app')], include: ['@acme/ui'], cwd: '/p', + importAliases: { + '@emotion/react': null, + '@emotion/styled': 'styled', + '@emotion/css': null, + 'styled-components': 'styled', + '@vanilla-extract/css': null, + }, needles: expect.arrayContaining([ '@devup-ui/react', '@stylexjs/stylex', @@ -1527,7 +1534,9 @@ describe('devupUIVitePlugin', () => { it('should print error when watch change error', async () => { writeFileSpy.mockResolvedValueOnce(undefined) getThemeInterfaceSpy.mockReturnValue('interface code') - existsSyncSpy.mockReturnValueOnce(true).mockReturnValueOnce(false) + existsSyncSpy.mockImplementation( + (path: fs.PathLike) => path === 'devup.json', + ) mkdirSpy.mockImplementation(() => { throw new Error('error') }) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index e56939930..77528c7d4 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -16,6 +16,7 @@ import { type ImportAliases, loadDevupConfig, mergeImportAliases, + readJsxImportSource, seedFileNumbers, tailwindCssFiles, withTailwindCss, @@ -316,7 +317,10 @@ export function DevupUI({ registerShorthands(shorthands ?? {}) setDebug(debug) setPrefix(prefix ?? null) - const importAliases = mergeImportAliases(userImportAliases) + const importAliases = mergeImportAliases( + userImportAliases, + readJsxImportSource(), + ) const cssMap = new Map() let resolvedConfig: ResolvedConfig | undefined // Set by the client `generateBundle`, run by the late hook of the sibling @@ -388,6 +392,7 @@ export function DevupUI({ include, cwd: projectRoot, needles: extractedNeedles(libPackage, importAliases), + importAliases, }), ) } catch { diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 175620b0f..2961faed6 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -20,6 +20,7 @@ import { loadDevupConfigSync, mergeImportAliases, planAtomHoist, + readJsxImportSource, seedFileNumbers, tailwindCssFiles, type WasmImportAliases, @@ -106,7 +107,10 @@ export class DevupUIWebpackPlugin { importAliases: userImportAliases, }: Partial = {}) { registerShorthands(shorthands ?? {}) - this.importAliases = mergeImportAliases(userImportAliases) + this.importAliases = mergeImportAliases( + userImportAliases, + readJsxImportSource(), + ) this.options = { package: libPackage, @@ -347,6 +351,7 @@ export class DevupUIWebpackPlugin { include: this.options.include, cwd, needles: extractedNeedles(this.options.package, this.importAliases), + importAliases: this.importAliases, toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), )