diff --git a/.changepacks/changepack_log_ts_styled_output.json b/.changepacks/changepack_log_ts_styled_output.json new file mode 100644 index 000000000..3a1536b3b --- /dev/null +++ b/.changepacks/changepack_log_ts_styled_output.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Generate React JSX-runtime calls for styled components in non-JSX source modules while preserving JSX and TSX output. No new build errors.", + "date": "2026-10-13T00:00:00.000Z" +} diff --git a/apps/landing/src/app/(detail)/docs/build-errors/page.mdx b/apps/landing/src/app/(detail)/docs/build-errors/page.mdx index 753b69bbc..9dabc9992 100644 --- a/apps/landing/src/app/(detail)/docs/build-errors/page.mdx +++ b/apps/landing/src/app/(detail)/docs/build-errors/page.mdx @@ -1902,15 +1902,14 @@ export function f() { const c = css; c = () => 1; return c({ color: "red" }); } **Why:** A followed alias cannot be reassigned after the rewrite removes its declaration. -**Fix:** Keep the const alias unchanged and call it. +**Fix:** Call the imported css directly instead of creating a local runtime alias. {/* probe: mutated-const-alias after */} ```tsx import { css } from '@devup-ui/react' export function f() { - const c = css - return c({ color: 'red' }) + return css({ color: 'red' }) } ``` @@ -3926,7 +3925,7 @@ The `devup/no-runtime-read` rule reports compile-time bindings read outside supp `` is read at runtime, where it does not exist: the build compiles it only where it is called or rendered. ``` -```tsx +```tsx-error import { css } from '@devup-ui/react' consume(css) ``` diff --git a/e2e/tailwind-hover-order.spec.ts b/e2e/tailwind-hover-order.spec.ts index 803106f74..a3514b427 100644 --- a/e2e/tailwind-hover-order.spec.ts +++ b/e2e/tailwind-hover-order.spec.ts @@ -154,6 +154,7 @@ for (const singleCss of [true, false]) { for (const fixture of cases) { for (const [index, width] of [640, 1024].entries()) { const label = 'props' in fixture ? 'explicit Devup guard: ' : '' + const devupColorIndex = 'props' in fixture ? 1 : 0 test(`${label}${fixture.classes}, ${width}px, singleCss=${singleCss}`, async ({ page, }, testInfo) => { @@ -191,7 +192,7 @@ for (const singleCss of [true, false]) { const observed = [] for (const [name, classes, css, color] of [ ['Tailwind', fixture.classes, oracleCss, expected[0]], - ['Devup', matched?.[1], devupCss, expected[1]], + ['Devup', matched?.[1], devupCss, expected[devupColorIndex]], ]) { await page.setContent( ``, @@ -207,14 +208,14 @@ for (const singleCss of [true, false]) { } else if (!disabled) { await expect(target).toHaveCSS( 'background-color', - colors.red[name === 'Tailwind' ? 0 : 1], + colors.red[name === 'Tailwind' ? 0 : devupColorIndex], ) } if (fixture.classes.includes('focus:')) { await target.focus() await expect(target).toHaveCSS( 'background-color', - colors.green[name === 'Tailwind' ? 0 : 1], + colors.green[name === 'Tailwind' ? 0 : devupColorIndex], ) } await page.mouse.down() diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 9666b6d87..a5c7f5ab0 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -1,3 +1,4 @@ +use super::styled_jsx::StyledJsx; use rustc_hash::FxHashMap; use crate::{ @@ -44,6 +45,7 @@ const STYLED_FACTORY: &str = /// only the runtime gives the component struct Base<'a> { name: String, + target: Option>, styles: Option>, bound: Option>, } @@ -52,6 +54,7 @@ impl<'a> Base<'a> { const fn named(name: String) -> Self { Self { name, + target: None, styles: None, bound: None, } @@ -113,10 +116,14 @@ fn tag_from_argument<'a>( } Some(jsx_name(argument).map_or_else( || Base { + target: Some(Expression::new_identifier(SPAN, STYLED_BASE, ast_builder)), bound: Some(argument.clone_in(ast_builder.allocator())), ..Base::named(STYLED_BASE.to_string()) }, - Base::named, + |name| Base { + target: Some(argument.clone_in(ast_builder.allocator())), + ..Base::named(name) + }, )) } @@ -185,6 +192,7 @@ pub fn extract_style_from_styled<'a>( split_filename: Option<&str>, imports: &FxHashMap, attrs: &[Expression<'a>], + helper: &mut Option, ) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { let original_scope = expression.clone_in(ast_builder.allocator()); let mut composed_classes = Vec::new(); @@ -286,12 +294,13 @@ pub fn extract_style_from_styled<'a>( )); let component = create_styled_component( ast_builder, - &base.name, + &base, StyledRender { class_name: &class_name, style_vars: &gen_styles(ast_builder, &props_styles, None), bindings: Vec::new(), }, + helper, ); let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); @@ -363,12 +372,13 @@ pub fn extract_style_from_styled<'a>( ); let component = create_styled_component( ast_builder, - &base.name, + &base, StyledRender { class_name: &class_name, style_vars: &gen_styles(ast_builder, &styles, None), bindings, }, + helper, ); let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); @@ -575,8 +585,9 @@ struct StyledRender<'b, 'a> { fn create_styled_component<'a>( ast_builder: &AstBuilder<'a>, - tag_name: &str, + base: &Base<'a>, render: StyledRender<'_, 'a>, + helper: &mut Option, ) -> Expression<'a> { let StyledRender { class_name, @@ -637,92 +648,93 @@ fn create_styled_component<'a>( None::>>, ast_builder, ); - let body = Expression::new_jsx_element( - SPAN, - JSXOpeningElement::boxed( + let class_name = class_name.as_ref().map_or_else( + || Expression::new_identifier(SPAN, "className", ast_builder), + |name| { + wrap_array_filter( + ast_builder, + &[ + name.clone_in(ast_builder.allocator()), + Expression::new_identifier(SPAN, "className", ast_builder), + ], + ) + .unwrap_or_else(|| name.clone_in(ast_builder.allocator())) + }, + ); + let style = style_vars.as_ref().map_or_else( + || Expression::new_identifier(SPAN, "style", ast_builder), + |style_vars| { + merge_object_expressions( + ast_builder, + &[ + style_vars.clone_in(ast_builder.allocator()), + Expression::new_identifier(SPAN, "style", ast_builder), + ], + ) + .unwrap_or_else(|| style_vars.clone_in(ast_builder.allocator())) + }, + ); + let body = if let Some(helper) = helper { + let target = base.target.as_ref().map_or_else( + || { + Expression::new_string_literal( + SPAN, + Str::from_in(&base.name, ast_builder.allocator()), + None, + ast_builder, + ) + }, + |target| target.clone_in(ast_builder.allocator()), + ); + helper.render(ast_builder, target, (class_name, style)) + } else { + Expression::new_jsx_element( SPAN, - JSXElementName::new_identifier( + JSXOpeningElement::boxed( SPAN, - Str::from_in(tag_name, ast_builder.allocator()), - ast_builder, - ), - None::>>, - oxc_allocator::Vec::from_iter_in( - vec![ - JSXAttributeItem::new_spread_attribute( - SPAN, - Expression::new_identifier(SPAN, "rest", ast_builder), - ast_builder, - ), - JSXAttributeItem::new_attribute( - SPAN, - JSXAttributeName::new_identifier(SPAN, "className", ast_builder), - Some(JSXAttributeValue::new_expression_container( + JSXElementName::new_identifier( + SPAN, + Str::from_in(&base.name, ast_builder.allocator()), + ast_builder, + ), + None::>>, + oxc_allocator::Vec::from_iter_in( + vec![ + JSXAttributeItem::new_spread_attribute( SPAN, - class_name - .as_ref() - .map_or_else( - || Expression::new_identifier(SPAN, "className", ast_builder), - |name| { - wrap_array_filter( - ast_builder, - &[ - name.clone_in(ast_builder.allocator()), - Expression::new_identifier( - SPAN, - "className", - ast_builder, - ), - ], - ) - .unwrap_or_else(|| name.clone_in(ast_builder.allocator())) - }, - ) - .into(), + Expression::new_identifier(SPAN, "rest", ast_builder), ast_builder, - )), - ast_builder, - ), - JSXAttributeItem::new_attribute( - SPAN, - JSXAttributeName::new_identifier(SPAN, "style", ast_builder), - Some(JSXAttributeValue::new_expression_container( + ), + JSXAttributeItem::new_attribute( SPAN, - style_vars - .as_ref() - .map_or_else( - || Expression::new_identifier(SPAN, "style", ast_builder), - |style_vars| { - merge_object_expressions( - ast_builder, - &[ - style_vars.clone_in(ast_builder.allocator()), - Expression::new_identifier( - SPAN, - "style", - ast_builder, - ), - ], - ) - .unwrap_or_else( - || style_vars.clone_in(ast_builder.allocator()), - ) - }, - ) - .into(), + JSXAttributeName::new_identifier(SPAN, "className", ast_builder), + Some(JSXAttributeValue::new_expression_container( + SPAN, + class_name.into(), + ast_builder, + )), ast_builder, - )), - ast_builder, - ), - ], + ), + JSXAttributeItem::new_attribute( + SPAN, + JSXAttributeName::new_identifier(SPAN, "style", ast_builder), + Some(JSXAttributeValue::new_expression_container( + SPAN, + style.into(), + ast_builder, + )), + ast_builder, + ), + ], + ast_builder, + ), ast_builder, ), + oxc_allocator::Vec::new_in(ast_builder), + None::>>, ast_builder, - ), - oxc_allocator::Vec::new_in(ast_builder), - None::>>, - ast_builder, - ); + ) + }; Expression::new_arrow_function_expression( SPAN, false, diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index 084b3512d..d95289c0d 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_jsx; mod styled_record; mod styled_record_evaluation; #[cfg(test)] diff --git a/libs/extractor/src/extractor/styled_jsx.rs b/libs/extractor/src/extractor/styled_jsx.rs new file mode 100644 index 000000000..75af546ef --- /dev/null +++ b/libs/extractor/src/extractor/styled_jsx.rs @@ -0,0 +1,171 @@ +use oxc_allocator::{FromIn, GetAllocator, Vec}; +use oxc_ast::{ + ast::{ + Argument, BindingIdentifier, BindingPattern, Expression, Ident, IdentifierName, + IdentifierReference, ImportDeclaration, ImportDeclarationSpecifier, ImportOrExportKind, + ModuleExportName, ObjectPropertyKind, Program, PropertyKey, PropertyKind, Statement, + StringLiteral, VariableDeclaration, VariableDeclarationKind, VariableDeclarator, + }, + builder::AstBuilder, +}; +use oxc_ast_visit::Visit; +use oxc_span::SPAN; +use rustc_hash::FxHashSet; + +#[cfg(test)] +#[path = "styled_jsx_tests.rs"] +mod tests; + +/// The module's fresh React factory, used only by generated non-JSX renderers. +pub(crate) struct StyledJsx { + name: String, + used: bool, +} + +#[derive(Default)] +struct Names<'a>(FxHashSet<&'a str>); + +impl<'a> Visit<'a> for Names<'a> { + fn visit_binding_identifier(&mut self, it: &BindingIdentifier<'a>) { + self.0.insert(it.name.as_str()); + } + + fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + self.0.insert(it.name.as_str()); + } +} + +impl StyledJsx { + /// Reserve a name across all scopes before extraction removes or rewrites nodes. + pub(crate) fn reserve(program: &Program<'_>) -> Option { + if program.source_type.is_jsx() { + return None; + } + let mut names = Names::default(); + names.visit_program(program); + let mut suffix = 0; + let name = loop { + let name = format!("__devupJsx{suffix}"); + if !names.0.contains(name.as_str()) { + break name; + } + suffix += 1; + }; + Some(Self { name, used: false }) + } + + /// Render the generated element with its existing class and style expressions. + pub(crate) fn render<'a>( + &mut self, + ast: &AstBuilder<'a>, + target: Expression<'a>, + props: (Expression<'a>, Expression<'a>), + ) -> Expression<'a> { + self.used = true; + let mut properties = Vec::with_capacity_in(3, ast); + properties.push(ObjectPropertyKind::new_spread_property( + SPAN, + Expression::new_identifier(SPAN, "rest", ast), + ast, + )); + for (key, value) in [("className", props.0), ("style", props.1)] { + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + PropertyKind::Init, + PropertyKey::new_static_identifier(SPAN, key, ast), + value, + false, + false, + false, + ast, + )); + } + Expression::new_call_expression( + SPAN, + Expression::new_identifier(SPAN, Ident::from_in(&self.name, ast.allocator()), ast), + None, + Vec::from_array_in( + [ + Argument::from(target), + Argument::from(Expression::new_object_expression(SPAN, properties, ast)), + ], + ast, + ), + false, + ast, + ) + } + + /// Insert a value binding only after a renderer has actually used it. + pub(crate) fn binding<'a>( + &self, + ast: &AstBuilder<'a>, + commonjs: bool, + ) -> Option> { + if !self.used { + return None; + } + let name = Ident::from_in(&self.name, ast.allocator()); + if commonjs { + let require = Expression::new_call_expression( + SPAN, + Expression::new_identifier(SPAN, "require", ast), + None, + Vec::from_array_in( + [Argument::from(Expression::new_string_literal( + SPAN, + "react/jsx-runtime", + None, + ast, + ))], + ast, + ), + false, + ast, + ); + let init = Expression::new_static_member_expression( + SPAN, + require, + IdentifierName::new(SPAN, "jsx", ast), + false, + ast, + ); + Some(Statement::VariableDeclaration(VariableDeclaration::boxed( + SPAN, + VariableDeclarationKind::Const, + Vec::from_array_in( + [VariableDeclarator::new( + SPAN, + BindingPattern::new_binding_identifier(SPAN, name, ast), + None, + Some(init), + false, + ast, + )], + ast, + ), + false, + ast, + ))) + } else { + Some(Statement::ImportDeclaration(ImportDeclaration::boxed( + SPAN, + Some(Vec::from_array_in( + [ImportDeclarationSpecifier::new_import_specifier( + SPAN, + ModuleExportName::new_identifier_name(SPAN, "jsx", ast), + BindingIdentifier::new(SPAN, name, ast), + ImportOrExportKind::Value, + ast, + )], + ast, + )), + StringLiteral::new(SPAN, "react/jsx-runtime", None, ast), + None, + None, + ImportOrExportKind::Value, + ast, + ))) + } + } +} diff --git a/libs/extractor/src/extractor/styled_jsx_tests.rs b/libs/extractor/src/extractor/styled_jsx_tests.rs new file mode 100644 index 000000000..024c57303 --- /dev/null +++ b/libs/extractor/src/extractor/styled_jsx_tests.rs @@ -0,0 +1,80 @@ +use super::*; +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +#[test] +fn commonjs_binding_resolves_factory_when_renderer_is_used() { + // Given: an actual CommonJS program, retaining its directive. + let arena = Allocator::default(); + let ast = AstBuilder::new(&arena); + let mut program = Parser::new(&arena, "'use strict';", SourceType::cjs()) + .parse() + .program; + let mut helper = StyledJsx::reserve(&program) + .unwrap_or_else(|| panic!("CommonJS source must reserve a styled JSX helper")); + let render = helper.render( + &ast, + Expression::new_string_literal(SPAN, "article", None, &ast), + ( + Expression::new_identifier(SPAN, "className", &ast), + Expression::new_identifier(SPAN, "style", &ast), + ), + ); + // When: the real binding and renderer execute against a CommonJS factory export. + program.body.push( + helper + .binding(&ast, true) + .unwrap_or_else(|| panic!("used renderer must produce a CommonJS binding")), + ); + let binding = Codegen::new().build(&program).code; + let render = crate::utils::readable_code(&render); + let script = format!( + "{binding}\nfunction require(path) {{ if (path !== 'react/jsx-runtime') throw Error(path); return {{ jsx: (target, props) => ({{target, props}}) }}; }}\nconst rest = {{children: 'child'}}; const className = 'caller'; const style = {{color: 'olive'}}; const result = {render}; JSON.stringify(result);" + ); + let mut context = Context::default(); + let result = context + .eval(Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("CommonJS renderer evaluation failed: {error}")); + // Then: the target and forwarded props come from the imported factory. + assert!(binding.starts_with("\"use strict\";\nconst ")); + assert_eq!( + result + .as_string() + .unwrap_or_else(|| panic!("CommonJS renderer JSON result must be a string")) + .to_std_string_escaped(), + r#"{"target":"article","props":{"children":"child","className":"caller","style":{"color":"olive"}}}"#, + ); +} + +#[rstest] +#[case(SourceType::tsx())] +#[case(SourceType::jsx())] +fn helper_is_unreserved_when_source_accepts_jsx(#[case] source_type: SourceType) { + // Given: a source dialect that accepts JSX. + let arena = Allocator::default(); + let program = Parser::new(&arena, "", source_type).parse().program; + // When: helper reservation runs. + let helper = StyledJsx::reserve(&program); + // Then: the existing JSX path has no helper state. + assert!(helper.is_none()); +} + +#[rstest] +#[case(false)] +#[case(true)] +fn binding_is_absent_when_renderer_is_unused(#[case] commonjs: bool) { + // Given: a non-JSX module with no renderer compiled. + let arena = Allocator::default(); + let ast = AstBuilder::new(&arena); + let program = Parser::new(&arena, "", SourceType::ts()).parse().program; + let helper = StyledJsx::reserve(&program) + .unwrap_or_else(|| panic!("non-JSX TypeScript source must reserve a styled JSX helper")); + // When: module binding insertion is requested. + let binding = helper.binding(&ast, commonjs); + // Then: neither module mode receives an unused factory. + assert!(binding.is_none()); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 3e4fb9c6a..904288c3e 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -13,6 +13,8 @@ mod mutations; mod prop_modify_utils; mod source_map; mod style_values; +#[cfg(test)] +mod styled_source_tests; mod stylex; mod tailwind; mod tailwind_children; diff --git a/libs/extractor/src/styled_source_tests/compatibility.rs b/libs/extractor/src/styled_source_tests/compatibility.rs new file mode 100644 index 000000000..bf91f7296 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/compatibility.rs @@ -0,0 +1,73 @@ +use super::*; +use oxc_codegen::Codegen; + +#[rstest] +#[case("tsx")] +#[case("jsx")] +#[serial] +fn output_is_byte_identical_when_source_accepts_jsx( + #[case] extension: &str, + #[values(true, false)] single_css: bool, +) { + // Given: baseline bytes captured with the installed main WASM, not a new snapshot. + 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\";\nexport const Card = ({ style, className, ...rest }) =>
;\n" + } else { + "import \"@devup-ui/react/devup-ui-0.css\";\nexport const Card = ({ style, className, ...rest }) =>
;\n" + }; + // When: the real extractor compiles the control. + let output = compile(&filename, source, single_css); + // Then: the entire module still parses and retains its exact baseline bytes. + let arena = Allocator::default(); + parse(&arena, &filename, &output); + assert_eq!(output, expected); +} + +#[rstest] +#[case("export const keep = 1;")] +#[case("import { styled } from '@devup-ui/react'; export const keep = 1;")] +#[case("import { css } from '@devup-ui/react'; export const keep = css({ color: 'red' });")] +#[serial] +fn helper_is_absent_when_module_has_no_compiled_styled( + #[case] source: &str, + #[values(true, false)] single_css: bool, +) { + // Given: valid non-JSX source without a styled renderer. + // When: the real extractor processes it. + let output = compile("test.ts", source, single_css); + // Then: no React element factory import is introduced. + let arena = Allocator::default(); + let program = parse(&arena, "test.ts", &output); + assert_eq!(jsx_imports(&program), Vec::::new()); +} + +#[rstest] +#[serial] +fn types_are_preserved_when_styled_is_lowered(#[values(true, false)] single_css: bool) { + // Given: declarations unrelated to the consumed styled call. + let declarations = "import type { External } from 'types';\ninterface Keep { value: External }\ntype KeepValue = string | number;\nenum KeepKind { A, B }\nconst keepValue: KeepValue = 'value' satisfies KeepValue;\nfunction keepIdentity(value: T): T { return value; }"; + let source = format!("{IMPORT}{declarations}\nconst Card = styled('div', {{ color: 'red' }});"); + let original_arena = Allocator::default(); + let original = parse(&original_arena, "test.ts", declarations); + let expected = Codegen::new().build(&original).code; + // When: extraction produces the full non-JSX module. + let output = compile("test.ts", &source, single_css); + // Then: the unrelated declaration AST still emits exactly the same TypeScript. + let arena = Allocator::default(); + let mut program = parse(&arena, "test.ts", &output); + program.body.retain(|statement| match statement { + Statement::ImportDeclaration(import) => import.import_kind.is_type(), + Statement::VariableDeclaration(declaration) => { + declaration.declarations.iter().any(|declarator| { + declarator + .id + .get_binding_identifier() + .is_some_and(|id| id.name == "keepValue") + }) + } + _ => true, + }); + assert_eq!(Codegen::new().build(&program).code, expected); +} diff --git a/libs/extractor/src/styled_source_tests/execution.rs b/libs/extractor/src/styled_source_tests/execution.rs new file mode 100644 index 000000000..323a76c96 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/execution.rs @@ -0,0 +1,84 @@ +use super::*; +use boa_engine::{Context, Source}; +use oxc_codegen::Codegen; + +#[rstest] +#[case("styled(tag)({ color: 'red' })")] +#[case("styled(targets[name])({ color: 'red' })")] +#[case("styled(active ? Component : Other)({ color: 'red' })")] +#[case("styled(getThing().div)({ color: 'red' })")] +#[case("styled(getBase())({ color: 'red' })")] +#[serial] +fn bound_target_is_evaluated_once_when_component_renders(#[case] factory: &str) { + // Given: runtime bases, including side effects and computed member reads. + let source = format!( + "{IMPORT}let reads = 0; const Component = 'article'; const Other = 'aside'; const targets = {{ get div() {{ reads++; return Component; }} }}; function getThing() {{ reads++; return {{ div: Component }}; }} function getBase() {{ reads++; return Component; }} function makeCard(tag, active, targets, name) {{ return {factory}; }} const Card = makeCard(Component, true, targets, 'div');" + ); + let output = compile("test.js", &source, true); + let arena = Allocator::default(); + let mut program = parse(&arena, "test.js", &output); + let calls = renders(&program); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].target, Target::Component("DevupStyled".into())); + let helper = &calls[0].helper; + program + .body + .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + let code = Codegen::new().build(&program).code; + // When: the generated renderer executes twice after definition-time evaluation. + let script = format!( + "const {helper} = (target, props) => ({{target, props}}); {code}\nconst before = reads; const first = Card({{children: 'first'}}); const second = Card({{children: 'second'}}); JSON.stringify([before, reads, first.target, second.target, first.props.children, second.props.children]);" + ); + let mut context = Context::default(); + let result = context + .eval(Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("bound-target renderer evaluation failed: {error}")); + // Then: renders keep the same target and never re-evaluate the runtime base. + let expected_reads = match factory { + "styled(tag)({ color: 'red' })" + | "styled(active ? Component : Other)({ color: 'red' })" => 0, + _ => 1, + }; + assert_eq!( + result + .as_string() + .unwrap_or_else(|| panic!("bound-target renderer JSON result must be a string")) + .to_std_string_escaped(), + format!("[{expected_reads},{expected_reads},\"article\",\"article\",\"first\",\"second\"]"), + ); +} + +#[rstest] +#[case("styled('article', { color: runtimeColor })")] +#[case("styled('article').attrs({ role: 'button' })({ color: runtimeColor })")] +#[serial] +fn caller_props_win_when_dynamic_styles_are_rendered(#[case] factory: &str) { + // Given: a dynamic CSS variable and caller-provided class, style and ordinary props. + let source = format!("{IMPORT}const runtimeColor = getColor(); const Card = {factory};"); + let output = compile("test.js", &source, true); + let arena = Allocator::default(); + let mut program = parse(&arena, "test.js", &output); + let calls = renders(&program); + assert_eq!(calls.len(), 1); + let helper = &calls[0].helper; + program + .body + .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + let code = Codegen::new().build(&program).code; + // When: the generated call renders with caller overrides. + let script = format!( + "const {helper} = (target, props) => ({{target, props}}); function getColor() {{ return 'red'; }} {code}\nconst first = Card({{className: 'caller', children: 'child', onClick: 7, ref: 8, as: 'section'}}); const variable = Object.keys(first.props.style)[0]; const second = Card({{style: {{[variable]: 'blue'}}, role: 'link'}}); JSON.stringify([first.target, first.props.className.includes('caller'), first.props.style[variable], first.props.children, first.props.onClick, first.props.ref, first.props.as, second.props.style[variable]]);" + ); + let mut context = Context::default(); + let result = context + .eval(Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("caller-props renderer evaluation failed: {error}")); + // Then: generated variables survive, caller style wins and pass-through props remain. + assert_eq!( + result + .as_string() + .unwrap_or_else(|| panic!("caller-props renderer JSON result must be a string")) + .to_std_string_escaped(), + r#"["article",true,"red","child",7,8,"section","blue"]"#, + ); +} diff --git a/libs/extractor/src/styled_source_tests/lowering.rs b/libs/extractor/src/styled_source_tests/lowering.rs new file mode 100644 index 000000000..258117059 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/lowering.rs @@ -0,0 +1,156 @@ +use super::*; +use oxc_semantic::SemanticBuilder; + +#[rstest] +#[case("ts")] +#[case("mts")] +#[case("cts")] +#[case("js")] +#[case("mjs")] +#[case("cjs")] +#[serial] +fn output_parses_when_original_suffix_excludes_jsx( + #[case] extension: &str, + #[values(true, false)] single_css: bool, + #[values( + "styled('div', { color: 'red' })", + "styled('div')({ color: 'red' })", + "styled.div({ color: 'red' })", + "styled.div`color: red;`" + )] + factory: &str, +) { + // Given: a valid styled definition in its original non-JSX dialect. + let filename = format!("test.{extension}"); + let source = format!("{IMPORT}export const Card = {factory};"); + // When: the real extractor compiles it. + let output = compile(&filename, &source, single_css); + // Then: the complete emitted module parses without JSX permission or ignored errors. + let arena = Allocator::default(); + parse(&arena, &filename, &output); +} + +#[rstest] +#[case("styled('Widget', {})", Target::Tag("Widget".into()))] +#[case("styled('motion.div', {})", Target::Tag("motion.div".into()))] +#[case("styled('my-card', {})", Target::Tag("my-card".into()))] +#[case("styled(Component, {})", Target::Component("Component".into()))] +#[case("styled(motion.div, {})", Target::Member("motion.div".into()))] +#[case("styled(Motion.Div.Inner, {})", Target::Member("Motion.Div.Inner".into()))] +#[case("styled(Box, {})", Target::Tag("div".into()))] +#[case("styled(forwardRef((props, ref) => createElement('div', { ...props, ref })))({})", Target::Component("DevupStyled".into()))] +#[serial] +fn render_target_is_preserved_when_styled_emits_a_call( + #[case] factory: &str, + #[case] expected: Target, + #[values(true, false)] single_css: bool, +) { + // Given: tags and component values with intentionally different identities. + let source = format!( + "{IMPORT}import {{ forwardRef, createElement }} from 'react';\nconst Card = {factory};" + ); + // When: the real extractor compiles even an empty-style factory. + let output = compile("test.ts", &source, single_css); + // Then: the render call receives the literal or component AST, not a guessed string. + let arena = Allocator::default(); + let program = parse(&arena, "test.ts", &output); + let calls = renders(&program); + assert_eq!(calls.len(), 1, "{output}"); + assert_eq!(calls[0].target, expected); + assert!(jsx_imports(&program).contains(&calls[0].helper), "{output}"); +} + +#[rstest] +#[case(".attrs({ role: 'button' })")] +#[case(".attrs(props => ({ role: props.role || 'button' }))")] +#[serial] +fn passthrough_props_remain_when_attrs_wrap_the_renderer( + #[case] attrs: &str, + #[values(true, false)] single_css: bool, +) { + // Given: caller as/ref/children are ordinary props, not a request for new semantics. + let source = format!( + "{IMPORT}const Card = styled('article'){attrs}`color: red;`;\nconst element = Card({{ as: 'section', ref: callerRef, children: 'child' }});" + ); + // When: extraction compiles the attrs-wrapped renderer. + let output = compile("test.ts", &source, single_css); + // Then: the native target and rest spread remain; ref/as are not stripped or reinterpreted. + let arena = Allocator::default(); + let program = parse(&arena, "test.ts", &output); + let calls = renders(&program); + assert_eq!(calls.len(), 1, "{output}"); + assert_eq!(calls[0].target, Target::Tag("article".into())); + assert_eq!( + calls[0] + .props + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + ["...", "className", "style"] + ); + assert_eq!(calls[0].props[0].1, "rest"); + assert!(output.contains("as: \"section\"")); + assert!(output.contains("ref: callerRef")); + assert!(output.contains("children: \"child\"")); + assert!(output.contains("role:")); +} + +#[rstest] +#[serial] +fn helper_resolves_to_import_when_names_collide(#[values(true, false)] single_css: bool) { + // Given: a binding, nested binding, free reference and shadowed existing jsx alias. + let source = format!( + "{IMPORT}import {{ jsx as existing }} from 'react/jsx-runtime';\nconst __devupJsx0 = 0;\nconst free = __devupJsx2;\nconst A = styled('div', {{}});\nfunction make(existing, __devupJsx1) {{ return styled('span', {{}}); }}\nconst element = existing('aside', {{ children: 'original' }});" + ); + // When: the extractor compiles two renderers in different scopes. + let output = compile("test.ts", &source, single_css); + // Then: both calls resolve to the same imported jsx symbol, never a shadowing binding. + let arena = Allocator::default(); + let program = parse(&arena, "test.ts", &output); + let semantic = SemanticBuilder::new().build(&program).semantic; + let calls = renders(&program); + assert_eq!(calls.len(), 2, "{output}"); + assert_eq!(calls[0].helper, calls[1].helper); + let imports = jsx_imports(&program); + assert!(imports.contains(&"existing".into())); + assert!(imports.contains(&calls[0].helper), "{output}"); + assert_eq!( + imports + .iter() + .filter(|name| name.as_str() != "existing") + .count(), + 1 + ); + let imported_symbol = program.body.iter().find_map(|statement| match statement { + Statement::ImportDeclaration(import) if import.source.value == "react/jsx-runtime" => { + import + .specifiers + .iter() + .flatten() + .find_map(|specifier| match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) + if named.local.name == calls[0].helper.as_str() => + { + named.local.symbol_id.get() + } + _ => None, + }) + } + _ => None, + }); + assert!(imported_symbol.is_some(), "{output}"); + for call in &calls { + assert!( + !["__devupJsx0", "__devupJsx1", "__devupJsx2", "existing"] + .contains(&call.helper.as_str()) + ); + let reference = match call.reference { + Some(reference) => reference, + None => panic!("missing helper reference"), + }; + assert_eq!( + semantic.scoping().get_reference(reference).symbol_id(), + imported_symbol + ); + } +} diff --git a/libs/extractor/src/styled_source_tests/mod.rs b/libs/extractor/src/styled_source_tests/mod.rs new file mode 100644 index 000000000..d8dd637c1 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/mod.rs @@ -0,0 +1,199 @@ +mod compatibility; +mod execution; +mod lowering; + +use crate::{ExtractOption, extract, utils::readable_code}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{ + ArrowFunctionExpression, BindingPattern, CallExpression, Expression, + ImportDeclarationSpecifier, ObjectPropertyKind, Program, Statement, +}; +use oxc_ast_visit::{ + Visit, + walk::{walk_arrow_function_expression, walk_call_expression}, +}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use oxc_syntax::reference::ReferenceId; +use rstest::rstest; +use serial_test::serial; + +const IMPORT: &str = "import { styled, Box } from '@devup-ui/react';\n"; + +fn parse<'a>(arena: &'a Allocator, filename: &str, code: &'a str) -> Program<'a> { + let source_type = match SourceType::from_path(filename) { + Ok(source_type) => source_type, + Err(error) => panic!("{filename}: {error}"), + }; + let parsed = Parser::new(arena, code, source_type).parse(); + assert_eq!( + parsed.diagnostics.len(), + 0, + "{filename}\n{code}\n{:?}", + parsed.diagnostics + ); + assert!(!parsed.fatal_error, "{filename}\n{code}"); + parsed.program +} + +fn compile(filename: &str, source: &str, single_css: bool) -> String { + let arena = Allocator::default(); + parse(&arena, filename, source); + reset_class_map(); + reset_file_map(); + match extract( + filename, + source, + ExtractOption { + single_css, + import_main_css: false, + ..ExtractOption::default() + }, + ) { + Ok(output) => output.code, + Err(error) => panic!("{filename}\n{source}\n{error}"), + } +} + +#[derive(Debug, PartialEq, Eq)] +enum Target { + Tag(String), + Component(String), + Member(String), +} + +#[derive(Debug)] +struct Render { + helper: String, + reference: Option, + target: Target, + props: Vec<(String, String)>, +} + +#[derive(Default)] +struct Renders(Vec); + +impl<'a> Visit<'a> for Renders { + fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'a>) { + for parameter in &arrow.params.items { + let pattern = match ¶meter.pattern { + BindingPattern::ObjectPattern(pattern) => pattern, + _ => continue, + }; + let keys: Vec<_> = pattern + .properties + .iter() + .filter_map(|property| property.key.static_name()) + .collect(); + if keys.iter().any(|key| key.as_ref() == "style") + && keys.iter().any(|key| key.as_ref() == "className") + { + assert_eq!( + keys.iter() + .map(std::convert::AsRef::as_ref) + .collect::>(), + ["style", "className"] + ); + assert_eq!( + pattern + .rest + .as_ref() + .and_then(|rest| rest.argument.get_binding_identifier()) + .map(|id| id.name.as_str()), + Some("rest") + ); + } + } + walk_arrow_function_expression(self, arrow); + } + + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + walk_call_expression(self, call); + let (target, props) = match call.arguments.as_slice() { + [target, props] => (target, props), + _ => return, + }; + let object = match props.as_expression() { + Some(Expression::ObjectExpression(object)) => object, + _ => return, + }; + let props: Vec<_> = object + .properties + .iter() + .map(|property| match property { + ObjectPropertyKind::ObjectProperty(property) => ( + property + .key + .static_name() + .map_or_else(String::new, |name| name.to_string()), + readable_code(&property.value), + ), + ObjectPropertyKind::SpreadProperty(spread) => { + ("...".into(), readable_code(&spread.argument)) + } + }) + .collect(); + if !props + .iter() + .any(|(key, value)| key == "..." && value == "rest") + || !props.iter().any(|(key, _)| key == "className") + || !props.iter().any(|(key, _)| key == "style") + { + return; + } + let target = match target.as_expression() { + Some(Expression::StringLiteral(tag)) => Target::Tag(tag.value.to_string()), + Some(Expression::Identifier(component)) => { + Target::Component(component.name.to_string()) + } + Some(expression @ Expression::StaticMemberExpression(_)) => { + Target::Member(readable_code(expression)) + } + other => panic!("unexpected render target: {other:?}"), + }; + let (helper, reference) = match &call.callee { + Expression::Identifier(helper) => (helper.name.to_string(), helper.reference_id.get()), + other => panic!("unexpected render helper: {other:?}"), + }; + self.0.push(Render { + helper, + reference, + target, + props, + }); + } +} + +fn renders(program: &Program<'_>) -> Vec { + let mut calls = Renders::default(); + calls.visit_program(program); + calls.0 +} + +fn jsx_imports(program: &Program<'_>) -> Vec { + program + .body + .iter() + .flat_map(|statement| match statement { + Statement::ImportDeclaration(import) if import.source.value == "react/jsx-runtime" => { + import + .specifiers + .iter() + .flatten() + .filter_map(|specifier| match specifier { + ImportDeclarationSpecifier::ImportSpecifier(named) + if named.imported.to_string() == "jsx" + && !import.import_kind.is_type() + && !named.import_kind.is_type() => + { + Some(named.local.name.to_string()) + } + _ => None, + }) + .collect::>() + } + _ => vec![], + }) + .collect() +} diff --git a/libs/extractor/src/tailwind_children.rs b/libs/extractor/src/tailwind_children.rs index 78d266b05..6ae20a798 100644 --- a/libs/extractor/src/tailwind_children.rs +++ b/libs/extractor/src/tailwind_children.rs @@ -124,6 +124,7 @@ pub fn children_utility(name: &str) -> Option> { #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use rstest::rstest; + use serial_test::serial; use super::*; use crate::tailwind::{declarations_of, parse_class}; @@ -177,6 +178,7 @@ mod tests { ])] #[case("space-x-reverse", &[("--tw-space-x-reverse", "1")])] #[case("space-y-reverse", &[("--tw-space-y-reverse", "1")])] + #[serial] fn space_adds_margins_between_the_children( #[case] class: &str, #[case] expected: &[(&str, &str)], @@ -229,6 +231,7 @@ mod tests { } #[test] + #[serial] fn divide_reverse_style_and_color() { expect("divide-x-reverse", &[("--tw-divide-x-reverse", "1")]); expect("divide-y-reverse", &[("--tw-divide-y-reverse", "1")]); @@ -268,11 +271,13 @@ mod tests { #[case("space-x-huge")] #[case("-space-x-reverse")] #[case("space-z-4")] + #[serial] fn unknown_children_utilities_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } #[test] + #[serial] fn children_utilities_apply_under_variants_to_the_children() { let class = parse_class("space-x-4").unwrap(); assert_eq!(class.conditions.len(), 1); @@ -310,6 +315,7 @@ mod tests { } #[test] + #[serial] fn children_rules_register_their_variables() { assert_eq!( parse_class("space-x-4").unwrap().rules(), diff --git a/libs/extractor/src/tailwind_color.rs b/libs/extractor/src/tailwind_color.rs index fa48bf6b0..cca7c4ddd 100644 --- a/libs/extractor/src/tailwind_color.rs +++ b/libs/extractor/src/tailwind_color.rs @@ -437,6 +437,7 @@ mod tests { #[case("caret-fuchsia-100", "oklch(95.2% 0.037 318.852)")] #[case("fill-zinc-900", "oklch(21% 0.006 285.885)")] #[case("stroke-stone-200", "oklch(92.3% 0.003 48.717)")] + #[serial] fn palette_is_the_default_theme_of_v4(#[case] class: &str, #[case] expected: &str) { assert_eq!(color(class).as_deref(), Some(expected)); } @@ -474,6 +475,7 @@ mod tests { )] #[case("bg-black/10", "color-mix(in oklab, #000 10%, transparent)")] #[case("text-white/75", "color-mix(in oklab, #fff 75%, transparent)")] + #[serial] fn opacity_modifier_mixes_with_transparent(#[case] class: &str, #[case] expected: &str) { assert_eq!(color(class).as_deref(), Some(expected)); } @@ -489,6 +491,7 @@ mod tests { #[case("bg-red-500/50/50")] #[case("bg-red-501")] #[case("bg-unknown")] + #[serial] fn invalid_color_stays_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } @@ -541,6 +544,7 @@ mod tests { ("--tw-blur", "blur(8px) !important"), ("filter", "var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,) !important"), ])] + #[serial] fn important_marks_every_declaration(#[case] class: &str, #[case] expected: &[(&str, &str)]) { let expected: Vec<(String, String)> = expected .iter() @@ -550,6 +554,7 @@ mod tests { } #[test] + #[serial] fn important_applies_under_variants() { let class = crate::tailwind::parse_class("hover:p-4!").unwrap(); assert_eq!(class.declarations, vec![decl("padding", "1rem !important")]); diff --git a/libs/extractor/src/tailwind_effects.rs b/libs/extractor/src/tailwind_effects.rs index 8b55e4290..90ffd4c4b 100644 --- a/libs/extractor/src/tailwind_effects.rs +++ b/libs/extractor/src/tailwind_effects.rs @@ -705,6 +705,7 @@ mod tests { #[case("blur-none", " ")] #[case("blur-[2px]", "blur(2px)")] #[case("blur-(--radius)", "blur(var(--radius))")] + #[serial] fn blur_composes_through_its_variable(#[case] class: &str, #[case] value: &str) { assert_eq!(written(class), filter("--tw-blur", value)); } @@ -729,6 +730,7 @@ mod tests { #[case("saturate-150", "--tw-saturate", "saturate(150%)")] #[case("sepia", "--tw-sepia", "sepia(100%)")] #[case("sepia-50", "--tw-sepia", "sepia(50%)")] + #[serial] fn filter_functions_set_their_own_variable( #[case] class: &str, #[case] variable: &str, @@ -764,6 +766,7 @@ mod tests { #[case("backdrop-opacity-12.5", "--tw-backdrop-opacity", "opacity(12.5%)")] #[case("backdrop-saturate-200", "--tw-backdrop-saturate", "saturate(200%)")] #[case("backdrop-sepia", "--tw-backdrop-sepia", "sepia(100%)")] + #[serial] fn backdrop_filter_functions_compose_too( #[case] class: &str, #[case] variable: &str, @@ -773,6 +776,7 @@ mod tests { } #[test] + #[serial] fn filter_and_backdrop_filter_read_every_function() { expect("filter", &[("filter", FILTER)]); expect("filter-none", &[("filter", "none")]); @@ -804,6 +808,7 @@ mod tests { } #[test] + #[serial] fn two_filters_add_up_instead_of_overwriting() { let blur = written("blur-sm"); let grayscale = written("grayscale"); @@ -852,6 +857,7 @@ mod tests { #[case("-filter-none")] #[case("backdrop-")] #[case("blurry")] + #[serial] fn unknown_filters_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } @@ -884,6 +890,7 @@ mod tests { "0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25))" )] #[case("shadow-none", "0 0 #0000")] + #[serial] fn shadow_sizes_follow_the_default_theme(#[case] class: &str, #[case] shadow: &str) { expect( class, @@ -927,11 +934,13 @@ mod tests { "shadow-current", "color-mix(in oklab, currentcolor var(--tw-shadow-alpha), transparent)" )] + #[serial] fn shadow_colors_set_the_color_the_shadows_read(#[case] class: &str, #[case] color: &str) { expect(class, &[("--tw-shadow-color", color)]); } #[test] + #[serial] fn shadow_inherit_and_unknown_shadows() { expect("shadow-inherit", &[("--tw-shadow-color", "inherit")]); for class in [ @@ -984,6 +993,7 @@ mod tests { #[case("bg-linear-to-r/increasing", "to right in oklch increasing hue")] #[case("bg-linear-to-r/decreasing", "to right in oklch decreasing hue")] #[case("bg-linear-90/[in_hsl_longer_hue]", "90deg in hsl longer hue")] + #[serial] fn linear_gradients_use_the_syntax_of_v4(#[case] class: &str, #[case] position: &str) { expect( class, @@ -998,6 +1008,7 @@ mod tests { } #[test] + #[serial] fn conic_and_radial_gradients() { for (class, position, image) in [ ( @@ -1047,6 +1058,7 @@ mod tests { } #[test] + #[serial] fn arbitrary_gradients_carry_their_own_stops() { expect( "bg-linear-[25deg,red_5%,yellow_60%,lime_90%,teal]", @@ -1102,6 +1114,7 @@ mod tests { #[case("bg-radial-45")] #[case("bg-conic-x")] #[case("bg-sweep-45")] + #[serial] fn gradients_v4_does_not_define_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } @@ -1110,6 +1123,7 @@ mod tests { const VIA_STOPS: &str = "var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position)"; #[test] + #[serial] fn gradient_stops_compose_through_variables() { expect( "from-red-500", @@ -1157,6 +1171,7 @@ mod tests { } #[test] + #[serial] fn gradient_stop_positions() { expect("from-10%", &[("--tw-gradient-from-position", "10%")]); expect("via-30%", &[("--tw-gradient-via-position", "30%")]); @@ -1178,11 +1193,13 @@ mod tests { #[case("-from-red-500")] #[case("via-none/50")] #[case("up-red-500")] + #[serial] fn unknown_gradient_stops_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } #[test] + #[serial] fn gradient_rules_register_the_stop_variables() { let rules = parse_class("from-red-500").unwrap().rules(); for name in [ @@ -1211,6 +1228,7 @@ mod tests { } #[test] + #[serial] fn modifiers_and_variants_apply_to_composed_utilities() { let class = parse_class("hover:blur-sm").unwrap(); assert_eq!(class.declarations, written_pairs("blur-sm")); @@ -1237,6 +1255,7 @@ mod tests { #[case("ring-2", "2px")] #[case("ring-8", "8px")] #[case("ring-[3px]", "3px")] + #[serial] fn ring_widths_draw_the_ring_composed_with_the_shadows( #[case] class: &str, #[case] width: &str, @@ -1256,6 +1275,7 @@ mod tests { } #[test] + #[serial] fn ring_colors_inset_rings_and_offsets() { expect("ring-inset", &[("--tw-ring-inset", "inset")]); expect( @@ -1327,6 +1347,7 @@ mod tests { #[case("ring-offset-nothing")] #[case("inset-ring-nothing")] #[case("-ring-2")] + #[serial] fn unknown_rings_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } @@ -1338,6 +1359,7 @@ mod tests { #[case("drop-shadow-lg", "0 4px 4px", "rgb(0 0 0 / 0.15)")] #[case("drop-shadow-xl", "0 9px 7px", "rgb(0 0 0 / 0.1)")] #[case("drop-shadow-2xl", "0 25px 25px", "rgb(0 0 0 / 0.15)")] + #[serial] fn drop_shadow_sizes_compose_into_filter( #[case] class: &str, #[case] offsets: &str, @@ -1359,6 +1381,7 @@ mod tests { ); } #[test] + #[serial] fn drop_shadow_without_a_size_none_colors_and_arbitrary_values() { expect( "drop-shadow", diff --git a/libs/extractor/src/tailwind_misc.rs b/libs/extractor/src/tailwind_misc.rs index 2b05db026..d4ef73a18 100644 --- a/libs/extractor/src/tailwind_misc.rs +++ b/libs/extractor/src/tailwind_misc.rs @@ -328,6 +328,7 @@ fn plain(name: &str) -> Option> { #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { use rstest::rstest; + use serial_test::serial; use crate::tailwind::declarations_of; @@ -365,6 +366,7 @@ mod tests { #[case("outline-double", &[("--tw-outline-style", "double"), ("outline-style", "double")])] #[case("outline-none", &[("--tw-outline-style", "none"), ("outline-style", "none")])] #[case("line-clamp-none", &[("overflow", "visible"), ("display", "block"), ("-webkit-box-orient", "horizontal"), ("-webkit-line-clamp", "unset")])] + #[serial] fn fixed_utilities(#[case] class: &str, #[case] expected: &[(&str, &str)]) { expect(class, expected); } @@ -373,6 +375,7 @@ mod tests { #[case("line-clamp-3", "3")] #[case("line-clamp-(--lines)", "var(--lines)")] #[case("line-clamp-[calc(1+1)]", "calc(1 + 1)")] + #[serial] fn line_clamp_clamps_to_a_number_of_lines(#[case] class: &str, #[case] lines: &str) { expect( class, @@ -386,6 +389,7 @@ mod tests { } #[test] + #[serial] fn text_decoration_offsets_and_outlines() { expect("underline-offset-4", &[("text-underline-offset", "4px")]); expect( @@ -451,6 +455,7 @@ mod tests { #[case("border-r-8", "border-right-style", "border-right-width", "8px")] #[case("border-b-[2px]", "border-bottom-style", "border-bottom-width", "2px")] #[case("border-l-2", "border-left-style", "border-left-width", "2px")] + #[serial] fn border_widths_draw_in_the_border_style( #[case] class: &str, #[case] style: &str, @@ -464,6 +469,7 @@ mod tests { } #[test] + #[serial] fn border_styles_set_the_style_the_widths_draw_in() { for style in ["solid", "dashed", "dotted", "double", "hidden", "none"] { expect( @@ -474,6 +480,7 @@ mod tests { } #[test] + #[serial] fn border_spacing_sets_both_axes_unless_one_is_named() { expect( "border-spacing-2", @@ -509,6 +516,7 @@ mod tests { } #[test] + #[serial] fn logical_sides_scroll_spacing_and_indent() { expect("start-4", &[("inset-inline-start", "1rem")]); expect("end-0", &[("inset-inline-end", "0px")]); @@ -574,6 +582,7 @@ mod tests { #[case("-indent")] #[case("-table-auto")] #[case("antialiased-")] + #[serial] fn unknown_utilities_stay_as_written(#[case] class: &str) { assert_eq!(declarations_of(class), None, "{class}"); } diff --git a/libs/extractor/src/tailwind_motion.rs b/libs/extractor/src/tailwind_motion.rs index 7f8ba011c..40330277d 100644 --- a/libs/extractor/src/tailwind_motion.rs +++ b/libs/extractor/src/tailwind_motion.rs @@ -160,6 +160,7 @@ pub fn keyframes_of(declarations: &[Declaration]) -> impl Iterator { /// offset of the code each is about pub errors: Vec<(u32, String)>, styled_imports: FxHashSet, + styled_jsx: 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 '...'`) @@ -318,6 +320,7 @@ impl<'a> DevupVisitor<'a> { util_imports: FxHashMap::default(), split_filename, styled_imports: FxHashSet::default(), + styled_jsx: None, stylex_imports: FxHashSet::default(), stylex_named_imports: FxHashMap::default(), global_style_components: FxHashSet::default(), @@ -1101,6 +1104,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } fn visit_program(&mut self, it: &mut Program<'a>) { + self.styled_jsx = StyledJsx::reserve(it); if self.binds_style_results(it) { self.style_values = crate::style_values::StyleValues::new( oxc_semantic::SemanticBuilder::new() @@ -1126,6 +1130,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { }); self.report_compiled_reads(it); } + if let Some(helper) = &self.styled_jsx + && let Some(binding) = helper.binding(&self.ast, it.source_type.is_commonjs()) + { + it.body.insert(0, binding); + } if !self.styles.is_empty() { for css_file in self.css_files.iter().rev() { it.body.insert( @@ -1240,6 +1249,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.split_filename.as_deref(), &self.imports, &attrs, + &mut self.styled_jsx, ); self.errors.extend(errors); self.styles.extend( diff --git a/libs/sheet/src/lib.rs b/libs/sheet/src/lib.rs index 9f78079ae..6fad7cb4e 100644 --- a/libs/sheet/src/lib.rs +++ b/libs/sheet/src/lib.rs @@ -3412,23 +3412,23 @@ mod tests { #[rstest] #[case( "hover:bg-red-500 active:bg-blue-500", - "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:active{background-color:#3B82F6}" + "@media(hover:hover){.c0:hover{background-color:oklch(63.7% .237 25.331)}}.c1:active{background-color:oklch(62.3% .214 259.815)}" )] #[case( "md:hover:bg-red-500 active:bg-blue-500", - ".c0:active{background-color:#3B82F6}@media(min-width:768px)and (hover:hover){.c1:hover{background-color:#EF4444}}" + ".c0:active{background-color:oklch(62.3% .214 259.815)}@media(min-width:768px)and (hover:hover){.c1:hover{background-color:oklch(63.7% .237 25.331)}}" )] #[case( "hover:bg-red-500 focus:bg-green-500 active:bg-blue-500", - "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:focus{background-color:#22C55E}.c2:active{background-color:#3B82F6}" + "@media(hover:hover){.c0:hover{background-color:oklch(63.7% .237 25.331)}}.c1:focus{background-color:oklch(72.3% .219 149.579)}.c2:active{background-color:oklch(62.3% .214 259.815)}" )] #[case( "hover:bg-red-500 disabled:bg-gray-500", - "@media(hover:hover){.c0:hover{background-color:#EF4444}}.c1:disabled{background-color:#6B7280}" + "@media(hover:hover){.c0:hover{background-color:oklch(63.7% .237 25.331)}}.c1:disabled{background-color:oklch(55.1% .027 264.364)}" )] #[case( "dark:hover:bg-red-500 active:bg-blue-500", - "@media(hover:hover){:root[data-theme=dark] .c0:hover{background-color:#EF4444}}.c1:active{background-color:#3B82F6}" + "@media(hover:hover){:root[data-theme=dark] .c0:hover{background-color:oklch(63.7% .237 25.331)}}.c1:active{background-color:oklch(62.3% .214 259.815)}" )] #[serial] fn tailwind_states_keep_css_order_when_hover_is_guarded(