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/.changepacks/changepack_log_imported_styled_definitions.json b/.changepacks/changepack_log_imported_styled_definitions.json new file mode 100644 index 000000000..184bcaf65 --- /dev/null +++ b/.changepacks/changepack_log_imported_styled_definitions.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "A styled component another module defines now composes at build time exactly as one the file defines itself: styled(ImportedBase)(...), styled(ImportedBase).attrs(...), ImportedBase.withComponent(...) and the Emotion css prop on ImportedBase render the base's tag with the base's styles under their own (the later declaration wins per property, selector, breakpoint and layer), merge attrs in order and filter props by what the styles read and shouldForwardProp. The build reads the other module through the same module resolution imported constants use (relative paths, tsconfig paths, package exports, include libraries) and reports it as a dependency, so a change to it rebuilds the importing file. A definition the build cannot read exactly (a base only the runtime gives, an attrs or style expression reading a binding of the defining module, a module with a build error, an unresolvable module, an import cycle) stays a runtime component that wraps, as before. Every exported styled component now carries a marker class computed from its file and its export name alone, so ${ImportedChild} { ... } in CSS text and [ImportedChild] as a rule key select it from another file; the marker replaces the registry-ordered one, which two files could not compute alike. No new build errors: the css prop on an imported styled component now reports the same override error a local one does when its styles overlap the component's", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 328a6646a..9382bb40c 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -486,6 +486,29 @@ pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> S }) } +/// The class marking the component `name` that `filename` defines. +/// +/// Other styles, in this file or another, select the component by it. It is +/// `index` among the components of the file, so it comes from the file and the +/// component alone: every file that selects the component computes the same +/// class, in any process. Two dashes keep it apart from every class styles are +/// named by. +#[must_use] +pub fn component_marker(name: &str, index: usize, filename: &str) -> String { + with_prefix(|prefix| { + let file = get_file_num_by_filename(filename); + if is_debug() { + format!("{prefix}c-{file}-{name}") + } else { + format!( + "{prefix}{}--{}", + num_to_nm_base(file), + num_to_nm_base(index) + ) + } + }) +} + /// 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 +858,26 @@ mod tests { ); } + #[test] + #[serial] + fn test_component_marker() { + set_debug(false); + reset_class_map(); + crate::file_map::reset_file_map(); + let child = component_marker("Child", 0, "a.tsx"); + assert_eq!(child, "a--a"); + assert_eq!(component_marker("Child", 0, "a.tsx"), child); + assert_eq!(component_marker("Child", 0, "b.tsx"), "b--a"); + assert_eq!(component_marker("Other", 2, "a.tsx"), "a--c"); + assert_eq!( + sheet_to_classname("color", 0, Some("red"), None, None, None), + "a" + ); + set_debug(true); + assert_eq!(component_marker("Child", 0, "a.tsx"), "c-0-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..ec4d3e61e 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -1,5 +1,9 @@ +use super::styled_capability::StyledCapability; use super::styled_css_slot::{StyledContext, StyledCssSlot}; +#[path = "styled_retained.rs"] +mod retained; use super::styled_jsx::StyledJsx; +use retained::Execution; use rustc_hash::FxHashMap; use crate::{ @@ -17,10 +21,10 @@ use crate::{ gen_style::gen_styles, styled_reads::{Forward, Reads, withheld}, utils::{ - STYLE_OBJECT, StyleArguments, build_time_error, call_with_values, merge_object_expressions, - readable_code, reads_directly, style_arguments, uncomposable_error, unplaced_error, - unreadable_styles, unwrap_syntax_only, unwrap_syntax_only_mut, wrap_array_filter, - wrap_direct_call, + STYLE_OBJECT, StyleArguments, build_time_error, call_with_values, expression_to_code, + merge_object_expressions, readable_code, reads_directly, style_arguments, + uncomposable_error, unplaced_error, unreadable_styles, unwrap_syntax_only, + unwrap_syntax_only_mut, wrap_array_filter, wrap_direct_call, }, }; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -33,7 +37,8 @@ use oxc_ast::{ builder::AstBuilder, }; use oxc_ast_visit::VisitMut; -use oxc_span::{GetSpan, SPAN}; +use oxc_parser::{Parser, ParserReturn}; +use oxc_span::{GetSpan, SPAN, SourceType}; use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; /// The binding a styled component reads the component it renders through, when @@ -67,8 +72,31 @@ pub struct StyledDefinition<'a> { reads: Reads, /// Its `shouldForwardProp`, as the build evaluates it forward: Option, + /// The classes other styles select it by, which a component extending it + /// gives too, as it renders this one + markers: Vec, + inherited_markers: Vec, bindings: Vec<(String, Expression<'a>)>, - css_slot: Option, + capability: Option, + execution: Execution<'a>, +} + +/// `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 classes other styles select it by +#[derive(Clone, Copy, Default)] +pub struct Naming<'s> { + pub split_filename: Option<&'s str>, + pub markers: &'s [String], } /// Whether a styled component renders a tag, which takes only valid @@ -132,6 +160,9 @@ impl<'a> StyledDefinition<'a> { } fn rebind(&self, builder: &AstBuilder<'a>, scope: &mut Expression<'a>) -> Self { + if !self.execution.replays() && self.bindings.is_empty() { + return self.clone_in(builder.allocator()); + } let allocator = builder.allocator(); let mut inherited = Self { name: self.name.clone(), @@ -146,7 +177,10 @@ impl<'a> StyledDefinition<'a> { attrs: clone_all(builder, &self.attrs).collect(), reads: self.reads.clone(), forward: self.forward.clone(), - css_slot: self.css_slot.clone(), + markers: self.markers.clone(), + inherited_markers: self.inherited_markers.clone(), + capability: self.capability.clone(), + execution: self.execution.clone_in(allocator), bindings: self .bindings .iter() @@ -212,11 +246,7 @@ impl StyledDefinition<'_> { name, "className" | "style" | "as" | "forwardedAs" | "key" | "ref" | "children" ) - || crate::styled_reads::passes( - name, - renders_tag(&self.name, self.bound.as_ref()), - self.forward.as_ref(), - ) + || crate::styled_reads::passes(name, self.native(), self.forward.as_ref()) } /// Whether an extension can render what this definition renders: a value @@ -235,7 +265,7 @@ impl<'a> StyledDefinition<'a> { /// and no value only the runtime gives #[must_use] pub fn inline(&self) -> Option<(&str, &[ExtractStyleProp<'a>])> { - (self.fixed_native() && self.bindings.is_empty()) + (self.fixed_native() && self.bindings.is_empty() && self.execution.replays()) .then_some((self.name.as_str(), self.styles.as_slice())) } @@ -247,15 +277,127 @@ impl<'a> StyledDefinition<'a> { && self.styles.iter().all(fixed) } - fn with_css_slot(mut self, slot: Option<&StyledCssSlot>) -> Self { - self.css_slot = slot - .filter(|_| self.fixed_native() && !self.bindings.is_empty()) + fn with_css_slot(mut self, slot: Option<&StyledCapability>, inherited: Option<&Self>) -> Self { + self.capability = slot + .filter(|_| { + (self.fixed_native() && !self.bindings.is_empty()) + || !self.execution.replays() + || (!self.native() + && self.fixed_summary() + && inherited.is_some_and(|base| { + base.bindings.is_empty() + && base.fixed_summary() + && (base.portable() || base.capability.is_some()) + })) + }) .cloned(); self } - pub(crate) const fn css_slot(&self) -> Option<&StyledCssSlot> { - self.css_slot.as_ref() + pub(crate) fn css_slot(&self) -> Option<&StyledCssSlot> { + self.capability + .as_ref() + .filter(|_| self.fixed_summary()) + .map(|capability| &capability.classes) + } + + /// The classes other styles select the component by + #[must_use] + pub fn markers(&self) -> &[String] { + &self.markers + } + + /// This definition in the arena of another file + #[must_use] + pub fn clone_in<'b>(&self, allocator: &'b oxc_allocator::Allocator) -> StyledDefinition<'b> { + StyledDefinition { + name: self.name.clone(), + target: self + .target + .as_ref() + .map(|target| target.clone_in(allocator)), + bound: self.bound.as_ref().map(|bound| bound.clone_in(allocator)), + classes: self + .classes + .iter() + .map(|class| class.clone_in(allocator)) + .collect(), + styles: self + .styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(), + attrs: self + .attrs + .iter() + .map(|attr| attr.clone_in(allocator)) + .collect(), + reads: self.reads.clone(), + forward: self.forward.clone(), + markers: self.markers.clone(), + inherited_markers: self.inherited_markers.clone(), + bindings: self + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + capability: self.capability.clone(), + execution: self.execution.clone_in(allocator), + } + } + + /// Whether another file can render this component in its place: it + /// renders a tag, and nothing it holds reads a binding of the file + /// defining it + #[must_use] + pub fn portable(&self) -> bool { + self.bindings.is_empty() + && self.execution.replays() + && renders_tag(&self.name, self.bound.as_ref()) + && closed(self.code()) + } + + /// The code the component evaluates for its classes, attrs and styles + fn code(&self) -> impl Iterator + '_ { + self.classes + .iter() + .chain(&self.attrs) + .chain(self.styles.iter().flat_map(style_expressions)) + .map(expression_to_code) + .chain( + self.styles + .iter() + .flat_map(ExtractStyleProp::extract) + .filter_map(|style| match style { + ExtractStyleValue::Dynamic(style) => Some(style.identifier().to_string()), + _ => None, + }), + ) + } + + fn with_markers(mut self, markers: &[String], inherited: Option<&Self>) -> Self { + self.markers = markers.to_vec(); + self.inherited_markers = inherited + .into_iter() + .flat_map(|definition| { + definition + .markers + .iter() + .chain(&definition.inherited_markers) + }) + .cloned() + .collect(); + self + } + + pub(crate) fn marker_classes(&self, ast: &AstBuilder<'a>) -> Option> { + merge_expression_for_class_name( + ast, + self.markers + .iter() + .chain(&self.inherited_markers) + .map(|marker| marker_class(ast, marker)), + ) } /// The styles the component gives what it renders @@ -281,7 +423,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) { @@ -297,7 +439,11 @@ pub fn with_component<'a>( ..Base::named(name) }; let mut scope = target.clone_in(allocator); - let definition = definition.rebind(ast_builder, &mut scope); + let mut definition = definition.rebind(ast_builder, &mut scope); + match &mut definition.execution { + Execution::Delegated(renderer) => renderer.native = renders_tag(&base.name, None), + Execution::Replay | Execution::RetainedFixed { .. } => {} + } let mut styles: Vec> = definition .styles .iter() @@ -310,7 +456,7 @@ pub fn with_component<'a>( ast_builder, &mut styles, None, - split_filename, + naming.split_filename, )), ); let withheld = withheld( @@ -327,7 +473,9 @@ pub fn with_component<'a>( (definition.reads.clone(), definition.forward.clone()), &definition.bindings, ) - .with_css_slot(context.css_slot); + .with_execution(ast_builder, Some(&definition)) + .with_css_slot(context.capability, Some(&definition)) + .with_markers(naming.markers, Some(&definition)); let component = create_styled_component( ast_builder, &base, @@ -340,14 +488,19 @@ pub fn with_component<'a>( .map(|(name, value)| (name.clone(), value.clone_in(allocator))) .collect(), withheld: &withheld, - css_slot: new_definition.css_slot(), + capability: new_definition.capability.as_ref(), + execution: &new_definition.execution, + markers: new_definition.render_markers(ast_builder), }, context.jsx, ); Some(( - base.render( + new_definition.retain_bound( ast_builder, - apply_attrs(ast_builder, component, &definition.attrs), + base.render( + ast_builder, + apply_attrs(ast_builder, component, &definition.attrs), + ), ), new_definition, )) @@ -397,11 +550,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,7 +614,10 @@ impl<'a> Base<'a> { attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(), reads, forward, - css_slot: None, + markers: Vec::new(), + inherited_markers: Vec::new(), + capability: None, + execution: Execution::Replay, bindings: bindings .iter() .map(|(name, value)| (name.clone(), value.clone_in(allocator))) @@ -473,11 +625,6 @@ impl<'a> Base<'a> { } } - /// The props a component rendering this base keeps away from it - fn withheld(&self, reads: &Reads, forward: Option<&Forward>) -> Vec { - withheld(reads, renders_tag(&self.name, self.bound.as_ref()), forward) - } - const fn named(name: String) -> Self { Self { name, @@ -640,12 +787,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, + markers, + } = 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)); @@ -781,7 +932,14 @@ pub fn extract_style_from_styled<'a>( .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator()))) .collect() }); - let withheld = base.withheld(&reads, forward.as_ref()); + let withheld = withheld( + &reads, + inherited.map_or_else( + || renders_tag(&base.name, base.bound.as_ref()), + StyledDefinition::native, + ), + forward.as_ref(), + ); let definition = base .definition( ast_builder, @@ -791,7 +949,9 @@ pub fn extract_style_from_styled<'a>( (reads, forward), &bindings, ) - .with_css_slot(context.css_slot); + .with_execution(ast_builder, inherited) + .with_css_slot(context.capability, inherited) + .with_markers(markers, inherited); let component = create_styled_component( ast_builder, &base, @@ -800,12 +960,16 @@ pub fn extract_style_from_styled<'a>( style_vars: &gen_styles(ast_builder, &props_styles, None), bindings, withheld: &withheld, - css_slot: definition.css_slot(), + capability: definition.capability.as_ref(), + execution: &definition.execution, + markers: definition.render_markers(ast_builder), }, context.jsx, ); - let styled_component = - base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); + let styled_component = definition.retain_bound( + ast_builder, + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)), + ); let result = ExtractResult { styles: props_styles, @@ -894,7 +1058,14 @@ pub fn extract_style_from_styled<'a>( split_filename, )), ); - let withheld = base.withheld(&reads, forward.as_ref()); + let withheld = withheld( + &reads, + inherited.map_or_else( + || renders_tag(&base.name, base.bound.as_ref()), + StyledDefinition::native, + ), + forward.as_ref(), + ); let definition = base .definition( ast_builder, @@ -904,7 +1075,9 @@ pub fn extract_style_from_styled<'a>( (reads, forward), &bindings, ) - .with_css_slot(context.css_slot); + .with_execution(ast_builder, inherited) + .with_css_slot(context.capability, inherited) + .with_markers(markers, inherited); let component = create_styled_component( ast_builder, &base, @@ -913,12 +1086,16 @@ pub fn extract_style_from_styled<'a>( style_vars: &gen_styles(ast_builder, &styles, None), bindings, withheld: &withheld, - css_slot: definition.css_slot(), + capability: definition.capability.as_ref(), + execution: &definition.execution, + markers: definition.render_markers(ast_builder), }, context.jsx, ); - let styled_component = - base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); + let styled_component = definition.retain_bound( + ast_builder, + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)), + ); let result = ExtractResult { styles, @@ -949,6 +1126,76 @@ pub fn extract_style_from_styled<'a>( } } +/// What every module reads the same, besides the props a component takes +const SHARED_NAMES: &[&str] = &[ + "rest", + "undefined", + "NaN", + "Infinity", + "Math", + "Number", + "String", + "Boolean", + "Object", + "Array", + "JSON", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", +]; + +/// The expressions a component evaluates for `prop` +fn style_expressions<'p, 'a>(prop: &'p ExtractStyleProp<'a>) -> Vec<&'p Expression<'a>> { + match prop { + ExtractStyleProp::Static(_) | ExtractStyleProp::Unreadable { .. } => Vec::new(), + ExtractStyleProp::StaticArray(props) => props.iter().flat_map(style_expressions).collect(), + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } => std::iter::once(condition) + .chain(consequent.iter().flat_map(|prop| style_expressions(prop))) + .chain(alternate.iter().flat_map(|prop| style_expressions(prop))) + .collect(), + ExtractStyleProp::Enum { condition, map } => std::iter::once(condition) + .chain(map.values().flatten().flat_map(style_expressions)) + .collect(), + ExtractStyleProp::Expression { expression, .. } => vec![expression], + ExtractStyleProp::MemberExpression { map, expression } => std::iter::once(expression) + .chain(map.values().flat_map(|prop| style_expressions(prop))) + .collect(), + } +} + +/// Whether the expressions `code` writes read nothing but the bindings they +/// declare, the props, and what every module reads the same +fn closed(code: impl Iterator) -> bool { + let source = code + .map(|code| ["(", &code, ");\n"].concat()) + .collect::(); + let allocator = oxc_allocator::Allocator::default(); + let ParserReturn { + program, + fatal_error, + .. + } = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + !fatal_error + && scoping + .root_unresolved_references() + .iter() + .all(|(name, references)| { + SHARED_NAMES.contains(&name.as_str()) + || references + .iter() + .all(|reference| !scoping.get_reference(*reference).is_value()) + }) +} + fn clone_all<'s, 'a>( ast_builder: &'s AstBuilder<'a>, expressions: &'s [Expression<'a>], @@ -1488,7 +1735,9 @@ struct StyledRender<'b, 'a> { style_vars: &'b Option>, bindings: Vec<(String, Expression<'a>)>, withheld: &'b [String], - css_slot: Option<&'b StyledCssSlot>, + capability: Option<&'b StyledCapability>, + execution: &'b Execution<'a>, + markers: Option>, } fn create_styled_component<'a>( @@ -1502,7 +1751,9 @@ fn create_styled_component<'a>( style_vars, bindings, withheld, - css_slot, + capability, + execution, + markers, } = render; let params = FormalParameters::boxed( SPAN, @@ -1601,8 +1852,22 @@ fn create_styled_component<'a>( None::>>, 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 replacement = capability.map(|slot| slot.classes.classes(ast_builder, class_name.as_ref())); + let delegated = match execution { + Execution::Delegated(renderer) => Some(renderer), + Execution::Replay | Execution::RetainedFixed { .. } => None, + }; + let own_classes = merge_expression_for_class_name( + ast_builder, + markers.into_iter().chain( + replacement + .as_ref() + .or(class_name.as_ref()) + .filter(|_| delegated.is_none()) + .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( @@ -1628,42 +1893,52 @@ fn create_styled_component<'a>( .unwrap_or_else(|| style_vars.clone_in(ast_builder.allocator())) }, ); - let omissions = css_slot.map(|slot| { + let omissions = capability.map(|slot| { let mut omissions = withheld.to_vec(); - omissions.push(slot.name().to_string()); + slot.omit(&mut omissions); omissions }); let withheld = omissions.as_deref().unwrap_or(withheld); + let ordinary = + super::styled_transient::filter(ast_builder, without_props(ast_builder, withheld)); + let forwarded = capability.map_or_else( + || ordinary.clone_in(ast_builder.allocator()), + |slot| slot.forwarded(ast_builder, &ordinary), + ); + let (target, rest, as_prop) = retained::transport( + ast_builder, + delegated, + retained::Transport { + capability, + replacement: replacement.as_ref(), + forwarded, + }, + ); let body = if let Some(helper) = helper { - helper.render( - ast_builder, - identifier(ast_builder, RENDERED), - (class_name, style, identifier(ast_builder, "forwardedAs")), - super::styled_transient::filter(ast_builder, without_props(ast_builder, withheld)), - ) + helper.render(ast_builder, target, (class_name, style, as_prop), rest) } else { Expression::new_jsx_element( SPAN, JSXOpeningElement::boxed( SPAN, - JSXElementName::new_identifier(SPAN, RENDERED, ast_builder), + JSXElementName::new_identifier( + SPAN, + Str::from_in( + delegated.map_or(RENDERED, |renderer| renderer.name.as_str()), + ast_builder.allocator(), + ), + ast_builder, + ), None::>>, oxc_allocator::Vec::from_iter_in( vec![ - JSXAttributeItem::new_spread_attribute( - SPAN, - super::styled_transient::filter( - ast_builder, - without_props(ast_builder, withheld), - ), - ast_builder, - ), + JSXAttributeItem::new_spread_attribute(SPAN, rest, ast_builder), JSXAttributeItem::new_attribute( SPAN, JSXAttributeName::new_identifier(SPAN, "as", ast_builder), Some(JSXAttributeValue::new_expression_container( SPAN, - identifier(ast_builder, "forwardedAs").into(), + as_prop.into(), ast_builder, )), ast_builder, @@ -1708,3 +1983,69 @@ fn create_styled_component<'a>( ast_builder, ) } + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use std::collections::BTreeMap; + + use oxc_allocator::Allocator; + + use super::*; + + #[test] + fn style_expressions_find_what_every_kind_of_style_evaluates() { + let allocator = Allocator::default(); + let ast_builder = AstBuilder::new(&allocator); + let name = |name: &str| { + Expression::new_identifier(SPAN, Str::from_in(name, &allocator), &ast_builder) + }; + let evaluated = |expression| ExtractStyleProp::Expression { + styles: vec![], + expression, + }; + let props = [ + ExtractStyleProp::Static(ExtractStyleValue::Typography("t".to_string())), + ExtractStyleProp::Unreadable { + offset: 0, + code: String::new(), + prop: false, + requirement: None, + }, + ExtractStyleProp::StaticArray(vec![evaluated(name("a"))]), + ExtractStyleProp::Conditional { + condition: name("b"), + consequent: Some(Box::new(evaluated(name("c")))), + alternate: Some(Box::new(evaluated(name("d")))), + }, + ExtractStyleProp::Enum { + condition: name("e"), + map: BTreeMap::from([("x".to_string(), vec![evaluated(name("f"))])]), + }, + ExtractStyleProp::MemberExpression { + map: BTreeMap::from([("k".to_string(), Box::new(evaluated(name("h"))))]), + expression: name("g"), + }, + ]; + let code: Vec = props + .iter() + .flat_map(style_expressions) + .map(expression_to_code) + .collect(); + assert_eq!(code, ["a;", "b;", "c;", "d;", "e;", "f;", "g;", "h;"]); + } + + #[test] + fn code_is_closed_when_it_reads_only_what_it_declares() { + let closed_code = |code: &[&str]| closed(code.iter().map(ToString::to_string)); + assert!(closed_code(&[])); + assert!(closed_code(&[ + "(p) => p.x * Math.PI", + "((p) => p.on)(rest)" + ])); + assert!(closed_code(&["(p: Props) => p.x"])); + assert!(!closed_code(&["(p) => scale(p.x)"])); + assert!(!closed_code(&["(p) => p.x", "window.mode"])); + assert!(!closed_code(&["@#$"])); + } +} diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index 594d7c0fc..e661a4e3c 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -9,6 +9,7 @@ pub(super) mod extract_style_from_jsx; pub(super) mod extract_style_from_member_expression; pub(super) mod extract_style_from_styled; pub(super) mod extract_style_from_stylex; +pub(super) mod styled_capability; pub(super) mod styled_css_slot; pub(super) mod styled_jsx; mod styled_record; diff --git a/libs/extractor/src/extractor/styled_capability.rs b/libs/extractor/src/extractor/styled_capability.rs new file mode 100644 index 000000000..aac7b888f --- /dev/null +++ b/libs/extractor/src/extractor/styled_capability.rs @@ -0,0 +1,37 @@ +use oxc_ast::{ + ast::{Expression, Program}, + builder::AstBuilder, +}; + +use super::styled_css_slot::StyledCssSlot; + +/// Independently reserved complete replacements consumed by the defining renderer. +#[derive(Clone)] +pub(crate) struct StyledCapability { + pub classes: StyledCssSlot, + pub forwarded: StyledCssSlot, +} + +impl StyledCapability { + pub(crate) fn reserve(program: &Program<'_>) -> Self { + Self { + classes: StyledCssSlot::reserve(program), + forwarded: StyledCssSlot::reserve_named(program, "__devupForwarded"), + } + } + + pub(crate) fn omit(&self, names: &mut Vec) { + names.extend([ + self.classes.name().to_string(), + self.forwarded.name().to_string(), + ]); + } + + pub(crate) fn forwarded<'a>( + &self, + ast: &AstBuilder<'a>, + ordinary: &Expression<'a>, + ) -> Expression<'a> { + self.forwarded.classes(ast, Some(ordinary)) + } +} diff --git a/libs/extractor/src/extractor/styled_css_slot.rs b/libs/extractor/src/extractor/styled_css_slot.rs index b61165f68..6d2746f57 100644 --- a/libs/extractor/src/extractor/styled_css_slot.rs +++ b/libs/extractor/src/extractor/styled_css_slot.rs @@ -15,12 +15,13 @@ use oxc_span::SPAN; use oxc_syntax::operator::LogicalOperator; use rustc_hash::FxHashSet; +use super::styled_capability::StyledCapability; use super::styled_jsx::StyledJsx; use crate::utils::unwrap_syntax_only; pub(crate) struct StyledContext<'s> { pub jsx: &'s mut Option, - pub css_slot: Option<&'s StyledCssSlot>, + pub capability: Option<&'s StyledCapability>, } pub(crate) enum CssPropLowering { @@ -84,11 +85,15 @@ impl<'a> Visit<'a> for Names { impl StyledCssSlot { /// Reserve a private prop before extraction can remove authored names or keys. pub(crate) fn reserve(program: &Program<'_>) -> Self { + Self::reserve_named(program, "__devupCss") + } + + pub(crate) fn reserve_named(program: &Program<'_>, prefix: &str) -> Self { let mut names = Names::default(); names.visit_program(program); let mut suffix = 0; loop { - let name = format!("__devupCss{suffix}"); + let name = format!("{prefix}{suffix}"); if !names.0.contains(&name) { return Self(name); } diff --git a/libs/extractor/src/extractor/styled_retained.rs b/libs/extractor/src/extractor/styled_retained.rs new file mode 100644 index 000000000..cbd4f75b0 --- /dev/null +++ b/libs/extractor/src/extractor/styled_retained.rs @@ -0,0 +1,220 @@ +use super::{ + RENDERED, STYLED_BASE, StyledCapability, StyledDefinition, call_with_values, fixed, identifier, + jsx_name, marker_class, merge_expression_for_class_name, renders_tag, with_property, + without_props, +}; +use oxc_allocator::{Allocator, CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, ObjectPropertyKind}, + builder::AstBuilder, +}; +use oxc_span::SPAN; + +pub(super) enum Execution<'a> { + Replay, + RetainedFixed { + native: bool, + capability: StyledCapability, + }, + Delegated(Renderer<'a>), +} + +pub(super) struct Renderer<'a> { + pub name: String, + pub target: Expression<'a>, + pub bound: Option>, + pub capability: StyledCapability, + pub native: bool, + pub identities: Vec, +} + +impl Execution<'_> { + pub(super) const fn replays(&self) -> bool { + match self { + Self::Replay => true, + Self::RetainedFixed { .. } | Self::Delegated(_) => false, + } + } + + pub(super) fn clone_in<'b>(&self, allocator: &'b Allocator) -> Execution<'b> { + match self { + Self::Replay => Execution::Replay, + Self::RetainedFixed { native, capability } => Execution::RetainedFixed { + native: *native, + capability: capability.clone(), + }, + Self::Delegated(renderer) => Execution::Delegated(Renderer { + name: renderer.name.clone(), + target: renderer.target.clone_in(allocator), + bound: renderer + .bound + .as_ref() + .map(|value| value.clone_in(allocator)), + capability: renderer.capability.clone(), + native: renderer.native, + identities: renderer.identities.clone(), + }), + } + } +} + +impl<'a> StyledDefinition<'a> { + pub(crate) fn shared(mut self) -> Option { + if self.portable() { + return Some(self); + } + if self.fixed_summary() + && let Some(capability) = self.capability.take() + { + let native = self.native(); + self.name = String::new(); + self.target = None; + self.bound = None; + self.bindings.clear(); + self.execution = Execution::RetainedFixed { + native, + capability: capability.clone(), + }; + self.capability = Some(capability); + return Some(self); + } + None + } + + pub(crate) fn used_as(&self, ast: &AstBuilder<'a>, target: &Expression<'a>) -> Self { + let mut definition = self.clone_in(ast.allocator()); + match &self.execution { + Execution::RetainedFixed { native, capability } => { + let name = jsx_name(target); + definition.execution = Execution::Delegated(Renderer { + name: name.clone().unwrap_or_else(|| STYLED_BASE.to_string()), + target: name.as_ref().map_or_else( + || identifier(ast, STYLED_BASE), + |_| target.clone_in(ast.allocator()), + ), + bound: name.is_none().then(|| target.clone_in(ast.allocator())), + capability: capability.clone(), + native: *native, + identities: self + .markers + .iter() + .chain(&self.inherited_markers) + .cloned() + .collect(), + }); + definition.target = Some(identifier(ast, "undefined")); + } + Execution::Replay | Execution::Delegated(_) => {} + } + definition + } + + pub(super) fn fixed_summary(&self) -> bool { + self.attrs.is_empty() && self.classes.is_empty() && self.styles.iter().all(fixed) + } + + pub(super) fn native(&self) -> bool { + match &self.execution { + Execution::Replay => renders_tag(&self.name, self.bound.as_ref()), + Execution::RetainedFixed { native, .. } => *native, + Execution::Delegated(renderer) => renderer.native, + } + } + + pub(super) fn with_execution(mut self, ast: &AstBuilder<'a>, inherited: Option<&Self>) -> Self { + if let Some(inherited) = inherited { + self.execution = inherited.execution.clone_in(ast.allocator()); + } + self + } + + pub(super) fn render_markers(&self, ast: &AstBuilder<'a>) -> Option> { + let inherited = match &self.execution { + Execution::Delegated(renderer) => renderer.identities.as_slice(), + Execution::Replay | Execution::RetainedFixed { .. } => &[], + }; + merge_expression_for_class_name( + ast, + self.markers + .iter() + .chain(&self.inherited_markers) + .filter(|marker| !inherited.contains(marker)) + .map(|marker| marker_class(ast, marker)), + ) + } + + pub(super) fn retain_bound( + &self, + ast: &AstBuilder<'a>, + component: Expression<'a>, + ) -> Expression<'a> { + match &self.execution { + Execution::Delegated(renderer) => match &renderer.bound { + Some(bound) => call_with_values( + ast, + vec![(STYLED_BASE.to_string(), bound.clone_in(ast.allocator()))], + component, + ), + None => component, + }, + Execution::Replay | Execution::RetainedFixed { .. } => component, + } + } +} + +pub(super) struct Transport<'b, 'a> { + pub capability: Option<&'b StyledCapability>, + pub replacement: Option<&'b Expression<'a>>, + pub forwarded: Expression<'a>, +} + +pub(super) fn transport<'a>( + ast: &AstBuilder<'a>, + renderer: Option<&Renderer<'a>>, + transport: Transport<'_, 'a>, +) -> (Expression<'a>, Expression<'a>, Expression<'a>) { + match renderer { + None => ( + identifier(ast, RENDERED), + transport.forwarded, + identifier(ast, "forwardedAs"), + ), + Some(renderer) => { + let mut controls = Vec::new(); + if let Some(capability) = transport.capability { + capability.omit(&mut controls); + } + let raw = with_property( + ast, + without_props(ast, &controls), + "forwardedAs", + identifier(ast, "forwardedAs"), + ); + let raw = Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::from_array_in( + [ + ObjectPropertyKind::new_spread_property(SPAN, raw, ast), + renderer.capability.classes.property( + ast, + transport + .replacement + .map(|value| value.clone_in(ast.allocator())), + ), + renderer + .capability + .forwarded + .property(ast, Some(transport.forwarded)), + ], + ast, + ), + ast, + ); + ( + renderer.target.clone_in(ast.allocator()), + raw, + identifier(ast, RENDERED), + ) + } + } +} 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/imported_styled.rs b/libs/extractor/src/imported_styled.rs new file mode 100644 index 000000000..bc9ed4325 --- /dev/null +++ b/libs/extractor/src/imported_styled.rs @@ -0,0 +1,914 @@ +//! Styled components other modules define, read where a file extends one, +//! selects one in CSS text or gives one a `css` prop, so they compose exactly +//! as the ones the file defines itself. + +use std::collections::BTreeSet; +use std::rc::Rc; + +use oxc_allocator::{Allocator, GetAllocator}; +use oxc_ast::ast::{ + CallExpression, Declaration, Expression, ImportDeclarationSpecifier, JSXAttributeItem, + JSXAttributeName, JSXElementName, JSXOpeningElement, Program, Statement, +}; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::{Visit, VisitMut, walk}; +use oxc_parser::{Parser, ParserReturn}; +use oxc_span::SourceType; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::css_prop::{CssProp, CssTakers}; +use crate::extractor::extract_style_from_styled::StyledDefinition; +use crate::import_alias_visit::{Aliased, transform_import_aliases_with_edits}; +use crate::utils::{is_vanilla_extract_file, unwrap_syntax_only}; +use crate::visit::DevupVisitor; +use crate::{ExtractOption, ModuleResolver}; + +/// What a module exports, which marks the components other files select +#[derive(Default)] +pub(crate) struct Exports { + /// Every name the module exports, in order + names: Vec, + /// The names each binding is exported as + bindings: FxHashMap>, + /// Whether `export default` exports a value no binding holds + anonymous_default: bool, +} + +impl Exports { + pub(crate) fn scan(program: &Program<'_>) -> Self { + let mut exports = Self::default(); + for statement in &program.body { + match statement { + Statement::ExportDeclaration(export) => { + if let Declaration::VariableDeclaration(declaration) = &export.declaration { + for declarator in &declaration.declarations { + if let Some(identifier) = declarator.id.get_binding_identifier() { + exports.add(identifier.name.as_str(), identifier.name.as_str()); + } + } + } + } + Statement::ExportNamedDeclaration(export) if !export.export_kind.is_type() => { + for specifier in export + .specifiers + .iter() + .filter(|specifier| !specifier.export_kind.is_type()) + { + exports.add( + specifier.local.name().as_str(), + specifier.exported.name().as_str(), + ); + } + } + Statement::ExportDefaultDeclaration(export) => { + match export.declaration.as_expression() { + Some(Expression::Identifier(identifier)) => { + exports.add(identifier.name.as_str(), "default"); + } + Some(_) => { + exports.anonymous_default = true; + exports.names.push("default".to_string()); + } + None => {} + } + } + _ => {} + } + } + exports.names.sort_unstable(); + exports.names.dedup(); + exports + } + + fn add(&mut self, binding: &str, exported: &str) { + self.names.push(exported.to_string()); + self.bindings + .entry(binding.to_string()) + .or_default() + .push(exported.to_string()); + } + + /// How many names the module exports + pub(crate) const fn count(&self) -> usize { + self.names.len() + } + + /// Where the exported `name` stands among the names the module exports + pub(crate) fn index(&self, name: &str) -> usize { + self.names + .binary_search_by(|exported| exported.as_str().cmp(name)) + .unwrap_or_default() + } + + /// The names the binding `binding` is exported as + pub(crate) fn of(&self, binding: &str) -> &[String] { + self.bindings.get(binding).map_or(&[], Vec::as_slice) + } + + pub(crate) fn bindings(&self) -> impl Iterator)> { + self.bindings.iter() + } + + pub(crate) const fn has_anonymous_default(&self) -> bool { + self.anonymous_default + } +} + +/// A styled component another module defines: the classes other styles +/// select it by, and what composing it takes when the build can read it +pub struct ImportedComponent<'a> { + pub markers: Vec, + pub definition: Option>, +} + +impl ImportedComponent<'_> { + /// This component in the arena of the file that reads it + pub fn clone_in<'b>(&self, allocator: &'b Allocator) -> ImportedComponent<'b> { + ImportedComponent { + markers: self.markers.clone(), + definition: self + .definition + .as_ref() + .map(|definition| definition.clone_in(allocator)), + } + } + + /// This component as another file can use it: composed only when its + /// definition holds nothing of the module defining it + fn shared(self, readable: bool) -> Self { + Self { + definition: self + .definition + .filter(|_| readable) + .and_then(StyledDefinition::shared), + ..self + } + } +} + +type Components<'a> = FxHashMap>; + +/// The components a file imports, and the modules read to find them +#[derive(Default)] +pub(crate) struct Reading<'a> { + pub components: Components<'a>, + pub dependencies: BTreeSet, +} + +/// Read the styled components `program` extends, selects or gives a `css` +/// prop, from the modules it imports them from +pub(crate) fn read<'a>( + ast_builder: &AstBuilder<'a>, + program: &Program<'a>, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, + css_prop: CssProp, +) -> Reading<'a> { + let Some(resolver) = resolver else { + return Reading::default(); + }; + let allocator = ast_builder.allocator(); + let mut reader = Reader { + option, + resolver, + allocator, + modules: FxHashMap::default(), + reading: vec![filename.to_string()], + dependencies: BTreeSet::new(), + }; + let components = reader.imports(program, filename, css_prop, allocator); + Reading { + components, + dependencies: reader.dependencies, + } +} + +/// The components a module exports, by export name +type Module<'a> = Rc>; + +struct Reader<'r, 'a> { + option: &'r ExtractOption, + resolver: &'r ModuleResolver, + allocator: &'a Allocator, + modules: FxHashMap>>, + /// The modules being read, which one of them importing another in a + /// cycle cannot be read through + reading: Vec, + dependencies: BTreeSet, +} + +impl<'a> Reader<'_, 'a> { + /// The components of the module `specifier` names, as `importer` imports it + fn module(&mut self, specifier: &str, importer: &str) -> Option> { + let module = (self.resolver)(specifier, importer)?; + self.dependencies.insert(module.path.clone()); + if let Some(read) = self.modules.get(&module.path) { + return read.clone(); + } + if self.reading.contains(&module.path) { + return None; + } + self.reading.push(module.path.clone()); + let read = self.read(&module.path, &module.code).map(Rc::new); + self.reading.pop(); + self.modules.insert(module.path, read.clone()); + read + } + + /// The styled components the module at `path` exports, found by compiling + /// it as its own extraction does + fn read(&mut self, path: &str, code: &str) -> Option> { + if is_vanilla_extract_file(path) { + return None; + } + let Aliased { + code: transformed, + css_prop, + .. + } = transform_import_aliases_with_edits( + code, + path, + &self.option.package, + &self.option.import_aliases, + ); + let sub = Allocator::default(); + let ParserReturn { + mut program, + fatal_error, + .. + } = Parser::new(&sub, &transformed, SourceType::from_path(path).ok()?).parse(); + if fatal_error { + return None; + } + let ast_builder = AstBuilder::new(&sub); + let styled_capability = + crate::extractor::styled_capability::StyledCapability::reserve(&program); + let inlined = crate::imported_constants::inline_constants( + &ast_builder, + &mut program, + path, + self.option, + Some(self.resolver), + css_prop, + ); + self.dependencies.extend(inlined.dependencies); + let imports = self.imports(&program, path, css_prop, &sub); + let mut exports = self.reexports(&program, path); + let (bucket, global, _) = crate::resolve_css_target(path, self.option); + let mut visitor = DevupVisitor::new( + &sub, + path, + &self.option.package, + Vec::new(), + if global { None } else { Some(bucket) }, + ); + visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); + visitor.import_css(inlined.css_styles); + visitor.unknown_bindings(&inlined.unknown); + visitor.changed_bindings(inlined.changed); + visitor.takes_css_prop(css_prop); + visitor.import_styled(imports); + visitor.reserve_styled(styled_capability); + visitor.visit_program(&mut program); + let readable = visitor.errors.is_empty() + && visitor.unknown_parts.is_empty() + && !visitor.composes_unknown; + exports.extend( + visitor + .components(self.allocator) + .into_iter() + .map(|(name, component)| (name, component.shared(readable))), + ); + Some(exports) + } + + /// What the module re-exports from the modules it exports from + fn reexports(&mut self, program: &Program<'_>, path: &str) -> Components<'a> { + let mut exports = FxHashMap::default(); + for statement in &program.body { + match statement { + Statement::ExportFromDeclaration(export) => { + if let Some(module) = self.module(&export.source.value, path) { + for specifier in &export.specifiers { + if let Some(component) = module.get(specifier.local.name().as_str()) { + exports.insert( + specifier.exported.name().to_string(), + component.clone_in(self.allocator), + ); + } + } + } + } + Statement::ExportAllDeclaration(export) if export.exported.is_none() => { + if let Some(module) = self.module(&export.source.value, path) { + for (name, component) in + module.iter().filter(|(name, _)| *name != "default") + { + exports + .entry(name.clone()) + .or_insert_with(|| component.clone_in(self.allocator)); + } + } + } + _ => {} + } + } + exports + } + + /// The components `program` imports that it extends, selects or gives a + /// `css` prop, in the arena of the file that reads them + fn imports<'b>( + &mut self, + program: &Program<'_>, + importer: &str, + css_prop: CssProp, + allocator: &'b Allocator, + ) -> Components<'b> { + let imported = imported_bindings(program, &self.option.package); + let mut used = Used { + styled: imported.styled, + css_prop: css_prop != CssProp::Off, + css: CssTakers::new( + program, + css_prop, + &format!("{}/compat", self.option.package), + ), + names: FxHashSet::default(), + }; + used.visit_program(program); + crate::style_values::selectors(program, &mut |expression| { + if let Expression::Identifier(identifier) = expression { + used.names.insert(identifier.name.to_string()); + } + }); + used.names.extend( + Exports::scan(program) + .bindings() + .map(|(binding, _)| binding.clone()), + ); + let mut names: Vec = used.names.into_iter().collect(); + names.sort_unstable(); + let mut components = FxHashMap::default(); + for name in names { + let found = match name.split_once('.') { + Some((namespace, member)) => imported + .bindings + .get(namespace) + .filter(|(_, export)| export == NAMESPACE) + .map(|(source, _)| (source, member)), + None => imported + .bindings + .get(&name) + .map(|(source, export)| (source, export.as_str())), + }; + if let Some((source, export)) = found + && let Some(module) = self.module(source, importer) + && let Some(component) = module.get(export) + { + components.insert(name, component.clone_in(allocator)); + } + } + components + } +} + +/// What a namespace import is imported as +const NAMESPACE: &str = "*"; + +/// What a program imports +struct Imported { + /// Each binding another module's export, with that module and the export + bindings: FxHashMap, + /// The bindings of the package's `styled` + styled: FxHashSet, +} + +fn imported_bindings(program: &Program<'_>, package: &str) -> Imported { + let mut imported = Imported { + bindings: FxHashMap::default(), + styled: FxHashSet::default(), + }; + for statement in &program.body { + let Statement::ImportDeclaration(import) = statement else { + continue; + }; + if import.import_kind.is_type() { + continue; + } + let source = import.source.value.as_str(); + let from_package = source.starts_with(package); + for specifier in import.specifiers.iter().flatten() { + let (local, export) = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) + if !specifier.import_kind.is_type() => + { + ( + specifier.local.name.to_string(), + specifier.imported.name().to_string(), + ) + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => { + (specifier.local.name.to_string(), "default".to_string()) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { + (specifier.local.name.to_string(), NAMESPACE.to_string()) + } + ImportDeclarationSpecifier::ImportSpecifier(_) => continue, + }; + if !from_package { + imported + .bindings + .insert(local, (source.to_string(), export)); + } else if export == "styled" { + imported.styled.insert(local); + } + } + } + imported +} + +/// A binding or a member of one, as the program writes it +fn written_name(expression: &Expression<'_>) -> Option { + match unwrap_syntax_only(expression) { + Expression::Identifier(identifier) => Some(identifier.name.to_string()), + Expression::StaticMemberExpression(member) => match &member.object { + Expression::Identifier(object) => { + Some(format!("{}.{}", object.name, member.property.name)) + } + _ => None, + }, + _ => None, + } +} + +/// The bindings a program composes, takes the `css` prop of, or selects +struct Used<'s> { + styled: FxHashSet, + css_prop: bool, + css: CssTakers<'s>, + names: FxHashSet, +} + +impl<'a> Visit<'a> for Used<'_> { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + let name = match unwrap_syntax_only(&call.callee) { + Expression::Identifier(callee) if self.styled.contains(callee.name.as_str()) => call + .arguments + .first() + .and_then(|first| first.as_expression()) + .and_then(written_name), + Expression::StaticMemberExpression(member) + if member.property.name == "withComponent" => + { + written_name(&member.object) + } + _ if self.css.property(call, |_| true).is_some() => call + .arguments + .first() + .and_then(|argument| argument.as_expression()) + .and_then(written_name), + _ => None, + }; + self.names.extend(name); + walk::walk_call_expression(self, call); + } + + fn visit_jsx_opening_element(&mut self, element: &JSXOpeningElement<'a>) { + if self.css_prop + && let JSXElementName::IdentifierReference(name) = &element.name + && element.attributes.iter().any(|attribute| { + matches!(attribute, JSXAttributeItem::Attribute(attribute) + if matches!(&attribute.name, JSXAttributeName::Identifier(attribute) if attribute.name == "css")) + }) + { + self.names.insert(name.name.to_string()); + } + walk::walk_jsx_opening_element(self, element); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use std::collections::{BTreeSet, HashMap}; + + use insta::assert_debug_snapshot; + use serial_test::serial; + + use super::*; + use crate::{ExtractOutput, ImportAlias, ResolvedModule, extract_with_modules}; + + fn option() -> ExtractOption { + ExtractOption { + single_css: true, + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]), + ..ExtractOption::default() + } + } + + fn extract_in(files: &[(&str, &str)], entry: &str) -> Result { + let modules: Vec<(String, String)> = files + .iter() + .map(|(path, code)| ((*path).to_string(), (*code).to_string())) + .collect(); + let resolver = move |specifier: &str, importer: &str| { + let directory = importer + .rsplit_once('/') + .map_or("", |(directory, _)| directory); + let path = format!("{directory}/{}", specifier.trim_start_matches("./")); + modules + .iter() + .find(|(file, _)| { + ["", ".ts", ".tsx", ".css.ts", "/index.ts"] + .iter() + .any(|extension| format!("{path}{extension}") == *file) + }) + .map(|(file, code)| ResolvedModule { + path: file.clone(), + code: code.clone(), + }) + }; + let code = files.iter().find(|(path, _)| *path == entry).unwrap().1; + extract_with_modules(entry, code, option(), false, &resolver).map_err(|e| e.to_string()) + } + + fn reset() { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + } + + fn render(files: &[(&str, &str)], entry: &str) -> String { + reset(); + match extract_in(files, entry) { + Ok(output) => { + let styles: BTreeSet = output + .styles + .iter() + .map(|style| format!("{style:?}")) + .collect(); + format!("{}\n{styles:#?}\n{:?}", output.code, output.dependencies) + } + Err(error) => error, + } + } + + const STYLED: &str = "import styled from '@emotion/styled';\n"; + const BASE_BODY: &str = r"export const Base = styled.div`color: red; padding: 4px;`; +export const Dyn = styled.button`font-size: ${(p) => (p.big ? '20px' : '10px')};`; +export const Pick = styled.p.withConfig({ shouldForwardProp: (prop) => prop !== 'tone' })`color: ${(p) => p.tone};`; +export default styled.span`margin: 1px;`; +"; + + fn base_module() -> String { + format!("{STYLED}{BASE_BODY}") + } + + fn consumers<'a>(base: &'a str, bodies: &[&'a str]) -> Vec { + bodies + .iter() + .map(|body| { + render( + &[ + ("/src/base.ts", base), + ( + "/src/app.tsx", + &format!( + "{STYLED}import Span, {{ Base, Dyn, Pick }} from './base';\n{body}" + ), + ), + ], + "/src/app.tsx", + ) + }) + .collect() + } + + const COMPOSITIONS: &[&str] = &[ + "export const A = styled(Base)`color: blue;`;", + "export const A = styled(Base)({ color: 'blue', padding: 8 });", + "export const A = styled(Base).attrs({ role: 'note' })`color: blue;`;", + "export const A = styled(Base).attrs((p) => ({ title: p.title }))`color: blue;`;", + "export const A = styled(Dyn)`color: blue;`;", + "export const A = styled(Pick)`margin: 0;`;", + "export const A = styled(Span)`color: blue;`;", + "export const A = styled(Base)`color: blue;`;\nexport const B = styled(A)`padding: 0;`;", + "export const W = Dyn.withComponent('section');\nexport const V = Base.withComponent(Span);", + "export const A = styled(Base)`color: blue;`;\nexport const C = () => ;", + ]; + + #[test] + #[serial] + fn imported_components_compose_like_local_ones() { + assert_debug_snapshot!(consumers(&base_module(), COMPOSITIONS)); + } + + const DEFINITIONS: [(&str, &str); 4] = [ + ( + "Base", + "export const Base = styled.div`color: red; padding: 4px;`;\n", + ), + ( + "Dyn", + "export const Dyn = styled.button`font-size: ${(p) => (p.big ? '20px' : '10px')};`;\n", + ), + ( + "Pick", + "export const Pick = styled.p.withConfig({ shouldForwardProp: (prop) => prop !== 'tone' })`color: ${(p) => p.tone};`;\n", + ), + ("Span", "export default styled.span`margin: 1px;`;\n"), + ]; + + #[test] + #[serial] + fn imported_components_give_no_style_local_ones_do_not() { + let pragma = "/** @jsxImportSource @emotion/react */\n"; + let css_props = [ + "export const C = () => ;", + "export const C = () => ;", + ]; + for body in COMPOSITIONS.iter().chain(&css_props) { + let header = if body.contains("css=") { pragma } else { "" }; + let used: Vec<&(&str, &str)> = DEFINITIONS + .iter() + .filter(|(name, _)| body.contains(name)) + .collect(); + let module: String = used.iter().map(|(_, definition)| *definition).collect(); + let named: Vec<&str> = used + .iter() + .map(|(name, _)| *name) + .filter(|name| *name != "Span") + .collect(); + let default = if used.iter().any(|(name, _)| *name == "Span") { + "Span, " + } else { + "" + }; + reset(); + let imported = extract_in( + &[ + ("/src/base.ts", &format!("{STYLED}{module}")), + ( + "/src/app.tsx", + &format!( + "{header}{STYLED}import {default}{{ {} }} from './base';\n{body}", + named.join(", ") + ), + ), + ], + "/src/app.tsx", + ) + .unwrap(); + reset(); + let local_source = format!( + "{header}{STYLED}{}\n{body}", + module.replace("export default styled.span", "const Span = styled.span") + ); + let local = extract_in(&[("/src/app.tsx", &local_source)], "/src/app.tsx").unwrap(); + assert!(imported.styles.is_subset(&local.styles), "{body}"); + assert_eq!(imported.dependencies, ["/src/base.ts"]); + } + } + #[test] + #[serial] + fn imported_components_give_css_props_what_local_ones_give() { + let pragma = "/** @jsxImportSource @emotion/react */\n"; + let bodies = [ + "export const C = () => ;", + "export const C = () => ;", + "export const C = () => ;", + "export const C = ({ rest }) => ;", + ]; + let outputs: Vec = bodies + .iter() + .map(|body| { + render( + &[ + ("/src/base.ts", &base_module()), + ( + "/src/app.tsx", + &format!("{pragma}import {{ Base, Dyn }} from './base';\n{body}"), + ), + ], + "/src/app.tsx", + ) + }) + .collect(); + assert_debug_snapshot!(outputs); + } + + #[test] + #[serial] + fn imported_components_are_selected_by_the_class_their_module_gives() { + let bodies = [ + "export const P = styled.div`${Base} { margin: 0; } ${Dyn}:hover & { color: red; }`;", + "export const P = styled.div({ [Base]: { margin: 0 }, [`&:focus ${Dyn}`]: { color: 'red' } });", + "export const P = styled.div`${Span} + ${Pick} { margin: 0; }`;", + "export const P = styled.div`${Base} { margin: 0; }`;\nexport const Q = styled(Base)`color: blue;`;", + ]; + assert_debug_snapshot!(consumers(&base_module(), &bodies)); + + let mid = format!( + "{STYLED}import {{ Base }} from './base';\nexport const Mid = styled(Base)`width: 1px;`;\nconst Local = styled(Mid)`height: 1px;`;\nexport {{ Local as Renamed }};\nexport default Mid;" + ); + let app = format!( + "{STYLED}import {{ Base }} from './base';\nimport Top, {{ Mid, Renamed }} from './mid';\nexport const P = styled.div`${{Base}} {{ margin: 0; }} ${{Mid}} {{ color: red; }} ${{Renamed}} {{ top: 0; }} ${{Top}} {{ left: 0; }}`;" + ); + assert_debug_snapshot!(render( + &[ + ("/src/base.ts", &base_module()), + ("/src/mid.ts", &mid), + ("/src/app.tsx", &app), + ], + "/src/app.tsx", + )); + } + + #[test] + #[serial] + fn every_file_computes_the_class_of_a_component_from_its_module_and_name() { + let files = [ + ("/src/base.ts", base_module()), + ( + "/src/app.tsx", + format!( + "{STYLED}import {{ Dyn }} from './base';\nexport const P = styled.div`${{Dyn}} {{ margin: 0; }}`;" + ), + ), + ]; + let files: Vec<(&str, &str)> = files.iter().map(|(p, c)| (*p, c.as_str())).collect(); + reset(); + let _ = css::file_map::get_file_num_by_filename("/src/app.tsx"); + let app = extract_in(&files, "/src/app.tsx").unwrap(); + let base = extract_in(&files, "/src/base.ts").unwrap(); + let marker = |text: &str| { + text.split(|c: char| !(c.is_alphanumeric() || c == '-' || c == '_')) + .find(|word| word.contains("--")) + .unwrap() + .to_string() + }; + let selector = app + .styles + .iter() + .find_map(|style| { + format!("{style:?}") + .split_once("& .") + .map(|(_, rest)| marker(rest)) + }) + .unwrap(); + assert!(base.code.contains(&selector), "{selector}: {}", base.code); + } + + #[test] + #[serial] + fn re_exports_and_namespaces_reach_the_defining_module() { + let reexports: &[(&str, &str)] = &[ + ("/src/base.ts", &base_module_static()), + ( + "/src/star.ts", + "export * from './base';\nexport * as everything from './base';\nexport const Base = 1;", + ), + ( + "/src/named.ts", + "export { Base as Renamed, Dyn, Missing } from './base';\nexport { default as Span } from './base';", + ), + ( + "/src/forward.ts", + "import { Dyn } from './base';\nexport { Dyn };\nexport type { Dyn as DynType };", + ), + ( + "/src/chain.ts", + "export * from './star';\nexport * from './named';", + ), + ]; + let bodies = [ + "import { Dyn } from './star';\nexport const A = styled(Dyn)`color: blue;`;", + "import { Base } from './star';\nexport const A = styled(Base)`color: blue;`;", + "import { Renamed, Span } from './named';\nexport const A = styled(Renamed)`color: blue;`;\nexport const B = styled(Span)`color: blue;`;", + "import { Dyn } from './forward';\nexport const A = styled(Dyn)`color: blue;`;", + "import { Renamed, Dyn } from './chain';\nexport const A = styled(Renamed)`color: blue;`;\nexport const B = styled(Dyn)`color: blue;`;", + "import * as UI from './named';\nexport const A = styled(UI.Renamed)`color: blue;`;\nexport const W = UI.Dyn.withComponent('a');\nexport const N = styled(UI.Nope)`color: red;`;", + "import { Base } from './named';\nexport const A = styled(Base)`color: blue;`;", + "import { Dyn, Span } from './star';\nexport const A = styled(Dyn)`color: blue;`;\nexport const B = styled(Span)`color: blue;`;", + ]; + let outputs: Vec = bodies + .iter() + .map(|body| { + let app = format!("{STYLED}{body}"); + let mut files = reexports.to_vec(); + files.push(("/src/app.tsx", &app)); + render(&files, "/src/app.tsx") + }) + .collect(); + assert_debug_snapshot!(outputs); + } + + fn base_module_static() -> String { + base_module() + } + + #[test] + #[serial] + fn imported_components_the_build_cannot_read_stay_runtime_components() { + let modules: &[(&str, &str)] = &[ + ( + "/src/link.ts", + "import styled from '@emotion/styled';\nimport Link from 'next/link';\nexport const L = styled(Link)`color: red;`;\nexport const M = styled(L)`margin: 0;`;", + ), + ( + "/src/attrs.ts", + "import styled from '@emotion/styled';\nconst handler = () => 1;\nexport const H = styled.div.attrs({ onClick: handler })`color: red;`;", + ), + ( + "/src/function.ts", + "import styled from '@emotion/styled';\nfunction scaled(n) { return n * 2; }\nexport const D = styled.div`margin: ${(p) => scaled(p.x)}px;`;", + ), + ( + "/src/global.ts", + "import styled from '@emotion/styled';\nexport const E = styled.div({ display: process.env.MODE });", + ), + ( + "/src/closed.ts", + "import styled from '@emotion/styled';\nexport const G = styled.div`margin: ${(p) => Number(p.x)}px;`;\nexport const T = styled.div({ color: ['red', 'blue'] });\nexport const U = styled.div`color: ${(p: Props) => p.tone};`;\ntype Props = { tone: string };", + ), + ( + "/src/plain.ts", + "export const Base = 5;\nexport function fn() {}\nexport const { a, b } = { a: 1, b: 2 };\nexport default function () {}\nexport type { Props };\nexport { type Props as P };", + ), + ( + "/src/bad.ts", + "import styled from '@emotion/styled';\nexport const Good = styled.div`color: red;`;\nexport const Bad = styled.div;", + ), + ( + "/src/cycle-a.ts", + "import styled from '@emotion/styled';\nimport { B } from './cycle-b';\nexport const A = styled(B)`color: red;`;", + ), + ( + "/src/cycle-b.ts", + "import styled from '@emotion/styled';\nimport { A } from './cycle-a';\nexport const B = styled(A)`color: blue;`;", + ), + ( + "/src/theme.css.ts", + "import { style } from '@vanilla-extract/css';\nexport const base = style({ color: 'red' });", + ), + ("/src/data.json", "{}"), + ("/src/broken.ts", "export const = ;"), + ( + "/src/types.ts", + "import styled from '@emotion/styled';\nexport const Base = styled.div`color: red;`;", + ), + ]; + let bodies = [ + "import { L, M } from './link';\nexport const A = styled(L)`color: blue;`;\nexport const B = styled(M)`color: blue;`;", + "import { H } from './attrs';\nimport { D } from './function';\nimport { E } from './global';\nimport { G, T, U } from './closed';\nexport const A = styled(H)`color: blue;`;\nexport const B = styled(D)`color: blue;`;\nexport const C = styled(G)`color: blue;`;\nexport const F = styled(E)`color: blue;`;\nexport const I = styled(T)`color: blue;`;\nexport const J = styled(U)`color: blue;`;", + "import Anonymous, { Base, fn, a, P } from './plain';\nexport const A = styled(Base)`color: blue;`;\nexport const B = styled(Anonymous)`color: blue;`;\nexport const C = styled(fn)`color: blue;`;\nexport const D = styled(a)`color: blue;`;\nexport const E = styled(P)`color: blue;`;", + "import { Good, Bad } from './bad';\nexport const A = styled(Good)`color: blue;`;\nexport const B = styled.div`${Good} { color: red; }`;", + "import { A } from './cycle-a';\nexport const App = styled(A)`color: blue;`;", + "import { base } from './theme.css';\nimport { Data } from './data.json';\nimport { Broken } from './broken';\nimport { Missing } from './missing';\nimport { Nope } from './types';\nexport const A = styled(Data)`color: blue;`;\nexport const B = styled(Broken)`color: blue;`;\nexport const C = styled(Missing)`color: blue;`;\nexport const D = styled(Nope)`color: blue;`;\nexport const E = styled(base)`color: blue;`;", + "import type { Base } from './types';\nimport { type Base as Other } from './types';\nexport const A = styled(Base)`color: blue;`;\nexport const B = styled(Other)`color: blue;`;", + "import { Base } from './types';\nexport const A = styled(Base)`color: blue;`;", + "import * as types from './types';\nexport const A = styled(types.deep.Base)`color: blue;`;\nexport const B = styled(types['Base'])`color: blue;`;", + ]; + let outputs: Vec = bodies + .iter() + .map(|body| { + let app = format!("{STYLED}{body}"); + let mut files = modules.to_vec(); + files.push(("/src/app.tsx", &app)); + render(&files, "/src/app.tsx") + }) + .collect(); + assert_debug_snapshot!(outputs); + } + + #[test] + #[serial] + fn exports_name_what_a_module_gives_other_files() { + let code = "import styled from '@emotion/styled';\nexport const A = 1, { b } = {};\nconst C = 2;\nexport { C, C as D };\nexport default C;\nexport function f() {}\nexport type { C as T };"; + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::ts()) + .parse() + .program; + let exports = Exports::scan(&program); + assert_eq!(exports.count(), 4); + assert_eq!(exports.of("C"), ["C", "D", "default"]); + assert_eq!(exports.of("A"), ["A"]); + assert_eq!(exports.of("missing"), [] as [String; 0]); + assert_eq!(exports.index("D"), 2); + assert!(!exports.has_anonymous_default()); + let code = "export default styled.div`color: red;`;"; + let program = Parser::new(&allocator, code, SourceType::ts()) + .parse() + .program; + let exports = Exports::scan(&program); + assert!(exports.has_anonymous_default()); + assert_eq!(exports.index("default"), 0); + } +} 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..436f43163 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; @@ -14,6 +18,7 @@ mod gen_class_name; mod gen_style; mod import_alias_visit; mod imported_constants; +mod imported_styled; mod jsx_import_source; mod jsx_pragmas; mod module_loader; @@ -130,8 +135,8 @@ pub enum ExtractStyleProp<'a> { }, } -impl<'a> ExtractStyleProp<'a> { - pub fn clone_in(&self, alloc: &'a Allocator) -> Self { +impl ExtractStyleProp<'_> { + pub fn clone_in<'b>(&self, alloc: &'b Allocator) -> ExtractStyleProp<'b> { match self { ExtractStyleProp::Static(v) => ExtractStyleProp::Static(v.clone()), ExtractStyleProp::StaticArray(arr) => { @@ -342,6 +347,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() @@ -547,6 +554,7 @@ fn extract_source( let mut program = parsing::parse(&allocator, filename, code_to_parse)?; let header_pragmas = jsx_pragmas::HeaderPragmas::capture(&program); + let styled_capability = extractor::styled_capability::StyledCapability::reserve(&program); let inlined = if processed_code.is_none() { imported_constants::inline_constants( &oxc_ast::builder::AstBuilder::new(&allocator), @@ -566,6 +574,19 @@ fn extract_source( failures.dedup(); return Err(failures.join("\n").into()); } + let imported = if processed_code.is_none() { + imported_styled::read( + &oxc_ast::builder::AstBuilder::new(&allocator), + &program, + filename, + &option, + resolver, + css_prop, + ) + } else { + imported_styled::Reading::default() + }; + dependencies.extend(imported.dependencies); if !namespace_edits.is_empty() { emotion_namespace::units::pixelify(&allocator, &mut program, &namespace_code); } @@ -578,9 +599,14 @@ 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); + visitor.import_styled(imported.components); + visitor.reserve_styled(styled_capability); visitor.visit_program(&mut program); if !has_relevant_import && alias_edits.is_empty() @@ -15599,7 +15625,7 @@ export const Other = other.withComponent('aside') export const a =
", ); for expected in [ - "export const Aside = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"aside\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"color-0-red--255\", className]", + "export const Aside = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"aside\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"c-0-Aside color-0-red--255\", className]", "as: DevupAs = Link,", "as: DevupAs = motion.div,", "export const Again = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"nav\",", @@ -23520,6 +23546,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 +23624,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 +23696,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__imported_styled__tests__imported_components_are_selected_by_the_class_their_module_gives-2.snap b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_are_selected_by_the_class_their_module_gives-2.snap new file mode 100644 index 000000000..90c21aa9e --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_are_selected_by_the_class_their_module_gives-2.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/imported_styled.rs +expression: "render(&[(\"/src/base.ts\", &base_module()), (\"/src/mid.ts\", &mid),\n(\"/src/app.tsx\", &app),], \"/src/app.tsx\",)" +--- +"import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Base } from \"./base\";\nimport Top, { Mid, Renamed } from \"./mid\";\nexport const P = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"c--a j k l m\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"red\\\", level: 0, selector: Some(Selector(\\\"& .b--a\\\")), style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"left\\\", value: \\\"0\\\", level: 0, selector: Some(Selector(\\\"& .b--a\\\")), style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: Some(Selector(\\\"& .a--a\\\")), style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"top\\\", value: \\\"0\\\", level: 0, selector: Some(Selector(\\\"& .b--b\\\")), style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/mid.ts\"]" diff --git a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_are_selected_by_the_class_their_module_gives.snap b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_are_selected_by_the_class_their_module_gives.snap new file mode 100644 index 000000000..1f74cf516 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_are_selected_by_the_class_their_module_gives.snap @@ -0,0 +1,10 @@ +--- +source: libs/extractor/src/imported_styled.rs +expression: "consumers(&base_module(), &bodies)" +--- +[ + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const P = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a h i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"red\\\", level: 0, selector: Some(Selector(\\\".a--b:hover &\\\")), style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: Some(Selector(\\\"& .a--a\\\")), style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const P = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a h i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"red\\\", level: 0, selector: Some(Selector(\\\"&:focus .a--b\\\")), style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: Some(Selector(\\\"& .a--a\\\")), style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const P = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a h\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: Some(Selector(\\\"& .a--d + .a--c\\\")), style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const P = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a h\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const Q = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b a--a i a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: Some(Selector(\\\"& .a--a\\\")), style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", +] diff --git a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_compose_like_local_ones.snap b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_compose_like_local_ones.snap new file mode 100644 index 000000000..ea9adb269 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_compose_like_local_ones.snap @@ -0,0 +1,16 @@ +--- +source: libs/extractor/src/imported_styled.rs +expression: "consumers(&base_module(), COMPOSITIONS)" +--- +[ + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"8px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)(((__devupContext) => ({\n\t...__devupContext,\n\tclassName: [__devupContext.className, __devupProps.className].filter(Boolean).join(\" \") || undefined\n}))(((__devupContext) => ((__devupAttrs) => ({\n\t...__devupContext,\n\t...__devupAttrs,\n\tclassName: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined,\n\tstyle: {\n\t\t...__devupContext.style,\n\t\t...__devupAttrs.style\n\t}\n}))({\n\t...{},\n\t...((p) => ({ title: p.title }))(__devupContext)\n}))({\n\t...__devupProps,\n\tclassName: undefined\n}))))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--b h c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--c h e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (p=>p.tone)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"color\\\", level: 0, identifier: \\\"(p=>p.tone)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"0\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--d h g\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"1px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b b--a a--a i h\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"0\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const W = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"section\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b a--b c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const V = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Span, forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`b--a a--a ${rest.__devupCss0 ?? \"a b\"}`, className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"red\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Span, { Base, Dyn, Pick } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = () => ;\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", +] diff --git a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_give_css_props_what_local_ones_give.snap b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_give_css_props_what_local_ones_give.snap new file mode 100644 index 000000000..9c039a474 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_give_css_props_what_local_ones_give.snap @@ -0,0 +1,10 @@ +--- +source: libs/extractor/src/imported_styled.rs +expression: outputs +--- +[ + "/** @jsxImportSource react */\nimport \"@devup-ui/react/devup-ui.css\";\nimport { Base, Dyn } from \"./base\";\nexport const C = () =>
;\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "/src/app.tsx:3:30: `css` on `` overrides styles `Base` sets, which the build orders only for a styled component rendering a tag with no attrs or props read, given no spread, `as` or `forwardedAs`: move these styles into `styled(Base)(...)`", + "/** @jsxImportSource react */\nimport \"@devup-ui/react/devup-ui.css\";\nimport { Base, Dyn } from \"./base\";\nexport const C = () => ;\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "/** @jsxImportSource react */\nimport \"@devup-ui/react/devup-ui.css\";\nimport { Base, Dyn } from \"./base\";\nexport const C = ({ rest }) => ;\n\n{\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"1px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", +] diff --git a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_the_build_cannot_read_stay_runtime_components.snap b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_the_build_cannot_read_stay_runtime_components.snap new file mode 100644 index 000000000..140ca50b0 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_the_build_cannot_read_stay_runtime_components.snap @@ -0,0 +1,15 @@ +--- +source: libs/extractor/src/imported_styled.rs +expression: outputs +--- +[ + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { L, M } from \"./link\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = L, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = M, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/link.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { H } from \"./attrs\";\nimport { D } from \"./function\";\nimport { E } from \"./global\";\nimport { G, T, U } from \"./closed\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = H, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--a i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = D, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--b i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--c c--a i a\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--b\": `${(p=>Number(p.x))(rest)}px` },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const F = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = E, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--d i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const I = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--e c--b i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const J = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e--f c--c i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"margin\\\", level: 0, identifier: \\\"`${(p=>Number(p.x))(rest)}px`\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/attrs.ts\", \"/src/closed.ts\", \"/src/function.ts\", \"/src/global.ts\"]", + "/src/app.tsx:3:18: `styled()` cannot use `styled(5)` at build time: it renders a tag, a component or a value naming one, with rule objects or CSS text", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Good, Bad } from \"./bad\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Good, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"red\\\", level: 0, selector: Some(Selector(\\\"& .a--b\\\")), style_order: None, layer: None })\",\n}\n[\"/src/bad.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { A } from \"./cycle-a\";\nexport const App = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = A, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"c--a a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/cycle-a.ts\", \"/src/cycle-b.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { base } from \"./theme.css\";\nimport { Data } from \"./data.json\";\nimport { Broken } from \"./broken\";\nimport { Missing } from \"./missing\";\nimport { Nope } from \"./types\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Data, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Broken, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Missing, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--c b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const D = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Nope, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--d b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const E = ((DevupStyled) => __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = DevupStyled, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--e b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n})))(base);\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/broken.ts\", \"/src/data.json\", \"/src/theme.css.ts\", \"/src/types.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport type { Base } from \"./types\";\nimport { type Base as Other } from \"./types\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Base, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Other, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/types.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Base } from \"./types\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/types.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as types from \"./types\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = types.deep.Base, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = ((DevupStyled) => __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = DevupStyled, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n})))(types[\"Base\"]);\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/types.ts\"]", +] diff --git a/libs/extractor/src/snapshots/extractor__imported_styled__tests__re_exports_and_namespaces_reach_the_defining_module.snap b/libs/extractor/src/snapshots/extractor__imported_styled__tests__re_exports_and_namespaces_reach_the_defining_module.snap new file mode 100644 index 000000000..8088d3f8f --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__imported_styled__tests__re_exports_and_namespaces_reach_the_defining_module.snap @@ -0,0 +1,14 @@ +--- +source: libs/extractor/src/imported_styled.rs +expression: outputs +--- +[ + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn } from \"./star\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--b h c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/star.ts\"]", + "/src/app.tsx:3:18: `styled()` cannot use `styled(1)` at build time: it renders a tag, a component or a value naming one, with rule objects or CSS text", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Renamed, Span } from \"./named\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b a--d h g\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"1px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/named.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn } from \"./forward\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--b h c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/forward.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Renamed, Dyn } from \"./chain\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b a--b h c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/chain.ts\", \"/src/named.ts\", \"/src/star.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as UI from \"./named\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--a h a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const W = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"a\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--c a--b c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const N = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = UI.Nope, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"red\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/named.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Base } from \"./named\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Base, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a h\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/named.ts\"]", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn, Span } from \"./star\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--b h c\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--d\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Span, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--b h\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{\n \"Dynamic(ExtractDynamicStyle { property: \\\"font-size\\\", level: 0, identifier: \\\"(p=>p.big?`20px`:`10px`)(rest)\\\", selector: None, style_order: None })\",\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\", \"/src/star.ts\"]", +] diff --git a/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap b/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap index c889379bd..901a1c8c2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap +++ b/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap @@ -5,7 +5,7 @@ expression: outputs [ ( "import { css, styled, Box } from '@devup-ui/react';\nconst myCss = css;\nconst again = myCss;\nconst S = styled;\nconst B = Box;\nexport const a = again({ color: 'red' });\nexport const s = S.div({ color: 'blue' });\nexport const b = ;", - "import { forwardRef as __devupForwardRef } from \"react\";\nexport const a = \"a-a\";\nexport const s = __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 b =
;", + "import { forwardRef as __devupForwardRef } from \"react\";\nexport const a = \"a-a\";\nexport const s = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--c a-b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const b =
;", ), ( "import { css } from '@devup-ui/react';\nexport function f(css) { return css; }\nexport const a = css({ color: 'red' });", diff --git a/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap index 3c166a75c..9b0e29e38 100644 --- a/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap +++ b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap @@ -75,5 +75,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as tokens from \"./styles\";\nimport { card, rules } from \"./styles\";\nimport { emotionClass } from \"./emotion\";\nconst make = (n: number) => ({ m: n });\nconst local = make(2);\nconst computed = make(3);\nconst name = String(\"named\");\nconst shared = {\n\trules: make(4),\n\tfixed: { p: 5 }\n};\nexport const a = \"a-e\";\nexport const b = \"a-a a-f\";\nexport const c = `named x ${`y`}`;\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-g\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const g = (on) => `a-e a-b ${on ? typeof on === \"string\" ? on : \"\" : \"\"}`;\nexport const h = \"a-c\";\nexport const i = \"a-e\";\nexport const k = `k ${tokens.card}`;\nexport const l = `a-d ${emotionClass}`;\nexport const n = ;\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as tokens from \"./styles\";\nimport { card, rules } from \"./styles\";\nimport { emotionClass } from \"./emotion\";\nconst make = (n: number) => ({ m: n });\nconst local = make(2);\nconst computed = make(3);\nconst name = String(\"named\");\nconst shared = {\n\trules: make(4),\n\tfixed: { p: 5 }\n};\nexport const a = \"a-e\";\nexport const b = \"a-a a-f\";\nexport const c = `named x ${`y`}`;\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d a-e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--e a-f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--f a-g\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const g = (on) => `a-e a-b ${on ? typeof on === \"string\" ? on : \"\" : \"\"}`;\nexport const h = \"a-c\";\nexport const i = \"a-e\";\nexport const k = `k ${tokens.card}`;\nexport const l = `a-d ${emotionClass}`;\nexport const n = ;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap b/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap index b9633f496..bdeee1ae0 100644 --- a/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap +++ b/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap @@ -99,5 +99,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY } from \"./tokens\";\nconst make = (n: number) => ({ m: n });\nconst KEY = [\"co\", \"lor\"].join(\"\");\nfunction darken(amount: number, color: string) {\n\treturn color === PRIMARY ? \"#112233\" : color;\n}\nconst card = make(2);\nconst hover = make(3);\nconst parts = [make(4), { p: 1 }];\nexport const a =
1} className=\"a-c\" />;\nexport const b =
;\nexport const c =
;\nexport const d =
;\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-f a-e a-g\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = \"a-h a-a\";\nexport const g = \"a-i\";\nexport const h =
;\nfunction local() {\n\treturn { p: 7 };\n}\nexport const i =
;\nexport const j = (register) => ((__devupSpread0) =>
)(register(\"email\"));\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY } from \"./tokens\";\nconst make = (n: number) => ({ m: n });\nconst KEY = [\"co\", \"lor\"].join(\"\");\nfunction darken(amount: number, color: string) {\n\treturn color === PRIMARY ? \"#112233\" : color;\n}\nconst card = make(2);\nconst hover = make(3);\nconst parts = [make(4), { p: 1 }];\nexport const a =
1} className=\"a-c\" />;\nexport const b =
;\nexport const c =
;\nexport const d =
;\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--e a-f a-e a-g\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = \"a-h a-a\";\nexport const g = \"a-i\";\nexport const h =
;\nfunction local() {\n\treturn { p: 7 };\n}\nexport const i =
;\nexport const j = (register) => ((__devupSpread0) =>
)(register(\"email\"));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap b/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap index 931bd225c..247c9e524 100644 --- a/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap +++ b/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap @@ -117,5 +117,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const a = `a ${cond ? \"b\" : \"c\"} ${cond ? \"d\" : \"\"}`;\nexport const b = `${flag ? \"e\" : \"f\"} ${flag ? \"\" : \"g\"}`;\nexport const c = `c ${cond ? other : \"\"} ${flag ? \"x\" : \"\"}`;\nexport const d = `i ${cond ? \"h\" : \"\"}`;\nexport const e = `${flag ? other : \"\"} ${flag ? \"c\" : \"b\"}`;\nexport const h = `${cond ? typeof cond === \"string\" ? cond : \"\" : \"\"} ${cond ? \"c\" : \"b\"}`;\nexport const i = \"c\";\nexport const j = `${value != null ? typeof value === \"string\" ? value : \"\" : \"\"} ${value != null ? \"c\" : \"b\"}`;\nexport const K = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, cond);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`${base} ${`j ${__devupRecord0 ? \"b\" : \"\"}`}`, className].filter(Boolean).join(\" \")} style={style} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const a = `a ${cond ? \"b\" : \"c\"} ${cond ? \"d\" : \"\"}`;\nexport const b = `${flag ? \"e\" : \"f\"} ${flag ? \"\" : \"g\"}`;\nexport const c = `c ${cond ? other : \"\"} ${flag ? \"x\" : \"\"}`;\nexport const d = `i ${cond ? \"h\" : \"\"}`;\nexport const e = `${flag ? other : \"\"} ${flag ? \"c\" : \"b\"}`;\nexport const h = `${cond ? typeof cond === \"string\" ? cond : \"\" : \"\"} ${cond ? \"c\" : \"b\"}`;\nexport const i = \"c\";\nexport const j = `${value != null ? typeof value === \"string\" ? value : \"\" : \"\"} ${value != null ? \"c\" : \"b\"}`;\nexport const K = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, cond);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`a--a ${`${base} ${`j ${__devupRecord0 ? \"b\" : \"\"}`}`}`, className].filter(Boolean).join(\" \")} style={style} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap b/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap index ba2a220df..163dbf50e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap +++ b/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap @@ -305,6 +305,6 @@ Ok( }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { space, Size, Local, focus } from \"./tokens\";\nconst base = {\n\tp: 4,\n\tcolor: \"red\",\n\tcolor: \"blue\"\n};\nconst hover = { opacity: .5 };\nconst merged = {\n\t...base,\n\tm: 1\n};\nconst partial = {\n\t...unknown,\n\tm: 1\n};\nconst proto = {\n\t__proto__: null,\n\ta: 1\n};\nconst getter = { get a() {\n\treturn 1;\n} };\nexport const withGetter =
;\nconst NONE = null;\nconst MEDIA = \"@media (min-width: 1px)\";\nconst keyed = { [MEDIA]: { color: \"green\" } };\nconst widths = [\n\t10,\n\tnull,\n\t20\n];\nconst more = [...widths, 30];\nconst bad = [...unknown];\nenum Level {\n\tLow = 1,\n\tHigh,\n\tName = \"n\",\n\tComputed = Math.max(1, 2),\n\tLast = 9\n}\ndeclare enum Ambient {\n\tX = 1\n}\nconst sx = { color: \"red\" };\nexport const a = \"a-b a-c\";\nexport const b = \"a-b a-d a-e a-f\";\nexport const c = \"a-b a-c a-g\";\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-b a-c a-e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = (Comp, handler) => ;\nexport const g =
;\nexport const h = \"a-b a-c\";\nexport const i = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const j = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"button\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const k = { \"x\": \"a-s\" };\n;\nexport const l = \"a-t\";\nexport const m = \"a-u a-v a-y a-z\";\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { space, Size, Local, focus } from \"./tokens\";\nconst base = {\n\tp: 4,\n\tcolor: \"red\",\n\tcolor: \"blue\"\n};\nconst hover = { opacity: .5 };\nconst merged = {\n\t...base,\n\tm: 1\n};\nconst partial = {\n\t...unknown,\n\tm: 1\n};\nconst proto = {\n\t__proto__: null,\n\ta: 1\n};\nconst getter = { get a() {\n\treturn 1;\n} };\nexport const withGetter =
;\nconst NONE = null;\nconst MEDIA = \"@media (min-width: 1px)\";\nconst keyed = { [MEDIA]: { color: \"green\" } };\nconst widths = [\n\t10,\n\tnull,\n\t20\n];\nconst more = [...widths, 30];\nconst bad = [...unknown];\nenum Level {\n\tLow = 1,\n\tHigh,\n\tName = \"n\",\n\tComputed = Math.max(1, 2),\n\tLast = 9\n}\ndeclare enum Ambient {\n\tX = 1\n}\nconst sx = { color: \"red\" };\nexport const a = \"a-b a-c\";\nexport const b = \"a-b a-d a-e a-f\";\nexport const c = \"a-b a-c a-g\";\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--e a-b a-c a-e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = (Comp, handler) => ;\nexport const g =
;\nexport const h = \"a-b a-c\";\nexport const i = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--i a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const j = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--j a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"button\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const k = { \"x\": \"a-s\" };\n;\nexport const l = \"a-t\";\nexport const m = \"a-u a-v a-y a-z\";\n", }, ) diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap index cb518b62a..2ea65ba63 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles-2.snap @@ -45,5 +45,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const G = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ 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...__devupProps,\n\t...{ type: \"button\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const H = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Base, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const I = __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 j = \"d\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const G = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ type: \"button\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const H = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Base, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const I = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--c c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const j = \"d\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap index 1147da59b..569e4ab3d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap @@ -166,5 +166,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global } from \"@devup-ui/react/compat\";\nexport const a = \"a b c d\";\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const D = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"f g h i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const E = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, cond);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`j ${__devupRecord0 ? \"k\" : \"\"}`, className].filter(Boolean).join(\" \")} style={style} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const F = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"l\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const spin = \"m\";\nexport const App = () => <>;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global } from \"@devup-ui/react/compat\";\nexport const a = \"a b c d\";\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--c f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const D = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d f g h i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const E = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, cond);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`a--e ${`j ${__devupRecord0 ? \"k\" : \"\"}`}`, className].filter(Boolean).join(\" \")} style={style} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const F = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--f l\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const spin = \"m\";\nexport const App = () => <>;\n", } 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..f4d123685 --- /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--c a\", 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={[\"a--b b c\", 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--c,& .a--c + a\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& .a--c\")), 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 a\", 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={[\"a--a b c\", 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--b\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: Some(Selector(\"&:hover .a--b\")), 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--c a\", 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 a\", 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={[\"a--b c\", 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--c\")), 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--c a\", 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={[\"a--b b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = () =>
;\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: Some(Selector(\"& .a--c\")), 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--a 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--a 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/snapshots/extractor__tests__inline_imported_constants.snap b/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap index 22c14a746..3cbab3fa2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap @@ -475,5 +475,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY, SIZE, NEG, TEMPLATE, colors, ALIAS, MUTABLE, DYNAMIC, renamed, alsoMutable, reBase, notThere, gone, value, ns, NEG_STRING, TEMPLATE_EXPRESSION, SPACED, NOT_OBJECT, helper, looped } from \"./tokens\";\nimport orange from \"./tokens\";\nimport * as tokens from \"./tokens\";\nimport named from \"./named\";\nimport { handler } from \"./handler\";\nimport cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC } from \"./cjs-tokens\";\nimport cjsObject, { OBJ, OVERRIDDEN, LATER } from \"./cjs-object\";\nimport twelve from \"./cjs-value\";\nimport twice from \"./cjs-twice\";\nimport marker, { named as markerNamed } from \"./cjs-marker\";\nimport { unused } from \"./unused\";\nimport missing from \"./missing\";\nexport const a =
;\nexport const b = \"a\";\nexport const c =
;\nexport const d = __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 e = (PRIMARY) =>
;\nexport const f = {\n\tPRIMARY,\n\tunused,\n\tSIZE: tokens.SIZE\n};\nexport const g =
;\nexport const h = ;\nexport const i = Devup[\"css\"]({ color: SIZE });\nexport const l =
;\nexport const j =
;\nexport const k = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"ba\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY, SIZE, NEG, TEMPLATE, colors, ALIAS, MUTABLE, DYNAMIC, renamed, alsoMutable, reBase, notThere, gone, value, ns, NEG_STRING, TEMPLATE_EXPRESSION, SPACED, NOT_OBJECT, helper, looped } from \"./tokens\";\nimport orange from \"./tokens\";\nimport * as tokens from \"./tokens\";\nimport named from \"./named\";\nimport { handler } from \"./handler\";\nimport cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC } from \"./cjs-tokens\";\nimport cjsObject, { OBJ, OVERRIDDEN, LATER } from \"./cjs-object\";\nimport twelve from \"./cjs-value\";\nimport twice from \"./cjs-twice\";\nimport marker, { named as markerNamed } from \"./cjs-marker\";\nimport { unused } from \"./unused\";\nimport missing from \"./missing\";\nexport const a =
;\nexport const b = \"a\";\nexport const c =
;\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = (PRIMARY) =>
;\nexport const f = {\n\tPRIMARY,\n\tunused,\n\tSIZE: tokens.SIZE\n};\nexport const g =
;\nexport const h = ;\nexport const i = Devup[\"css\"]({ color: SIZE });\nexport const l =
;\nexport const j =
;\nexport const k = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--k ba\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__package_imported_whole.snap b/libs/extractor/src/snapshots/extractor__tests__package_imported_whole.snap index 5e57a16b6..a85c6355f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__package_imported_whole.snap +++ b/libs/extractor/src/snapshots/extractor__tests__package_imported_whole.snap @@ -9,10 +9,10 @@ expression: outputs ), ( "import Devup from '@devup-ui/react';\nexport const S = Devup.styled.div({ color: 'red' });\nexport const T = Devup.styled('span', { color: 'blue' });\nexport const U = Devup.styled.p.attrs({ role: 'note' })({ m: 1 });\nexport const g = ;", - "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Devup from \"@devup-ui/react\";\nexport const S = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const T = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", 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 U = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const g = ;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport Devup from \"@devup-ui/react\";\nexport const S = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const T = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b a-b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const U = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--c a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const g = ;\n", ), ( "import * as Devup from '@devup-ui/react';\nimport { styled } from '@devup-ui/react';\nexport const S = styled.div({ p: 1 });\nexport const o = other.styled.div({ p: 1 });\nexport const m = ;", - "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nexport const S = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const o = other.styled.div({ p: 1 });\nexport const m = ;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nexport const S = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const o = other.styled.div({ p: 1 });\nexport const m = ;\n", ), ] diff --git a/libs/extractor/src/snapshots/extractor__tests__style_results_read_as_values.snap b/libs/extractor/src/snapshots/extractor__tests__style_results_read_as_values.snap index 60899524c..467458353 100644 --- a/libs/extractor/src/snapshots/extractor__tests__style_results_read_as_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__style_results_read_as_values.snap @@ -141,5 +141,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global } from \"@devup-ui/react/compat\";\nconst fadeIn = \"a-a\";\nconst slide = \"a-b\";\nconst card = \"a-c\";\nexport const a = \"a-d a-e\";\nexport const b = \"a-c a-f\";\nexport const c = \"a-g\";\nexport const d =
;\nexport const E = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n;\nexport const g = ;\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global } from \"@devup-ui/react/compat\";\nconst fadeIn = \"a-a\";\nconst slide = \"a-b\";\nconst card = \"a-c\";\nexport const a = \"a-d a-e\";\nexport const b = \"a-c a-f\";\nexport const c = \"a-g\";\nexport const d =
;\nexport const E = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a-i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n;\nexport const g = ;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_components_attrs_and_every_styled_import.snap b/libs/extractor/src/snapshots/extractor__tests__styled_components_attrs_and_every_styled_import.snap index 67bda7e78..363aa35ea 100644 --- a/libs/extractor/src/snapshots/extractor__tests__styled_components_attrs_and_every_styled_import.snap +++ b/libs/extractor/src/snapshots/extractor__tests__styled_components_attrs_and_every_styled_import.snap @@ -55,5 +55,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const A = __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 B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ type: \"button\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const D = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Base, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"d\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const E = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"input\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"e\", className].filter(Boolean).join(\" \")} style={style} />)(((__devupContext) => ({\n\t...__devupContext,\n\tclassName: [__devupContext.className, __devupProps.className].filter(Boolean).join(\" \") || undefined\n}))(((__devupContext) => ((__devupAttrs) => ({\n\t...__devupContext,\n\t...__devupAttrs,\n\tclassName: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined,\n\tstyle: {\n\t\t...__devupContext.style,\n\t\t...__devupAttrs.style\n\t}\n}))({\n\t...{},\n\t...typeof extra === \"function\" ? extra(__devupContext) : extra\n}))(((__devupContext) => ((__devupAttrs) => ({\n\t...__devupContext,\n\t...__devupAttrs,\n\tclassName: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined,\n\tstyle: {\n\t\t...__devupContext.style,\n\t\t...__devupAttrs.style\n\t}\n}))({\n\t...{},\n\t...((props) => ({ size: props.small ? 5 : 10 }))(__devupContext)\n}))({\n\t...__devupProps,\n\tclassName: undefined\n})))))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const G = other.div.attrs({ role: \"note\" })({ margin: 1 });\nexport const I = factory.attrs({ id: \"i\" })({ margin: 3 });\nexport const J = other(Base).attrs({ id: \"j\" })({ margin: 4 });\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--c c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ type: \"button\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const D = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = Base, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d d\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const E = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"input\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--e e\", className].filter(Boolean).join(\" \")} style={style} />)(((__devupContext) => ({\n\t...__devupContext,\n\tclassName: [__devupContext.className, __devupProps.className].filter(Boolean).join(\" \") || undefined\n}))(((__devupContext) => ((__devupAttrs) => ({\n\t...__devupContext,\n\t...__devupAttrs,\n\tclassName: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined,\n\tstyle: {\n\t\t...__devupContext.style,\n\t\t...__devupAttrs.style\n\t}\n}))({\n\t...{},\n\t...typeof extra === \"function\" ? extra(__devupContext) : extra\n}))(((__devupContext) => ((__devupAttrs) => ({\n\t...__devupContext,\n\t...__devupAttrs,\n\tclassName: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined,\n\tstyle: {\n\t\t...__devupContext.style,\n\t\t...__devupAttrs.style\n\t}\n}))({\n\t...{},\n\t...((props) => ({ size: props.small ? 5 : 10 }))(__devupContext)\n}))({\n\t...__devupProps,\n\tclassName: undefined\n})))))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const G = other.div.attrs({ role: \"note\" })({ margin: 1 });\nexport const I = factory.attrs({ id: \"i\" })({ margin: 3 });\nexport const J = other(Base).attrs({ id: \"j\" })({ margin: 4 });\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_components_of_any_base.snap b/libs/extractor/src/snapshots/extractor__tests__styled_components_of_any_base.snap index 88f671265..ee877a041 100644 --- a/libs/extractor/src/snapshots/extractor__tests__styled_components_of_any_base.snap +++ b/libs/extractor/src/snapshots/extractor__tests__styled_components_of_any_base.snap @@ -5,15 +5,15 @@ expression: outputs [ ( "export const A = styled(motion.div)`color: red;`;", - "export const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = motion.div, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));", + "export const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = motion.div, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));", ), ( "export const B = styled((Motion as any).Div.Inner)({ color: 'red' });", - "export const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = (Motion as any).Div.Inner, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));", + "export const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = (Motion as any).Div.Inner, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));", ), ( "export const C = styled(forwardRef((p, r) =>
)).attrs({ role: 'x' })`color: red;`;", - "export const C = ((DevupStyled) => __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = DevupStyled, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"x\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n})))(forwardRef((p, r) =>
));", + "export const C = ((DevupStyled) => __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = DevupStyled, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"x\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n})))(forwardRef((p, r) =>
));", ), ( "export const D = (tag) => styled(tag, { color: 'red' });", @@ -21,6 +21,6 @@ expression: outputs ), ( "export const E = styled(getThing().div)`color: red;`;", - "export const E = ((DevupStyled) => __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = DevupStyled, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n})))(getThing().div);", + "export const E = ((DevupStyled) => __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = DevupStyled, forwardedAs, ...rest }) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a-a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n})))(getThing().div);", ), ] diff --git a/libs/extractor/src/snapshots/extractor__tests__template_interpolations.snap b/libs/extractor/src/snapshots/extractor__tests__template_interpolations.snap index 0e542d901..8758d1e79 100644 --- a/libs/extractor/src/snapshots/extractor__tests__template_interpolations.snap +++ b/libs/extractor/src/snapshots/extractor__tests__template_interpolations.snap @@ -99,5 +99,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst C = \"red\";\nconst O = .5;\nconst SEL = \".x\";\nconst SNIPPET = \"display: flex;\";\nconst mixin = \"a-a\";\n;\n;\nexport const k = \"a-b\";\nexport const c = \"a-c a-d\";\nexport const d = `a-e ${mixin}`;\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`a-d ${mixin} ${((p) => p.active && mixin)(rest) || \"\"}`, className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst C = \"red\";\nconst O = .5;\nconst SEL = \".x\";\nconst SNIPPET = \"display: flex;\";\nconst mixin = \"a-a\";\n;\n;\nexport const k = \"a-b\";\nexport const c = \"a-c a-d\";\nexport const d = `a-e ${mixin}`;\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`a--a ${`a-d ${mixin} ${((p) => p.active && mixin)(rest) || \"\"}`}`, className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b a-f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 892efb73a..73707842d 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; } @@ -64,6 +79,20 @@ impl<'a> StyleValues<'a> { .then_some(symbol) } + /// The name the import `symbol` binds + pub fn import_name(&self, symbol: SymbolId) -> Option<&str> { + let scoping = self.scoping.as_ref()?; + scoping + .symbol_flags(symbol) + .is_import() + .then(|| scoping.symbol_name(symbol)) + } + + /// The top-level binding `name` names + pub fn root_symbol(&self, name: &str) -> Option { + self.scoping.as_ref()?.get_root_binding(name.into()) + } + /// Whether `symbol` is declared below the top level of the module, where /// the constants styles read are not pub fn is_local(&self, symbol: SymbolId) -> bool { @@ -76,6 +105,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 +282,7 @@ impl<'a> StyleValues<'a> { ast, scoping, values: &self.values, + in_key: false, context: if in_rules { ReadContext::Rules } else { @@ -262,6 +296,62 @@ 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 symbols = FxHashSet::default(); + selectors(program, &mut |expression| { + symbols.extend(values.symbol(expression)); + }); + symbols +} + +/// Visit the expressions styles read as selectors: interpolated in CSS text +/// where a value cannot stand, or as the computed key of a rule object +pub fn selectors<'a>(program: &oxc_ast::ast::Program<'a>, found: &mut dyn FnMut(&Expression<'a>)) { + oxc_ast_visit::Visit::visit_program(&mut Selected { found }, program); +} + +struct Selected<'s, 'a> { + found: &'s mut dyn FnMut(&Expression<'a>), +} + +impl<'a> oxc_ast_visit::Visit<'a> for Selected<'_, 'a> { + 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.found)(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.found)(expression); + } + } + key => (self.found)(key), + } + } + oxc_ast_visit::walk::walk_object_property(self, it); + } +} + struct Names<'s, 'a> { ast: &'s AstBuilder<'a>, scoping: &'s Scoping, @@ -305,6 +395,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 +405,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 +455,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 +502,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 +542,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/compatibility.rs b/libs/extractor/src/styled_source_tests/compatibility.rs index c188c420e..9e584a110 100644 --- a/libs/extractor/src/styled_source_tests/compatibility.rs +++ b/libs/extractor/src/styled_source_tests/compatibility.rs @@ -26,9 +26,9 @@ fn output_is_byte_identical_when_source_accepts_jsx( let filename = format!("test.{extension}"); let source = "import { styled } from '@devup-ui/react';\nexport const Card = styled('article', { p: 2, color: 'olive' });"; let expected = if single_css { - "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const Card = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"article\", 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}));\n" + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const Card = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"article\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n" } else { - "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const Card = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"article\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a a-b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n" + "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const Card = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"article\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--a a-a a-b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n" }; // When: the real extractor compiles the control. let output = compile(&filename, source, single_css); 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..a65902d2e 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,14 +27,16 @@ 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_capability::StyledCapability, + styled_css_slot::{CssPropLowering, StyledContext}, styled_jsx::StyledJsx, }; use crate::gen_class_name::{gen_class_names, merge_expression_for_class_name}; use crate::gen_style::gen_styles; +use crate::imported_styled::{Exports, ImportedComponent}; use crate::prop_modify_utils::{ add_class_and_style, add_class_and_style_to_object, compile_cva, convert_class_name, modify_prop_object, modify_props, written_class_name, written_object_class_name, @@ -52,27 +57,30 @@ use oxc_ast::ast::JSXAttributeItem::Attribute; use oxc_ast::ast::JSXAttributeName::Identifier; use oxc_ast::ast::{ Argument, ArrayExpressionElement, BinaryOperator, BindingPattern, CallExpression, ChainElement, - 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, + ComputedMemberExpression, ExportDefaultDeclaration, Expression, ExpressionStatement, + FormalParameter, FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, + ImportOrExportKind, JSXAttributeItem, JSXAttributeValue, JSXChild, JSXElement, JSXElementName, + 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, - walk_variable_declarator, walk_variable_declarators, + walk_call_expression, walk_export_default_declaration, walk_expression, + walk_expression_statement, walk_import_declaration, walk_jsx_attribute_value, walk_jsx_child, + walk_jsx_element, walk_program, walk_variable_declarator, walk_variable_declarators, }; use oxc_syntax::number::NumberBase; 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, @@ -191,7 +199,7 @@ pub struct DevupVisitor<'a> { pub errors: Vec<(u32, String)>, styled_imports: FxHashSet, styled_jsx: Option, - styled_css_slot: Option, + styled_capability: Option, /// Tracked `StyleX` default/namespace import names (e.g., `stylex` from `import stylex from '...'`) stylex_imports: FxHashSet, /// Tracked `StyleX` named imports (e.g., `create` from `import { create } from '...'`) @@ -210,6 +218,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 +262,22 @@ 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 classes of the exported or selected styled component the + /// declaration being visited defines, with where its definition starts + pending_marker: Option<(u32, Vec<(String, usize)>)>, + /// What the file exports, whose components carry markers other files + /// select them by + exports: Exports, + /// The components only this file selects, which are marked after those it + /// exports + local_markers: usize, + /// The styled components other modules define that the file extends, + /// selects or gives a `css` prop, by the binding it imports them as + imported_styled: FxHashMap>, + /// The styled component an anonymous `export default` defines + default_definition: Option>, /// Whether a generated styled component forwards refs through React's /// `forwardRef`, which the program then imports forwards_refs: bool, @@ -398,6 +427,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 +498,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 +525,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 +535,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 +543,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 +576,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 +627,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 +660,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 +724,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 @@ -752,10 +834,12 @@ impl<'a> DevupVisitor<'a> { split_filename, styled_imports: FxHashSet::default(), styled_jsx: None, - styled_css_slot: None, + styled_capability: None, 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 +858,12 @@ impl<'a> DevupVisitor<'a> { style_values: crate::style_values::StyleValues::default(), css_styles: None, pending_styled: None, + selected_components: FxHashSet::default(), + pending_marker: None, + exports: Exports::default(), + local_markers: 0, + imported_styled: FxHashMap::default(), + default_definition: None, forwards_refs: false, styled_definitions: FxHashMap::default(), imported_css: FxHashMap::default(), @@ -792,6 +882,117 @@ impl<'a> DevupVisitor<'a> { self.css_prop = css_prop; } + /// Hand the visitor the styled components other modules define + pub fn import_styled(&mut self, components: FxHashMap>) { + self.imported_styled = components; + } + + pub(crate) fn reserve_styled(&mut self, capability: StyledCapability) { + self.styled_capability = Some(capability); + } + + /// The styled component `symbol` binds, in this file or another module + fn definition(&self, symbol: oxc_syntax::symbol::SymbolId) -> Option<&StyledDefinition<'a>> { + self.styled_definitions.get(&symbol).or_else(|| { + self.style_values + .import_name(symbol) + .and_then(|name| self.imported_styled.get(name)) + .and_then(|component| component.definition.as_ref()) + }) + } + + /// The styled component `expression` reads: a binding, or a member of a + /// module imported whole + fn definition_of(&self, expression: &Expression<'_>) -> Option<&StyledDefinition<'a>> { + match expression { + Expression::StaticMemberExpression(member) => { + let namespace = self + .style_values + .symbol(&member.object) + .and_then(|symbol| self.style_values.import_name(symbol))?; + self.imported_styled + .get(&format!("{namespace}.{}", member.property.name)) + .and_then(|component| component.definition.as_ref()) + } + expression => self + .style_values + .symbol(expression) + .and_then(|symbol| self.definition(symbol)), + } + } + + /// The classes other styles select the component bound to `name` by: one + /// for each name the file exports it as, and a class of its own when it + /// is only selected here + fn markers_of(&mut self, name: &str, exported: bool, selected: bool) -> Vec<(String, usize)> { + let mut markers: Vec<(String, usize)> = if exported { + self.exports + .of(name) + .iter() + .map(|exported| (exported.clone(), self.exports.index(exported))) + .collect() + } else { + Vec::new() + }; + if markers.is_empty() && selected { + markers.push((name.to_string(), self.exports.count() + self.local_markers)); + self.local_markers += 1; + } + markers + } + + fn marker(&self, name: &str, index: usize) -> String { + css::component_marker(name, index, &self.filename) + } + + /// The classes of markers a declaration is known to give, named only once + /// it becomes a component so that an export that is not one numbers no file + fn marker_classes(&self, markers: &[(String, usize)]) -> Vec { + markers + .iter() + .map(|(name, index)| self.marker(name, *index)) + .collect() + } + + /// The styled components the file exports, by the names it exports them + /// as, in the arena of the file that reads them + pub fn components<'b>( + &self, + allocator: &'b Allocator, + ) -> FxHashMap> { + let mut components = FxHashMap::default(); + for (binding, names) in self.exports.bindings() { + let component = self + .style_values + .root_symbol(binding) + .and_then(|symbol| self.styled_definitions.get(&symbol)) + .map(|definition| ImportedComponent { + markers: definition.markers().to_vec(), + definition: Some(definition.clone_in(allocator)), + }) + .or_else(|| { + self.imported_styled + .get(binding) + .map(|component| component.clone_in(allocator)) + }); + if let Some(component) = component { + for name in names { + components.insert(name.clone(), component.clone_in(allocator)); + } + } + } + if let Some(definition) = &self.default_definition { + components.insert( + "default".to_string(), + ImportedComponent { + markers: definition.markers().to_vec(), + definition: Some(definition.clone_in(allocator)), + }, + ); + } + components + } + pub fn unknown_bindings(&mut self, unknown: &crate::imported_constants::Unknown) { self.unknown_bindings.clone_from(unknown); } @@ -799,7 +1000,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 +1119,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 +1285,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, @@ -1310,7 +1520,8 @@ impl<'a> DevupVisitor<'a> { .map(|style| style.clone_in(allocator)) .collect() }; - let definition = symbol.and_then(|symbol| self.styled_definitions.get(&symbol)); + let definition = symbol.and_then(|symbol| self.definition(symbol)); + let markers = definition.and_then(|definition| definition.marker_classes(&self.ast)); let composed = definition.filter(|_| renders).and_then(|definition| { definition .inline() @@ -1325,10 +1536,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 +1704,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> { @@ -2217,18 +2786,63 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { walk_variable_declarators(self, it); } + fn visit_export_default_declaration(&mut self, it: &mut ExportDefaultDeclaration<'a>) { + let start = it + .declaration + .as_expression() + .filter(|_| self.exports.has_anonymous_default()) + .map(|expression| expression.span().start); + if let Some(start) = start { + self.pending_marker = Some(( + start, + vec![("default".to_string(), self.exports.index("default"))], + )); + } + walk_export_default_declaration(self, it); + if start.is_some() { + self.pending_marker = None; + self.default_definition = self + .pending_styled + .take() + .filter(|(at, _)| Some(*at) == start) + .map(|(_, definition)| definition); + } + } + fn visit_program(&mut self, it: &mut Program<'a>) { + self.exports = Exports::scan(it); 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( + self.styled_capability + .get_or_insert_with(|| StyledCapability::reserve(it)); + 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); + let imported: Vec<_> = self + .selected_components + .iter() + .filter_map(|symbol| { + let name = self.style_values.import_name(*symbol)?; + let marker = self.imported_styled.get(name)?.markers.first()?; + Some((*symbol, marker.clone())) + }) + .collect(); + for (symbol, marker) in imported { + self.style_values.insert( + symbol, + crate::style_values::StyleValue::Component(format!(".{marker}")), + ); + } } walk_program(self, it); self.style_values.read_names(&self.ast, it); @@ -2302,6 +2916,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), @@ -2385,11 +3002,17 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.unknown_arguments("styled", &call.arguments); self.changed_arguments("styled", &call.arguments); } - let inherited = extended(it) - .and_then(|base| self.style_values.symbol(base)) - .and_then(|symbol| self.styled_definitions.get(&symbol)) - .filter(|definition| definition.extendable()); let start = it.span().start; + let markers = self + .pending_marker + .take_if(|(at, _)| *at == start) + .map(|(_, markers)| self.marker_classes(&markers)) + .unwrap_or_default(); + let inherited = extended(it).and_then(|base| { + self.definition_of(base) + .filter(|definition| definition.extendable()) + .map(|definition| definition.used_as(&self.ast, base)) + }); let StyledExtraction { result, expression, @@ -2398,13 +3021,16 @@ 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(), + markers: &markers, + }, &self.imports, (&attrs, forward), - inherited, + inherited.as_ref(), StyledContext { jsx: &mut self.styled_jsx, - css_slot: self.styled_css_slot.as_ref(), + capability: self.styled_capability.as_ref(), }, ); self.errors.extend(errors); @@ -2433,22 +3059,37 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && let Expression::StaticMemberExpression(member) = &call.callee && member.property.name == "withComponent" && let Some(definition) = self - .style_values - .symbol(&member.object) - .and_then(|symbol| self.styled_definitions.get(&symbol)) + .definition_of(unwrap_syntax_only(&member.object)) + .map(|definition| definition.used_as(&self.ast, &member.object)) + && let pending = self + .pending_marker + .as_ref() + .filter(|(at, _)| *at == call.span.start) + .map(|(_, markers)| self.marker_classes(markers)) + .unwrap_or_default() && let Some((component, definition)) = with_component( &self.ast, - definition, + &definition, call.arguments[0].to_expression(), - self.split_filename.as_deref(), + Naming { + split_filename: self.split_filename.as_deref(), + markers: pending.as_slice(), + }, StyledContext { jsx: &mut self.styled_jsx, - css_slot: self.styled_css_slot.as_ref(), + capability: self.styled_capability.as_ref(), }, ) { let start = call.span.start; + self.pending_marker.take_if(|(at, _)| *at == start); self.forwards_refs = true; + self.styles.extend( + definition + .styles() + .iter() + .flat_map(ExtractStyleProp::extract), + ); *it = component; self.pending_styled = Some((start, definition)); } @@ -3591,6 +4232,25 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.local_styles.insert(symbol); } + let marked = styled_binding + .zip(start) + .zip(it.id.get_binding_identifier().map(|id| id.name.to_string())) + .and_then(|((symbol, start), name)| { + let selected = self.selected_components.contains(&symbol); + let exported = !self.style_values.is_local(symbol); + let markers = self.markers_of(&name, exported, selected); + (!markers.is_empty()).then_some((symbol, start, markers, selected)) + }); + if let Some((symbol, start, markers, selected)) = &marked { + if *selected { + let class = self.marker(&markers[0].0, markers[0].1); + self.style_values.insert( + *symbol, + crate::style_values::StyleValue::Component(format!(".{class}")), + ); + } + self.pending_marker = Some((*start, markers.clone())); + } 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 +4266,12 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { walk_variable_declarator(self, it); + // A binding marked 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 +4396,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 +4454,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); @@ -3790,7 +4467,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && let Some(definition) = self .style_values .reference_symbol(name) - .and_then(|symbol| self.styled_definitions.get(&symbol)) + .and_then(|symbol| self.definition(symbol)) { elem.opening_element .attributes diff --git a/libs/extractor/tests/imported_styled_captures.rs b/libs/extractor/tests/imported_styled_captures.rs new file mode 100644 index 000000000..ca3216761 --- /dev/null +++ b/libs/extractor/tests/imported_styled_captures.rs @@ -0,0 +1,249 @@ +use boa_engine::{Context, Source}; +use extractor::{ + ExtractOption, ModuleResolver, ResolvedModule, extract_with_modules, extract_without_source_map, +}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{ImportDeclarationSpecifier, Statement}; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; +use serial_test::serial; + +const BASE: &str = "import {styled} from '@devup-ui/react';const trace=[];const selected=true;const Base=styled.article({...((trace.push('controller'),selected)?{color:(trace.push('leaf'),'red')}:{}),color:'green'});export {Base};"; + +fn executable(filename: &str, source: &str, resolver: Option<&ModuleResolver>) -> String { + let option = ExtractOption { + single_css: true, + ..ExtractOption::default() + }; + let output = match resolver { + Some(resolver) => extract_with_modules(filename, source, option, false, resolver), + None => extract_without_source_map(filename, source, option), + } + .unwrap_or_else(|error| panic!("module extraction failed: {error}")); + let arena = Allocator::default(); + let parsed = Parser::new(&arena, &output.code, SourceType::ts()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code); + let mut program = parsed.program; + let mut imports = String::new(); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement { + for specifier in import.specifiers.iter().flatten() { + if let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier { + let helper = match specifier.imported.name().as_str() { + "jsx" => Some("jsx"), + "forwardRef" => Some("forwardRef"), + _ => None, + }; + if let Some(helper) = helper { + write!(imports, "const {}={helper};", specifier.local.name).unwrap_or_else( + |error| panic!("helper binding formatting failed: {error}"), + ); + } + } + } + } + } + program.body.retain(|statement| { + !matches!( + statement, + Statement::ImportDeclaration(_) | Statement::ExportNamedDeclaration(_) + ) + }); + format!("{imports}{}", Codegen::new().build(&program).code) +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn captures_keep_defining_scope_when_imported_fixed_styles_extend(#[case] readable: bool) { + // Given: overwritten styles retain module-local effects, with conflicting importer names. + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + (specifier == "./base" && importer == "/src/app.ts").then(|| ResolvedModule { + path: "/src/base.ts".to_string(), + code: BASE.to_string(), + }) + }; + let base = executable("/src/base.ts", BASE, None); + let app = executable( + "/src/app.ts", + "import {styled} from '@devup-ui/react';import {Base} from './base';const trace=[];const selected=false;const Card=styled(Base)({padding:'2px'});export {Card};", + if readable { Some(&resolver) } else { None }, + ); + let script = format!( + "const forwardRef=render=>({{render}});const jsx=(target,props)=>({{target,props}});const base=(()=>{{{base}return {{Base,trace}};}})();const app=(({{Base}})=>{{{app}return {{Card,trace}};}})(base);function render(target,props){{const e=target.render(props,props.ref);return typeof e.target==='string'?e:render(e.target,e.props);}}render(app.Card,{{ref:7,children:'child'}});JSON.stringify([base.trace,app.trace]);" + ); + + // When: real generated callbacks render the extension across isolated module closures. + let result = Context::default() + .eval(Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("module execution failed: {error}\n{script}")); + let observed = result + .as_string() + .unwrap_or_else(|| panic!("module observation must be JSON")) + .to_std_string_escaped(); + + // Then: only the defining module observes controller/leaf evaluation, regardless of readability. + assert_eq!( + observed, r#"[["controller","leaf"],[]]"#, + "readable={readable}\n{script}" + ); +} +use std::fmt::Write; + +fn observe(script: &str) -> String { + Context::default() + .eval(Source::from_bytes(script)) + .unwrap_or_else(|error| panic!("module execution failed: {error}\n{script}")) + .as_string() + .unwrap_or_else(|| panic!("module observation must be JSON")) + .to_std_string_escaped() +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn outer_effects_precede_definer_effects_when_fixed_import_renders_repeatedly( + #[case] readable: bool, + #[values(false, true)] overlap: bool, +) { + // Given: both modules retain effects behind overwritten fixed declarations. + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let source = BASE.replace( + "trace.push('controller')", + "(trace.push('controller'),order.push('A'))", + ); + let base = executable("/src/base.ts", &source, None); + let resolver = move |specifier: &str, importer: &str| { + (specifier == "./base" && importer == "/src/app.ts").then(|| ResolvedModule { + path: "/src/base.ts".to_string(), + code: source.clone(), + }) + }; + let color = if overlap { ",color:'blue'" } else { "" }; + let app = executable( + "/src/app.ts", + &format!( + "import {{styled}} from '@devup-ui/react';import {{Base}} from './base';const trace=[];const Card=styled(Base)({{...((trace.push('controller'),order.push('B'),true)?{{margin:(trace.push('leaf'),'1px')}}:{{}}),margin:'2px',padding:'2px'{color}}});export {{Card}};" + ), + if readable { Some(&resolver) } else { None }, + ); + let script = format!( + "const order=[];const forwardRef=render=>({{render}});const jsx=(target,props)=>({{target,props}});const base=(()=>{{{base}return {{Base,trace}};}})();const app=(({{Base}})=>{{{app}return {{Card,trace}};}})(base);function render(target,props){{const e=target.render(props,props.ref);return typeof e.target==='string'?e:render(e.target,e.props);}}const before=[base.trace.slice(),app.trace.slice()];const first=render(app.Card,{{ref:7,children:'first'}});const second=render(app.Card,{{ref:8,children:'second'}});JSON.stringify([before,[first,second].map(e=>[e.target,e.props.ref,e.props.children]),base.trace,app.trace,order]);" + ); + + // When: the emitted callbacks execute in their separate real module closures. + let observed = observe(&script); + + // Then: outer B precedes inner A on every render without moving either closure. + assert_eq!( + observed, + serde_json::json!([ + [[], []], + [["article", 7, "first"], ["article", 8, "second"]], + ["controller", "leaf", "controller", "leaf"], + ["controller", "leaf", "controller", "leaf"], + ["B", "A", "B", "A"] + ]) + .to_string(), + "readable={readable}, overlap={overlap}\n{script}" + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn definer_captures_stay_local_when_native_attrs_preclude_a_fixed_slot(#[case] readable: bool) { + // Given: literal native attrs exclude fixed_native, but overwritten captures still matter. + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let source = BASE.replace("styled.article(", "styled.article.attrs({title:'base'})("); + let base = executable("/src/base.ts", &source, None); + let resolver = move |specifier: &str, importer: &str| { + (specifier == "./base" && importer == "/src/app.ts").then(|| ResolvedModule { + path: "/src/base.ts".to_string(), + code: source.clone(), + }) + }; + let app = executable( + "/src/app.ts", + "import {styled} from '@devup-ui/react';import {Base} from './base';const trace=[];const Card=styled(Base)({padding:'2px'});export {Card};", + if readable { Some(&resolver) } else { None }, + ); + let script = format!( + "const forwardRef=render=>({{render}});const jsx=(target,props)=>({{target,props}});const base=(()=>{{{base}return {{Base,trace}};}})();const app=(({{Base}})=>{{{app}return {{Card,trace}};}})(base);function render(target,props){{const e=target.render(props,props.ref);return typeof e.target==='string'?e:render(e.target,e.props);}}const before=[base.trace.slice(),app.trace.slice()];const final=render(app.Card,{{ref:7,children:'child'}});JSON.stringify([before,final.target,final.props.title,final.props.ref,final.props.children,base.trace,app.trace]);" + ); + + // When: a real imported extension renders a capture-bearing native attrs definition. + let observed = observe(&script); + + // Then: native attrs and definer-only effects survive, even without fixed-slot capability. + assert_eq!( + observed, + serde_json::json!([ + [[], []], + "article", + "base", + 7, + "child", + ["controller", "leaf"], + [] + ]) + .to_string(), + "readable={readable}\n{script}" + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn custom_retarget_keeps_definer_captures_when_target_reads_component_props( + #[case] captured: bool, +) { + // Given: a custom retarget needs component forwarding, not native theme omissions. + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let source = if captured { + BASE + } else { + "import {styled} from '@devup-ui/react';const trace=[];const Base=styled.article({color:'green'});export {Base};" + }; + let resolver = move |specifier: &str, importer: &str| { + (specifier == "./base" && importer == "/src/app.ts").then(|| ResolvedModule { + path: "/src/base.ts".to_string(), + code: source.to_string(), + }) + }; + let base = executable("/src/base.ts", source, None); + let app = executable( + "/src/app.ts", + "import {styled} from '@devup-ui/react';import {forwardRef as makeTarget} from 'react';import {jsx as createElement} from 'react/jsx-runtime';import {Base} from './base';const trace=[];const Target=makeTarget((p,ref)=>createElement('aside',{title:p.theme?.tone,'data-custom':p.customProp,ref,children:p.children,className:p.className,style:p.style}));const Card=Base.withComponent(Target);export {Card};", + Some(&resolver), + ); + let script = format!( + "const forwardRef=render=>({{render}});const jsx=(target,props)=>({{target,props}});const base=(()=>{{{base}return {{Base,trace}};}})();const app=(({{Base}})=>{{{app}return {{Card,trace}};}})(base);function render(target,props){{const e=target.render(props,props.ref);return typeof e.target==='string'?e:render(e.target,e.props);}}const final=render(app.Card,{{ref:7,children:'child',theme:{{tone:'caller'}},customProp:'custom'}});JSON.stringify([final.target,final.props.title,final.props['data-custom'],final.props.ref,final.props.children,base.trace,app.trace]);" + ); + + // When: real compiled modules render the imported custom withComponent target. + let observed = observe(&script); + + // Then: forwarding and lexical ownership both survive the custom target. + let trace = if captured { + vec!["controller", "leaf"] + } else { + vec![] + }; + assert_eq!( + observed, + serde_json::json!(["aside", "caller", "custom", 7, "child", trace, []]).to_string(), + "captured={captured}\n{script}" + ); +} diff --git a/libs/extractor/tests/imported_styled_forwarding.rs b/libs/extractor/tests/imported_styled_forwarding.rs new file mode 100644 index 000000000..f0df58ad0 --- /dev/null +++ b/libs/extractor/tests/imported_styled_forwarding.rs @@ -0,0 +1,185 @@ +mod retained; + +use retained::{BASE, run}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(("", "aside", "caller"))] +#[case((".withComponent('main')", "main", "missing"))] +#[case((".withComponent(Target)", "nav", "caller"))] +#[serial] +fn retained_chain_uses_declared_target_filter_when_intermediate_has_no_bindings( + #[case] target: (&str, &str, &str), + #[values(false, true)] native_middle: bool, +) { + let (retarget, expected_tag, tone) = target; + // Given: A owns captures, B has zero local bindings, and C may retarget B again. + let middle_target = if native_middle { "'section'" } else { "Target" }; + let middle = format!( + "import {{styled}} from '@devup-ui/react';import {{forwardRef}} from 'react';import {{jsx}} from 'react/jsx-runtime';import {{Base}} from './base';const trace=[];const Target=forwardRef((p,ref)=>jsx('aside',{{...p,'data-tone':p.theme?.tone??'missing',ref}}));const Mid=Base.withComponent({middle_target});export {{Mid,trace}};" + ); + let definition = if retarget.is_empty() { + "const Card=styled(Mid)({color:'blue',padding:'2px'});".to_string() + } else { + format!( + "const Extended=styled(Mid)({{color:'blue',padding:'2px'}});const Card=Extended{retarget};" + ) + }; + let app = format!( + "import {{styled}} from '@devup-ui/react';import {{forwardRef}} from 'react';import {{jsx}} from 'react/jsx-runtime';import {{Mid}} from './mid';const trace=[];const Target=forwardRef((p,ref)=>jsx('nav',{{...p,'data-tone':p.theme?.tone??'missing',ref}}));{definition}export {{Card,trace}};" + ); + // When: the actual generated three-module chain renders twice with target channels. + let result = run( + &[ + ("/src/base.ts", BASE), + ("/src/mid.ts", &middle), + ("/src/app.ts", &app), + ], + "const app=modules['/src/app.ts'];const before=modules['/src/base.ts'].trace.slice();const first=render(jsx(app.Card,{theme:{tone:'caller'},customProp:'custom',ref:7,children:'child',forwardedAs:'em',style:{opacity:0.5}}));const second=render(jsx(app.Card,{theme:{tone:'caller'},ref:8,children:'next'}));JSON.stringify([before,[first,second].map(e=>[e.target,e.props.ref,e.props.children,slots(e),values(e,'color'),values(e,'padding')]),first.props['data-tone']??'missing',first.props.as,first.props.customProp,first.props.style.opacity,modules['/src/base.ts'].trace,modules['/src/mid.ts'].trace,app.trace,identities(first).length,new Set(identities(first)).size]);", + ); + // Then: incoming forwarding bypasses obsolete filters and the original A renderer remains. + let tag = if retarget.is_empty() && native_middle { + "section" + } else { + expected_tag + }; + let tone = if retarget.is_empty() { + if native_middle { "missing" } else { "caller" } + } else { + tone + }; + assert_eq!( + result, + serde_json::json!([ + [], + [ + [tag, 7, "child", [], ["blue"], ["2px"]], + [tag, 8, "next", [], ["blue"], ["2px"]] + ], + tone, + "em", + "custom", + 0.5, + ["controller", "leaf", "controller", "leaf"], + [], + [], + 3, + 3 + ]) + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn exact_slot_pairs_are_translated_when_source_and_importer_reservations_differ( + #[case] collide: bool, + #[values("/src/app.ts", "/src/app.tsx")] filename: &str, +) { + // Given: wrapped authored class/forwarded keys force independent upstream reservations. + let authored = if collide { + "const authored={[(('__devupCss0') as string)]:'class',['__devupForwarded0' as const]:'forwarded'};" + } else { + "const authored={value:'control'};" + }; + let base = format!("{authored}{BASE}"); + let app = "import {styled} from '@devup-ui/react';import * as UI from './base';const trace=[];const Card=styled(UI.Base)({color:'blue'});export {Card,trace};"; + let outer = "import {styled} from '@devup-ui/react';import {Card} from './app';const Outer=styled(Card)({padding:'2px'});export {Outer};"; + // When: equal or distinct exact keys are consumed, then empty object/string overrides render. + let result = run( + &[ + ("/src/base.ts", &base), + (filename, app), + ("/src/outer.ts", outer), + ], + &format!( + "render(jsx(modules['/src/outer.ts'].Outer,{{ref:7,children:'child',theme:{{tone:'value'}}}}));const cardProps=frames[0].props;const baseProps=frames[1].props;const classKey=slots({{props:cardProps}}).find(k=>k.startsWith('__devupCss'));const forwardedKey=slots({{props:cardProps}}).find(k=>k.startsWith('__devupForwarded'));const same=slots({{props:cardProps}}).sort().join()===slots({{props:baseProps}}).sort().join();frames.length=0;const final=render(jsx(modules[{filename:?}].Card,{{[classKey]:'',[forwardedKey]:{{}},ref:9,children:'removed',theme:{{tone:'removed'}},customProp:'removed',style:{{opacity:0.75}}}}));JSON.stringify([same,slots(final),values(final,'color'),final.props.ref??null,final.props.children??null,final.props.theme??null,final.props.customProp??null,final.props.style.opacity,modules['/src/base.ts'].trace,modules[{filename:?}].trace]);" + ), + ); + // Then: neither empty override restores defaults and no private transport reaches the leaf. + assert_eq!( + result, + serde_json::json!([ + !collide, + [], + [], + null, + null, + null, + null, + 0.75, + ["controller", "leaf", "controller", "leaf"], + [] + ]) + ); +} + +#[test] +#[serial] +fn combined_known_filters_use_raw_props_when_custom_chain_retargets_native_root() { + // Given: two canonical named predicates and a custom target that reads theme/ref/children. + let base = BASE.replace( + "styled.article(", + "styled.article.withConfig({shouldForwardProp:p=>p!=='dropA'})(", + ); + let middle = "import {styled} from '@devup-ui/react';import {Base} from './base';const Mid=styled(Base).withConfig({shouldForwardProp:p=>p!=='dropB'})({padding:'2px'});export {Mid};"; + let app = "import {styled} from '@devup-ui/react';import {forwardRef} from 'react';import {jsx} from 'react/jsx-runtime';import {Mid} from './mid';const Target=forwardRef((p,ref)=>jsx('nav',{...p,ref}));const Card=Mid.withComponent(Target);const App=()=> ;export {Card,App};"; + // When: JSX pruning and the compiled renderer use the same inherited filter metadata. + let result = run( + &[ + ("/src/base.ts", &base), + ("/src/mid.ts", middle), + ("/src/app.tsx", app), + ], + "const raw=modules['/src/app.tsx'].App();const final=render(raw);JSON.stringify([raw.props.dropA??null,raw.props.dropB??null,raw.props.$secret??null,final.props.dropA??null,final.props.dropB??null,final.props.$secret??null,final.props.theme.tone,final.props.customProp,final.props.ref,final.props.children,slots(final),values(final,'padding'),modules['/src/base.ts'].trace]);", + ); + // Then: canonical filtering, not a new runtime predicate, removes only its known omissions. + assert_eq!( + result, + serde_json::json!([ + null, + null, + null, + null, + null, + null, + "caller", + "custom", + 7, + "child", + [], + ["2px"], + ["controller", "leaf"] + ]) + ); +} + +#[test] +#[serial] +fn local_attrs_reads_keep_inputs_when_fixed_import_filters_forwarding() { + // Given: local own attrs and a CSS read need props that canonical native forwarding omits. + let app = "import {styled} from '@devup-ui/react';import {Base} from './base';const Card=styled(Base).attrs(p=>({title:p.source,style:{opacity:(0.5)}}))`color:${p=>p.tone};padding:2px;`;export {Card};"; + // When: only this local pipeline runs before the original fixed definer callback. + let result = run( + &[("/src/base.ts", BASE), ("/src/app.ts", app)], + "const final=render(jsx(modules['/src/app.ts'].Card,{source:'title',tone:'blue',style:{color:'pink'},ref:7,children:'child'}));JSON.stringify([final.props.title,final.props.tone??null,values(final,'color'),values(final,'padding'),final.props.style.color,final.props.style.opacity,final.props.ref,final.props.children,slots(final),modules['/src/base.ts'].trace]);", + ); + // Then: evaluation uses raw props separately from the complete forwarded-rest object. + assert_eq!( + result, + serde_json::json!([ + "title", + null, + ["blue"], + ["2px"], + "pink", + 0.5, + 7, + "child", + [], + ["controller", "leaf"] + ]) + ); +} diff --git a/libs/extractor/tests/imported_styled_read_fixed.rs b/libs/extractor/tests/imported_styled_read_fixed.rs new file mode 100644 index 000000000..3cf3a565e --- /dev/null +++ b/libs/extractor/tests/imported_styled_read_fixed.rs @@ -0,0 +1,137 @@ +mod retained; + +use retained::{BASE, run, run_with_reading}; +use rstest::rstest; +use serial_test::serial; + +fn modules(custom: bool, attrs: bool) -> [String; 3] { + let base = BASE.replace( + "trace.push('controller')", + "(trace.push('controller'),order.push('A'))", + ); + let target = if custom { "Target" } else { "'section'" }; + let middle = format!( + "import {{styled}} from '@devup-ui/react';import {{forwardRef}} from 'react';import {{jsx}} from 'react/jsx-runtime';import {{Base}} from './base';const trace=[];const Target=forwardRef((p,ref)=>jsx('aside',{{...p,ref}}));const Read=styled(Base).withConfig({{shouldForwardProp:p=>p!=='denied'&&p!=='blocked'}})({{color:p=>p.denied,padding:p=>p.$tone,...((trace.push('controller'),order.push('B'),true)?{{color:(trace.push('leaf'),'red')}}:{{}}),color:'green',padding:'2px'}});const Mid=Read.withComponent({target});export {{Mid,trace}};" + ); + let attrs = if attrs { + ".attrs(p=>(trace.push([p.denied,p.$tone]),order.push('attrs'),{denied:p.denied+'-outer',$tone:p.$tone+'-outer'}))" + } else { + "" + }; + let app = format!( + "import {{styled}} from '@devup-ui/react';import {{Mid}} from './mid';const trace=[];const Card=styled(Mid){attrs}({{color:'blue'}});const App=(denied,tone,ref,child)=>;export {{Card,App,trace}};" + ); + [base, middle, app] +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn overwritten_prop_reads_keep_fixed_summary_and_original_effect_owners( + #[case] custom: bool, + #[values(false, true)] attrs: bool, +) { + // Given: B's authored prop reads are overwritten, leaving fixed styles and retained effects. + let [base, middle, app] = modules(custom, attrs); + // When: the zero-local-style-binding outer hop renders twice, optionally applying local attrs. + let result = run( + &[ + ("/src/base.ts", &base), + ("/src/mid.ts", &middle), + ("/src/app.tsx", &app), + ], + "const app=modules['/src/app.tsx'];const before=[modules['/src/base.ts'].trace.slice(),modules['/src/mid.ts'].trace.slice(),app.trace.slice(),order.slice()];const first=render(app.App('one','tone-one',7,'first'));const second=render(app.App('two','tone-two',8,'second'));JSON.stringify([before,[first,second].map(e=>[e.target,e.props.ref,e.props.children,values(e,'color'),values(e,'padding'),slots(e),identities(e).length,new Set(identities(e)).size]),modules['/src/base.ts'].trace,modules['/src/mid.ts'].trace,app.trace,order]);", + ); + // Then: D1 removes green, both callbacks keep their owners, and outer attrs run only locally. + let tag = if custom { "aside" } else { "section" }; + let attr_trace = if attrs { + vec![vec!["one", "tone-one"], vec!["two", "tone-two"]] + } else { + vec![] + }; + let order = if attrs { + vec!["attrs", "B", "A", "attrs", "B", "A"] + } else { + vec!["B", "A", "B", "A"] + }; + assert_eq!( + result, + serde_json::json!([ + [[], [], [], []], + [ + [tag, 7, "first", ["blue"], ["2px"], [], 3, 3], + [tag, 8, "second", ["blue"], ["2px"], [], 3, 3] + ], + ["controller", "leaf", "controller", "leaf"], + ["controller", "leaf", "controller", "leaf"], + attr_trace, + order + ]) + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn denied_and_transient_inputs_reach_read_bearing_callback_before_canonical_filtering( + #[case] custom: bool, + #[values(false, true)] attrs: bool, +) { + // Given: Reads must preserve denied/transient inputs, while Forward still rejects blocked. + let [base, middle, app] = modules(custom, attrs); + // When: actual emitted props enter and leave B's original callback across the third module. + let result = run( + &[ + ("/src/base.ts", &base), + ("/src/mid.ts", &middle), + ("/src/app.tsx", &app), + ], + "const raw=modules['/src/app.tsx'].App('caller','transient',7,'child');const final=render(raw);JSON.stringify([[raw.props.denied??null,raw.props.$tone??null,raw.props.blocked??null],frames.slice(0,2).map(e=>[e.props.denied??null,e.props.$tone??null,e.props.blocked??null,slots(e).length]),[final.props.denied??null,final.props.$tone??null,final.props.blocked??null,slots(final),final.props.ref,final.props.children]]);", + ); + // Then: raw evaluation inputs include local attrs overrides, not the filtered ordinary object. + let denied = if attrs { "caller-outer" } else { "caller" }; + let tone = if attrs { + "transient-outer" + } else { + "transient" + }; + assert_eq!( + result, + serde_json::json!([ + ["caller", "transient", null], + [[denied, tone, null, 2], [denied, tone, null, 2]], + [null, null, null, [], 7, "child"] + ]) + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn read_bearing_captured_root_does_not_gain_descendant_capability(#[case] readable: bool) { + // Given: an attrs-free read-bearing ROOT has no emitted pair under the existing root guard. + let root = "import {styled} from '@devup-ui/react';const trace=[];const Mid=styled.article({color:p=>p.denied,...((trace.push('controller'),true)?{color:(trace.push('leaf'),'red')}:{}),color:'green'});export {Mid,trace};"; + let app = "import {styled} from '@devup-ui/react';import {Mid} from './mid';const Card=styled(Mid)({margin:'3px'});export {Card};"; + // When: a non-overlapping extension renders the original root without acquiring a slot. + let result = run_with_reading( + &[("/src/mid.ts", root), ("/src/app.ts", app)], + "const before=modules['/src/mid.ts'].trace.slice();const final=render(jsx(modules['/src/app.ts'].Card,{ref:7,children:'child'}));JSON.stringify([before,slots(frames[0]),values(final,'color'),values(final,'margin'),final.props.ref,final.props.children,modules['/src/mid.ts'].trace]);", + readable, + ); + // Then: proposal A does not widen fixed_native or manufacture a root capability. + assert_eq!( + result, + serde_json::json!([ + [], + [], + ["green"], + ["3px"], + 7, + "child", + ["controller", "leaf"] + ]) + ); +} diff --git a/libs/extractor/tests/imported_styled_replay_custom.rs b/libs/extractor/tests/imported_styled_replay_custom.rs new file mode 100644 index 000000000..cb6f9e73c --- /dev/null +++ b/libs/extractor/tests/imported_styled_replay_custom.rs @@ -0,0 +1,267 @@ +mod retained; + +use retained::{BASE as CAPTURED_BASE, run, run_with_reading}; +use rstest::rstest; +use serial_test::serial; + +const BASE: &str = "import {styled} from '@devup-ui/react';const Base=styled.article({color:'green',padding:'2px'});export {Base};"; +const TARGET: &str = "const trace=[];const Target=forwardRef((p,ref)=>{const {theme,customProp,...ordinary}=p;return jsx('aside',{...ordinary,ref,title:theme?.tone,'data-custom':customProp});});const targets={get Target(){trace.push('target');return Target;}};"; + +#[rstest] +#[case("const Mid=Base.withComponent(targets.Target);")] +#[case( + "const Retarget=Base.withComponent(targets.Target);const Mid=styled(Retarget)({margin:'1px'});" +)] +#[case( + "const Retarget=Base.withComponent(targets.Target);const Mid=styled(Retarget)`margin:1px;`;" +)] +#[serial] +fn fixed_custom_replay_descendant_retains_renderer_when_imported_styles_replace_green( + #[case] definition: &str, + #[values(false, true)] captured: bool, + #[values(false, true)] collide: bool, +) { + // Given: binding-free native A flattens into custom B, whose target belongs only to B. + let authored = if collide { + "const authored={['__devupCss0' as const]:'class',[('__devupForwarded0' as string)]:'forwarded'};" + } else { + "" + }; + let middle = format!( + "import {{styled}} from '@devup-ui/react';import {{forwardRef}} from 'react';import {{jsx}} from 'react/jsx-runtime';import {{Base}} from './base';{authored}{TARGET}{definition}export {{Mid,trace}};" + ); + let effects = if captured { + "...((trace.push('controller'),order.push('C'),true)?{margin:(trace.push('leaf'),'1px')}:{})," + } else { + "" + }; + let app = format!( + "import {{styled}} from '@devup-ui/react';import {{jsx}} from '@emotion/react';import {{Mid}} from './mid';const trace=[];const Card=styled(Mid)({{{effects}margin:'2px',color:'blue'}});const App=(ref,children)=> {{children}};export {{App,Card,trace}};" + ); + // When: the exact public source renders twice, then an explicit target bypasses B's getter. + let result = run( + &[ + ("/src/base.ts", BASE), + ("/src/mid.ts", &middle), + ("/src/app.tsx", &app), + ], + "const mid=modules['/src/mid.ts'];const app=modules['/src/app.tsx'];const before=[mid.trace.slice(),app.trace.slice()];const first=render(app.App(7,'first'));const incoming=frames[0];const original=incoming.target===mid.Mid;const flattened=frames.every(e=>e.target!==modules['/src/base.ts'].Base);const controls=slots(incoming);const second=render(app.App(8,'second'));const third=render(jsx(app.Card,{as:'nav',forwardedAs:'em',ref:9,children:'third',style:{borderColor:'orange'}}));JSON.stringify([before,original,flattened,controls.length,controls.includes('__devupCss0'),controls.includes('__devupForwarded0'),[first,second,third].map(e=>[e.target,e.props.ref,e.props.children,e.props.as,values(e,'color'),values(e,'padding'),values(e,'margin'),slots(e),e.props.style.borderColor,identities(e).length,new Set(identities(e)).size]),first.props.title,first.props['data-custom'],mid.trace,app.trace,order]);", + ); + // Then: real B consumes the pair; only blue/5px remain, without replaying A's callback. + let effects = if captured { + vec![ + "controller", + "leaf", + "controller", + "leaf", + "controller", + "leaf", + ] + } else { + vec![] + }; + assert_eq!( + result, + serde_json::json!([ + [[], []], + true, + true, + 2, + !collide, + !collide, + [ + [ + "aside", + 7, + "first", + null, + ["blue"], + ["5px"], + ["2px"], + [], + "orange", + 3, + 3 + ], + [ + "aside", + 8, + "second", + null, + ["blue"], + ["5px"], + ["2px"], + [], + "orange", + 3, + 3 + ], + [ + "nav", + 9, + "third", + "em", + ["blue"], + ["2px"], + ["2px"], + [], + "orange", + 3, + 3 + ] + ], + "caller", + "custom", + ["target", "target"], + effects, + if captured { + vec!["C", "C", "C"] + } else { + vec![] + } + ]) + ); +} + +#[rstest] +#[case( + "/src/app.ts", + "jsx(Mid,{css:{color:'blue',padding:5},theme:{tone:'caller'},ref:7,children:'child'})" +)] +#[case( + "/src/app.tsx", + "child" +)] +#[serial] +fn direct_css_uses_real_custom_descendant_pair_when_base_is_binding_free( + #[case] filename: &str, + #[case] element: &str, +) { + // Given: direct imported JSX and JSX-call consumers share the same fixed custom descendant. + let middle = format!( + "import {{styled}} from '@devup-ui/react';import {{forwardRef}} from 'react';import {{jsx}} from 'react/jsx-runtime';import {{Base}} from './base';{TARGET}const Mid=Base.withComponent(targets.Target);export {{Mid,trace}};" + ); + let app = format!( + "import {{jsx}} from '@emotion/react';import {{Mid}} from './mid';const App=()=>{element};export {{App}};" + ); + // When: a caller override replaces the imported component's fixed defaults. + let result = run( + &[ + ("/src/base.ts", BASE), + ("/src/mid.ts", &middle), + (filename, &app), + ], + &format!( + "const mid=modules['/src/mid.ts'];const raw=modules[{filename:?}].App();const final=render(raw);JSON.stringify([raw.target===mid.Mid,slots(raw).length,final.target,final.props.title,final.props.ref,final.props.children,values(final,'color'),values(final,'padding'),slots(final),mid.trace]);" + ), + ); + // Then: both syntax paths replace green through the original custom renderer. + assert_eq!( + result, + serde_json::json!([ + true, + 1, + "aside", + "caller", + 7, + "child", + ["blue"], + ["5px"], + [], + ["target"] + ]) + ); +} + +#[rstest] +#[case(("const Mid=styled(Target)({color:'green',padding:'2px'});", "2px", None, false))] +#[case(("const Retarget=Base.withComponent(Target);const Mid=styled(Retarget).attrs(p=>({id:p.source}))({});", "2px", Some("source"), false))] +#[case(("const Retarget=Base.withComponent(Target);const Mid=styled(Retarget)`padding:${p=>p.space};`;", "4px", None, false))] +#[case(("const Retarget=Base.withComponent(Target);const Mid=styled(Retarget)('opaque',{});", "2px", None, true))] +#[serial] +fn excluded_custom_definitions_keep_existing_behavior_without_descendant_capability( + #[case] fixture: (&str, &str, Option<&str>, bool), + #[values(false, true)] readable: bool, +) { + let (definition, padding, id, opaque) = fixture; + // Given: arbitrary custom roots, attrs, non-fixed styles and opaque classes stay excluded. + let base = if readable { + "import {Base} from './base';" + } else { + "const Base=styled.article({color:'green',padding:'2px'});" + }; + let middle = format!( + "import {{styled}} from '@devup-ui/react';import {{forwardRef}} from 'react';import {{jsx}} from 'react/jsx-runtime';{base}{TARGET}{definition}export {{Mid}};" + ); + let app = "import {styled} from '@devup-ui/react';import {Mid} from './mid';const Card=styled(Mid)({margin:'3px'});export {Card};"; + // When: a non-overlapping extension renders through the unchanged readable/unread boundary. + let result = run_with_reading( + &[ + ("/src/base.ts", BASE), + ("/src/mid.ts", &middle), + ("/src/app.ts", app), + ], + "const final=render(jsx(modules['/src/app.ts'].Card,{source:'source',space:'4px',ref:7,children:'child'}));JSON.stringify([slots(frames[0]).length,final.target,values(final,'color'),values(final,'padding'),values(final,'margin'),final.props.id??null,final.props.ref,final.props.children,slots(final),final.props.className.split(' ').includes('opaque')]);", + readable, + ); + // Then: no real pair is invented or transported, and each existing behavior survives. + assert_eq!( + result, + serde_json::json!([ + 0, + "aside", + ["green"], + [padding], + ["3px"], + id, + 7, + "child", + [], + opaque + ]) + ); +} + +#[rstest] +#[case(BASE, false)] +#[case(CAPTURED_BASE, true)] +#[serial] +fn empty_pair_is_complete_when_fixed_custom_descendant_has_replay_or_retained_base( + #[case] base: &str, + #[case] captured: bool, +) { + // Given: custom B is derived from either the approved Replay or existing captured root. + let middle = format!( + "import {{styled}} from '@devup-ui/react';import {{forwardRef}} from 'react';import {{jsx}} from 'react/jsx-runtime';import {{Base}} from './base';{TARGET}const Mid=Base.withComponent(targets.Target);export {{Mid,trace}};" + ); + let app = "import {styled} from '@devup-ui/react';import {Mid} from './mid';const Card=styled(Mid)({color:'blue'});export {Card};"; + // When: the emitted exact pair is used to send empty class and forwarding replacements. + let result = run( + &[ + ("/src/base.ts", base), + ("/src/mid.ts", &middle), + ("/src/app.ts", app), + ], + "render(jsx(modules['/src/app.ts'].Card,{}));const controls=slots(frames[0]);const classKey=controls.find(k=>k.startsWith('__devupCss'));const forwardedKey=controls.find(k=>k.startsWith('__devupForwarded'));const mid=modules['/src/mid.ts'];const final=render(jsx(mid.Mid,{[classKey]:'',[forwardedKey]:{},ref:7,children:'removed',theme:{tone:'removed'}}));JSON.stringify([controls.length,final.target,values(final,'color'),values(final,'padding'),final.props.ref??null,final.props.children??null,final.props.title??null,slots(final),mid.trace,modules['/src/base.ts'].trace??[]]);", + ); + // Then: no default is restored, and retained captures still execute only in their owner. + assert_eq!( + result, + serde_json::json!([ + 2, + "aside", + [], + [], + null, + null, + null, + [], + ["target", "target"], + if captured { + vec!["controller", "leaf", "controller", "leaf"] + } else { + vec![] + } + ]) + ); +} diff --git a/libs/extractor/tests/imported_styled_retained_edges.rs b/libs/extractor/tests/imported_styled_retained_edges.rs new file mode 100644 index 000000000..1ce9e7278 --- /dev/null +++ b/libs/extractor/tests/imported_styled_retained_edges.rs @@ -0,0 +1,185 @@ +mod retained; + +use retained::{BASE, run}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn retained_effect_order_matches_winning_styles_when_both_modules_capture(#[case] overlap: bool) { + // Given: overwritten effects in each module have independent trace owners. + let base = BASE.replace( + "trace.push('controller')", + "(trace.push('controller'),order.push('A'))", + ); + let color = if overlap { ",color:'blue'" } else { "" }; + let app = format!( + "import {{styled}} from '@devup-ui/react';import {{Base}} from './base';const trace=[];const Card=styled(Base)({{...((trace.push('controller'),order.push('B'),true)?{{margin:(trace.push('leaf'),'1px')}}:{{}}),margin:'2px',padding:'2px'{color}}});export {{Card,trace}};" + ); + // When: two renders execute the generated callbacks and expose their actual atoms. + let result = run( + &[("/src/base.ts", &base), ("/src/app.ts", &app)], + "const app=modules['/src/app.ts'];const before=[modules['/src/base.ts'].trace.slice(),app.trace.slice()];const first=render(jsx(app.Card,{ref:7,children:'first'}));const second=render(jsx(app.Card,{ref:8,children:'second'}));JSON.stringify([before,[first,second].map(e=>[e.target,e.props.ref,e.props.children,values(e,'color'),values(e,'margin'),values(e,'padding'),slots(e)]),modules['/src/base.ts'].trace,app.trace,order]);", + ); + // Then: B precedes A per render while D1 removes only the losing style, not its effects. + assert_eq!( + result, + serde_json::json!([ + [[], []], + [ + [ + "article", + 7, + "first", + [if overlap { "blue" } else { "green" }], + ["2px"], + ["2px"], + [] + ], + [ + "article", + 8, + "second", + [if overlap { "blue" } else { "green" }], + ["2px"], + ["2px"], + [] + ] + ], + ["controller", "leaf", "controller", "leaf"], + ["controller", "leaf", "controller", "leaf"], + ["B", "A", "B", "A"] + ]) + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn default_target_reads_stay_in_definer_when_retained_child_renders(#[case] retarget: bool) { + // Given: the middle module alone owns an observable default-target getter. + let middle = "import {styled} from '@devup-ui/react';import {forwardRef} from 'react';import {jsx} from 'react/jsx-runtime';import {Base} from './base';const trace=[];const Target=forwardRef((p,ref)=>jsx('aside',{...p,ref}));const targets={get Target(){trace.push('target');return Target;}};const Mid=Base.withComponent(targets.Target);export {Mid,trace};"; + let definition = if retarget { + "Mid.withComponent('main')" + } else { + "styled(Mid)({})" + }; + let app = format!( + "import {{styled}} from '@devup-ui/react';import {{Mid}} from './mid';const Card={definition};export {{Card}};" + ); + // When: two default renders and one runtime-as override traverse the original middle callback. + let result = run( + &[ + ("/src/base.ts", BASE), + ("/src/mid.ts", middle), + ("/src/app.ts", &app), + ], + "const mid=modules['/src/mid.ts'];const before=mid.trace.slice();const Card=modules['/src/app.ts'].Card;const first=render(jsx(Card,{ref:7,children:'first'}));const second=render(jsx(Card,{ref:8,children:'second'}));const third=render(jsx(Card,{as:'nav',forwardedAs:'em',ref:9,children:'third'}));JSON.stringify([before,[first,second,third].map(e=>[e.target,e.props.ref,e.props.children,e.props.as??null,slots(e)]),mid.trace,modules['/src/base.ts'].trace]);", + ); + // Then: no eager or importer getter read is added; explicit targets skip obsolete defaults. + let tag = if retarget { "main" } else { "aside" }; + let reads = if retarget { + vec![] + } else { + vec!["target", "target"] + }; + assert_eq!( + result, + serde_json::json!([ + [], + [ + [tag, 7, "first", null, []], + [tag, 8, "second", null, []], + ["nav", 9, "third", "em", []] + ], + reads, + [ + "controller", + "leaf", + "controller", + "leaf", + "controller", + "leaf" + ] + ]) + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn custom_export_advertises_its_own_slot_when_imported_css_replaces_styles(#[case] jsx: bool) { + // Given: a custom fixed-summary descendant has no local captures or portable Target AST. + let middle = "import {styled} from '@devup-ui/react';import {forwardRef} from 'react';import { jsx } from 'react/jsx-runtime';import {Base} from './base';const Target=forwardRef((p,ref)=>jsx('aside',{...p,'data-tone':p.theme?.tone,ref}));const Mid=Base.withComponent(Target);export {Mid};"; + let filename = if jsx { "/src/app.tsx" } else { "/src/app.ts" }; + let element = if jsx { + "" + } else { + "jsx(Mid,{css:{color:'blue',padding:'4px'},theme:{tone:'caller'},ref:7,children:'child'})" + }; + let app = format!( + "import {{jsx}} from '@emotion/react';import {{Mid}} from './mid';const App=()=>{element};export {{App}};" + ); + // When: the last importer composes styles directly against the middle module's real pair. + let result = run( + &[ + ("/src/base.ts", BASE), + ("/src/mid.ts", middle), + (filename, &app), + ], + &format!( + "const raw=modules[{filename:?}].App();const final=render(raw);JSON.stringify([slots(raw).length,final.target,final.props['data-tone'],final.props.ref,final.props.children,values(final,'color'),values(final,'padding'),slots(final),identities(final).length,new Set(identities(final)).size,modules['/src/base.ts'].trace]);" + ), + ); + // Then: the custom renderer and A closure remain while later fixed CSS wins exactly once. + assert_eq!( + result, + serde_json::json!([ + 1, + "aside", + "caller", + 7, + "child", + ["blue"], + ["4px"], + [], + 2, + 2, + ["controller", "leaf"] + ]) + ); +} + +#[rstest] +#[case("/src/app.ts")] +#[case("/src/app.tsx")] +#[serial] +fn bound_import_keeps_original_value_when_lowercase_name_requires_eager_binding( + #[case] filename: &str, +) { + // Given: a lowercase imported value uses the canonical eager bound-target path. + let app = "import {styled} from '@devup-ui/react';import {Base as base} from './base';const Card=styled(base)({color:'blue'});export {Card};"; + // When: real emitted code renders the retained lowercase binding twice. + let result = run( + &[("/src/base.ts", BASE), (filename, app)], + &format!( + "const Card=modules[{filename:?}].Card;const before=modules['/src/base.ts'].trace.slice();const first=render(jsx(Card,{{ref:7,children:'first'}}));const second=render(jsx(Card,{{ref:8,children:'second'}}));JSON.stringify([before,[first,second].map(e=>[e.target,e.props.ref,e.props.children,values(e,'color'),slots(e)]),modules['/src/base.ts'].trace]);" + ), + ); + // Then: the renderer is a value, never a lowercase native tag or an invalid JSX target. + assert_eq!( + result, + serde_json::json!([ + [], + [ + ["article", 7, "first", ["blue"], []], + ["article", 8, "second", ["blue"], []] + ], + ["controller", "leaf", "controller", "leaf"] + ]) + ); +} diff --git a/libs/extractor/tests/imported_styled_transport.rs b/libs/extractor/tests/imported_styled_transport.rs new file mode 100644 index 000000000..a41af7a2d --- /dev/null +++ b/libs/extractor/tests/imported_styled_transport.rs @@ -0,0 +1,147 @@ +mod retained; + +use retained::{BASE, run}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn fixed_css_composes_when_imported_or_extended( + #[case] extension: bool, + #[values(false, true)] jsx: bool, + #[values(false, true)] overlap: bool, +) { + // Given: the direct/extended caller shadows both defining capture names. + let filename = if jsx { "/src/app.tsx" } else { "/src/app.ts" }; + let definition = if extension { + "const Card=styled(Base)({padding:'2px'});" + } else { + "" + }; + let imported = if extension { "Base" } else { "Base as Card" }; + let css = if overlap { + "{color:'blue',margin:'3px'}" + } else { + "{margin:'3px'}" + }; + let element = if jsx { + format!("") + } else { + format!("jsx(Card,{{css:{css},ref,children:'child',className:'external'}})") + }; + let app = format!( + "import {{styled}} from '@devup-ui/react';import {{jsx}} from '@emotion/react';import {{{imported}}} from './base';const trace=[];const selected=false;{definition}const App=(trace,selected,ref)=>{element};export {{Card,App,trace}};" + ); + // When: generated module callbacks render twice through the real compiled caller. + let result = run( + &[("/src/base.ts", BASE), (filename, &app)], + &format!( + "const app=modules[{filename:?}];const raw=app.App([],false,7);const first=render(raw);const second=render(app.App([],false,8));JSON.stringify([raw.target===app.Card,[first,second].map(e=>[e.target,e.props.ref,e.props.children,slots(e),values(e,'color'),values(e,'padding'),values(e,'margin'),e.props.className.includes('external')]),modules['/src/base.ts'].trace,app.trace,identities(first).length,new Set(identities(first)).size]);" + ), + ); + // Then: only the winning atoms survive, identities are unique, and A owns its effects. + let padding = if extension { vec!["2px"] } else { vec![] }; + assert_eq!( + result, + serde_json::json!([ + true, + [ + [ + "article", + 7, + "child", + [], + [if overlap { "blue" } else { "green" }], + padding, + ["3px"], + true + ], + [ + "article", + 8, + "child", + [], + [if overlap { "blue" } else { "green" }], + padding, + ["3px"], + true + ] + ], + ["controller", "leaf", "controller", "leaf"], + [], + if extension { 2 } else { 1 }, + if extension { 2 } else { 1 } + ]) + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn complete_class_replacement_stays_empty_when_registered_caller_covers_styles( + #[case] extension: bool, +) { + // Given: registered purple covers both the fixed color and the CSS-prop color. + let definition = if extension { + "const Card=styled(Base)({});" + } else { + "" + }; + let imported = if extension { "Base" } else { "Base as Card" }; + let app = format!( + "import {{styled,css}} from '@devup-ui/react';import {{jsx}} from '@emotion/react';import {{{imported}}} from './base';{definition}const override=css({{color:'purple'}});const App=()=>jsx(Card,{{css:{{color:'blue'}},className:override+' external'}});export {{Card,App}};" + ); + // When: a complete empty replacement travels through equal-spelled local/upstream slots. + let result = run( + &[("/src/base.ts", BASE), ("/src/app.ts", &app)], + "const app=modules['/src/app.ts'];const raw=app.App();const final=render(raw);JSON.stringify([slots(raw).map(k=>raw.props[k]),values(final,'color'),slots(final),modules['/src/base.ts'].trace]);", + ); + // Then: an empty string suppresses the original green instead of restoring it. + assert_eq!( + result, + serde_json::json!([[""], ["purple"], [], ["controller", "leaf"]]) + ); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn dynamic_css_variables_keep_inline_precedence_when_fixed_import_is_extended(#[case] jsx: bool) { + // Given: CSS variables are caller-owned and inline styles may replace them. + let filename = if jsx { "/src/app.tsx" } else { "/src/app.ts" }; + let element = if jsx { + "" + } else { + "jsx(Card,{css:{color:value},style,ref:7,children:'child'})" + }; + let app = format!( + "import {{styled}} from '@devup-ui/react';import {{jsx}} from '@emotion/react';import {{Base}} from './base';const Card=styled(Base)({{padding:'2px'}});const App=(value,style)=>{element};export {{App}};" + ); + // When: one caller overrides a property, then another overrides the generated variable. + let result = run( + &[("/src/base.ts", BASE), (filename, &app)], + &format!( + "const app=modules[{filename:?}];const first=render(app.App('blue',{{color:'pink'}}));const variable=Object.keys(first.props.style).find(k=>k.startsWith('--'));const second=render(app.App('teal',{{[variable]:'orange',opacity:0.5}}));JSON.stringify([values(first,'color'),values(second,'color'),first.props.style.color,second.props.style.opacity,values(first,'padding'),first.props.ref,first.props.children,slots(first),slots(second),modules['/src/base.ts'].trace]);" + ), + ); + // Then: inline precedence, captures, and final ordinary props match the local contract. + assert_eq!( + result, + serde_json::json!([ + ["blue"], + ["orange"], + "pink", + 0.5, + ["2px"], + 7, + "child", + [], + [], + ["controller", "leaf", "controller", "leaf"] + ]) + ); +} diff --git a/libs/extractor/tests/retained/mod.rs b/libs/extractor/tests/retained/mod.rs new file mode 100644 index 000000000..ed391e2fb --- /dev/null +++ b/libs/extractor/tests/retained/mod.rs @@ -0,0 +1,225 @@ +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Write; + +use boa_engine::{Context, Source}; +use extractor::extract_style::{ + ExtractStyleProperty, extract_style_value::ExtractStyleValue, style_property::StyleProperty, +}; +use extractor::{ + ExtractOption, ImportAlias, ResolvedModule, extract_with_modules, extract_without_source_map, +}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{ImportDeclarationSpecifier, Statement}; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; +use oxc_transformer::{TransformOptions, Transformer}; +use serde_json::Value; + +pub const BASE: &str = "import {styled} from '@devup-ui/react';const trace=[];const selected=true;const Base=styled.article({...((trace.push('controller'),selected)?{color:(trace.push('leaf'),'red')}:{}),color:'green'});export {Base,trace};"; + +pub fn run(files: &[(&str, &str)], observation: &str) -> Value { + run_with_reading(files, observation, true) +} + +pub fn run_with_reading(files: &[(&str, &str)], observation: &str, readable: bool) -> Value { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let modules: Vec<(String, String)> = files + .iter() + .map(|(path, code)| (path.to_string(), code.to_string())) + .collect(); + let resolver = move |specifier: &str, _: &str| { + let path = format!("/src/{}", specifier.trim_start_matches("./")); + modules + .iter() + .find(|(file, _)| { + ["", ".ts", ".tsx"] + .iter() + .any(|extension| format!("{path}{extension}") == *file) + }) + .map(|(path, code)| ResolvedModule { + path: path.clone(), + code: code.clone(), + }) + }; + let mut script = String::from( + r"const modules={};const frames=[];const order=[]; +function forwardRef(render){return {render};} +function jsx(target,props){return {target,props};} +function render(element){if(typeof element.target==='string')return element;const next=element.target.render(element.props,element.props.ref);frames.push(next);return render(next);} +function slots(e){return Object.keys(e.props).filter(k=>k.startsWith('__devupCss')||k.startsWith('__devupForwarded'));} +", + ); + let mut rules = BTreeMap::new(); + for (filename, source) in files { + let option = ExtractOption { + single_css: true, + import_aliases: HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + let output = if readable { + extract_with_modules(filename, source, option, false, &resolver) + } else { + extract_without_source_map(filename, source, option) + } + .unwrap_or_else(|error| panic!("{filename}\n{source}\n{error}")); + for style in &output.styles { + let rule = match style { + ExtractStyleValue::Static(style) => { + let StyleProperty::ClassName(name) = style.extract(None) else { + panic!("static atom must have a class") + }; + Some(( + name, + serde_json::json!([ + style.property(), + style.value(), + style.level(), + style + .selector() + .map(ToString::to_string) + .unwrap_or_default() + ]), + )) + } + ExtractStyleValue::Dynamic(style) => { + let StyleProperty::Variable { + class_name, + variable_name, + .. + } = style.extract(None) + else { + panic!("dynamic atom must have a variable") + }; + Some(( + class_name, + serde_json::json!([ + style.property(), + format!("var({variable_name})"), + style.level(), + style + .selector() + .map(ToString::to_string) + .unwrap_or_default() + ]), + )) + } + _ => None, + }; + if let Some((name, rule)) = rule { + rules.insert(name, rule); + } + } + let arena = Allocator::default(); + let parsed = Parser::new( + &arena, + &output.code, + SourceType::from_path(filename).unwrap_or_else(|error| panic!("{error}")), + ) + .parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code); + let mut program = parsed.program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let transformed = Transformer::new( + &arena, + std::path::Path::new(filename), + &TransformOptions::default(), + ) + .build_with_scoping(scoping, &mut program); + assert_eq!(transformed.diagnostics.len(), 0, "{}", output.code); + let mut imports = String::new(); + let mut exports = Vec::new(); + for statement in &program.body { + match statement { + Statement::ImportDeclaration(import) => { + for specifier in import.specifiers.iter().flatten() { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) => { + let imported = named.imported.name(); + let local = named.local.name; + let helper = match imported.as_str() { + "jsx" | "jsxs" | "jsxDEV" => Some("jsx"), + "forwardRef" => Some("forwardRef"), + _ => None, + }; + if let Some(helper) = helper { + if local != helper { + write!(imports, "const {local}={helper};") + .unwrap_or_else(|error| panic!("{error}")); + } + } else if import.source.value.starts_with("./") { + let module = resolver(&import.source.value, filename) + .unwrap_or_else(|| panic!("fixture module must resolve")); + write!( + imports, + "const {local}=modules[{:?}].{imported};", + module.path + ) + .unwrap_or_else(|error| panic!("{error}")); + } + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(namespace) => { + let module = resolver(&import.source.value, filename) + .unwrap_or_else(|| panic!("fixture module must resolve")); + write!( + imports, + "const {}=modules[{:?}];", + namespace.local.name, module.path + ) + .unwrap_or_else(|error| panic!("{error}")); + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => { + panic!("fixture uses named or namespace imports") + } + } + } + } + Statement::ExportNamedDeclaration(export) => { + exports.extend(export.specifiers.iter().map(|specifier| { + format!("{}:{}", specifier.exported.name(), specifier.local.name()) + })); + } + _ => {} + } + } + program.body.retain(|statement| { + !matches!( + statement, + Statement::ImportDeclaration(_) | Statement::ExportNamedDeclaration(_) + ) + }); + write!( + script, + "modules[{filename:?}]=(()=>{{{imports}{}return {{{}}};}})();", + Codegen::new().build(&program).code, + exports.join(",") + ) + .unwrap_or_else(|error| panic!("{error}")); + } + write!( + script, + "const rules={};", + serde_json::to_string(&rules).unwrap_or_else(|error| panic!("{error}")) + ) + .unwrap_or_else(|error| panic!("{error}")); + script.push_str(r"function values(e,property,level=0,selector=''){return (e.props.className||'').split(/\s+/).flatMap(k=>rules[k]?[rules[k]]:[]).filter(r=>r[0]===property&&r[2]===level&&r[3]===selector).map(r=>r[1].startsWith('var(')?e.props.style[r[1].slice(4,-1)]:r[1]).sort();} +function identities(e){return (e.props.className||'').split(/\s+/).filter(k=>k.includes('--'));} +"); + script.push_str(observation); + let result = Context::default() + .eval(Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("{error}\n{script}")); + let json = result + .as_string() + .unwrap_or_else(|| panic!("observation must be JSON")) + .to_std_string_escaped(); + serde_json::from_str(&json).unwrap_or_else(|error| panic!("{error}\n{json}\n{script}")) +} 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__/imported-styled.test.ts b/packages/next-plugin/src/__tests__/imported-styled.test.ts new file mode 100644 index 000000000..039e205d9 --- /dev/null +++ b/packages/next-plugin/src/__tests__/imported-styled.test.ts @@ -0,0 +1,137 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { withModuleResolver } from '../wasm' + +const originalCwd = process.cwd() + +describe('styled components another module defines', () => { + let root: string + + const write = (path: string, code: string) => { + mkdirSync(dirname(join(root, path)), { recursive: true }) + writeFileSync(join(root, path), code) + } + + const extract = (file: string) => + wasm.codeExtractWithoutSourceMap( + file, + readFileSync(join(root, file), 'utf8'), + '@devup-ui/react', + '@devup-ui/react', + true, + false, + false, + { '@emotion/react': null, '@emotion/styled': 'styled' }, + ) + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'devup-ui-imported-styled-')) + write( + 'tsconfig.json', + JSON.stringify({ + compilerOptions: { baseUrl: '.', paths: { '@/*': ['src/*'] } }, + }), + ) + write( + 'src/Base.tsx', + "import styled from '@emotion/styled'\nexport const Base = styled.div`color: red; padding: 4px;`\nexport const Dyn = styled.button`font-size: ${(p) => (p.big ? '20px' : '10px')};`\n", + ) + write( + 'node_modules/@acme/ui/package.json', + JSON.stringify({ name: '@acme/ui', exports: { '.': './src/index.js' } }), + ) + write( + 'node_modules/@acme/ui/src/index.js', + "import styled from '@emotion/styled'\nexport const Card = styled.section`border: 13px solid;`\n", + ) + process.chdir(root) + withModuleResolver(wasm) + }) + + afterEach(() => { + process.chdir(originalCwd) + rmSync(root, { recursive: true, force: true }) + }) + + it('composes under the styles of the component using it', () => { + write( + 'src/App.tsx', + "import styled from '@emotion/styled'\nimport { Base, Dyn } from '@/Base'\nimport { Card } from '@acme/ui'\nexport const A = styled(Base).attrs({ role: 'note' })`color: blue;`\nexport const B = styled(Dyn)`margin: 0;`\nexport const C = styled(Card)`border: 17px dotted;`\nexport const W = Base.withComponent('section')\n", + ) + const app = extract('src/App.tsx') + expect(app.code).not.toContain('DevupAs = Base') + expect(app.code).not.toContain('DevupAs = Dyn') + expect(app.code).not.toContain('DevupAs = Card') + expect(app.code).toContain('DevupAs = "div"') + expect(app.code).toContain('DevupAs = "button"') + expect(app.code).toContain('DevupAs = "section"') + expect(app.code).toContain('role: "note"') + expect([...app.dependencies].sort()).toEqual([ + 'node_modules/@acme/ui/src/index.js', + 'src/Base.tsx', + ]) + const css = wasm.getCss(null, false) + expect(css).toContain('color:blue') + expect(css).toContain('padding:4px') + expect(css).toContain('border:17px dotted') + expect(css).not.toContain('border:13px solid') + }) + + it('follows the definition when its module changes', () => { + write( + 'src/App.tsx', + "import styled from '@emotion/styled'\nimport { Base } from './Base'\nexport const A = styled(Base)`margin: 0;`\n", + ) + const before = extract('src/App.tsx') + write( + 'src/Base.tsx', + "import styled from '@emotion/styled'\nexport const Base = styled.main`color: green;`\n", + ) + const after = extract('src/App.tsx') + expect(before.dependencies).toEqual(['src/Base.tsx']) + expect(after.dependencies).toEqual(['src/Base.tsx']) + expect(before.code).toContain('DevupAs = "div"') + expect(after.code).toContain('DevupAs = "main"') + expect(wasm.getCss(null, false)).toContain('color:green') + }) + + it('selects a component by the class its own module gives it', () => { + write( + 'src/App.tsx', + "import styled from '@emotion/styled'\nimport { Base } from './Base'\nexport const P = styled.div`${Base} { margin: 7123px; }`\n", + ) + extract('src/App.tsx') + const base = extract('src/Base.tsx') + const selector = /\.[\w-]+ \.([\w-]+)\{margin:7123px/.exec( + wasm.getCss(null, false), + ) + const marker = selector?.[1] + expect(marker).toBeDefined() + expect(base.code).toContain(marker as string) + }) + + it('keeps a component it cannot read a runtime component', () => { + write( + 'src/Link.tsx', + "import styled from '@emotion/styled'\nimport Link from 'next/link'\nexport const Nav = styled(Link)`color: red;`\n", + ) + write( + 'src/App.tsx', + "import styled from '@emotion/styled'\nimport { Nav } from './Link'\nexport const A = styled(Nav)`color: blue;`\n", + ) + const app = extract('src/App.tsx') + expect(app.code).toContain('DevupAs = Nav') + expect(app.dependencies).toEqual(['src/Link.tsx']) + }) +}) 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/__tests__/webpack-delivery-http.cjs b/packages/next-plugin/src/__tests__/webpack-delivery-http.cjs index f954edf74..37fb9dbdf 100644 --- a/packages/next-plugin/src/__tests__/webpack-delivery-http.cjs +++ b/packages/next-plugin/src/__tests__/webpack-delivery-http.cjs @@ -58,6 +58,7 @@ function elementProof(html, parsed, proof) { assert.ok(classes?.length, `missing actual classes for ${proof.id}`) for (const name of classes) { assert.match(name, /^[\w-]+$/) + if (name.includes('--')) continue assert.ok( parsed.get(`.${name}`)?.size, `${proof.id}: actual class .${name} has no linked declarations`, 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('\\', '/'), }), )