diff --git a/.changepacks/changepack_log_imported_retarget_relevance.json b/.changepacks/changepack_log_imported_retarget_relevance.json new file mode 100644 index 000000000..cb951ec9f --- /dev/null +++ b/.changepacks/changepack_log_imported_retarget_relevance.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/next-plugin/package.json": "Patch" + }, + "note": "Compile known imported styled withComponent calls without requiring an unused styling-package import; preserve semantic import boundaries and share relevance with plugin filtering. No new build errors.", + "date": "2026-10-24T00:00:00.000Z" +} diff --git a/libs/extractor/src/imported_styled.rs b/libs/extractor/src/imported_styled.rs index d3e75afcb..1df046699 100644 --- a/libs/extractor/src/imported_styled.rs +++ b/libs/extractor/src/imported_styled.rs @@ -23,6 +23,8 @@ use crate::utils::{is_vanilla_extract_file, unwrap_syntax_only}; use crate::visit::DevupVisitor; use crate::{ExtractOption, ModuleResolver}; +pub(crate) mod relevance; + /// What a module exports, which marks the components other files select #[derive(Default)] pub(crate) struct Exports { @@ -743,6 +745,7 @@ impl<'a> Visit<'a> for Used<'_, '_> { #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { + mod relevance_tests; use std::collections::{BTreeSet, HashMap}; use insta::assert_debug_snapshot; diff --git a/libs/extractor/src/imported_styled/relevance.rs b/libs/extractor/src/imported_styled/relevance.rs new file mode 100644 index 000000000..952bbeba2 --- /dev/null +++ b/libs/extractor/src/imported_styled/relevance.rs @@ -0,0 +1,65 @@ +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::Visit; +use rustc_hash::FxHashSet; +use std::rc::Rc; + +use super::{Reading, Used}; +use crate::css_prop::{CssProp, CssTakers}; +use crate::{ExtractOption, ModuleResolver}; + +pub(crate) fn candidate(code: &str) -> bool { + code.contains("withComponent") && code.contains("import") +} + +pub(crate) fn read<'a>( + allocator: &'a Allocator, + filename: &str, + code: &'a str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, +) -> Reading<'a> { + if resolver.is_none() || !candidate(code) { + return Reading::default(); + } + let Ok(source_type) = crate::parsing::source_type(filename) else { + return Reading::default(); + }; + let Ok(program) = crate::parsing::checked(allocator, code, source_type) else { + return Reading::default(); + }; + let scoping = Rc::new( + oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(), + ); + let names = { + let mut used = Used { + styled: FxHashSet::default(), + css_prop: false, + css_takers: CssTakers::new(&program, &scoping, CssProp::Off, ""), + names: FxHashSet::default(), + scoping: &scoping, + }; + used.visit_program(&program); + used.names + }; + if names.is_empty() { + return Reading::default(); + } + let mut scoping = Some(scoping); + let mut reading = super::read( + &AstBuilder::new(allocator), + &program, + filename, + option, + resolver, + CssProp::Off, + &mut scoping, + ); + reading + .components + .retain(|name, component| names.contains(name) && component.definition.is_some()); + reading +} diff --git a/libs/extractor/src/imported_styled/tests/relevance_tests.rs b/libs/extractor/src/imported_styled/tests/relevance_tests.rs new file mode 100644 index 000000000..7fcfde8e5 --- /dev/null +++ b/libs/extractor/src/imported_styled/tests/relevance_tests.rs @@ -0,0 +1,216 @@ +use super::*; +use crate::has_devup_ui_with; +use rstest::rstest; + +fn resolve(specifier: &str, _: &str) -> Option { + let code = match specifier { + "./base" => base_module(), + "./forward" => "export { Dyn } from './base';".to_string(), + "./types" => "export type { Dyn } from './base';".to_string(), + "./object" => "export const Dyn = { withComponent: (tag) => tag };".to_string(), + "./unreadable" => { + "import styled from '@emotion/styled'; export const Dyn = styled(unknown)`color:red;`;" + .to_string() + } + _ => return None, + }; + Some(ResolvedModule { + path: format!("/src/{}.tsx", specifier.trim_start_matches("./")), + code, + }) +} + +#[rstest] +#[case( + "named", + "import { Dyn } from './base'; export const A = Dyn.withComponent('a');" +)] +#[case( + "reexport", + "import { Dyn } from './forward'; export const A = Dyn.withComponent('a');" +)] +#[case( + "namespace", + "import * as UI from './base'; export const A = UI.Dyn.withComponent('a');" +)] +#[case( + "trigger", + "import { styled } from '@devup-ui/react'; import { Dyn } from './base'; export const A = Dyn.withComponent('a');" +)] +#[serial] +fn imported_retargets_are_relevant_without_package_imports(#[case] name: &str, #[case] code: &str) { + reset(); + let option = option(); + assert!(has_devup_ui_with( + "/src/app.tsx", + code, + &option.package, + &option.import_aliases, + Some(&resolve), + )); + let output = extract_with_modules("/src/app.tsx", code, option, false, &resolve).unwrap(); + assert_ne!(output.css_file, None); + assert_debug_snapshot!(name, format!("{}\n{:?}", output.code, output.dependencies)); +} + +#[rstest] +#[case( + "import { Dyn } from './base'; export function retarget(Dyn) { return Dyn.withComponent('a'); }" +)] +#[case( + "import * as UI from './base'; export function retarget(UI) { return UI.Dyn.withComponent('a'); }" +)] +#[case("import type { Dyn } from './base'; export const A = Dyn.withComponent('a');")] +#[case("import { type Dyn } from './base'; export const A = Dyn.withComponent('a');")] +#[case("import { Dyn } from './object'; export const A = Dyn.withComponent('a');")] +#[case("import { Dyn } from './missing'; export const A = Dyn.withComponent('a');")] +#[case("import { Dyn } from './unreadable'; export const A = Dyn.withComponent('a');")] +#[case("import { Missing } from './base'; export const A = Missing.withComponent('a');")] +#[case("import { Dyn } from './types'; export const A = Dyn.withComponent('a');")] +#[case( + "import './base'; const Dyn = { withComponent: tag => tag }; export const A = Dyn.withComponent('a');" +)] +#[case("import * as UI from './base'; export const A = UI[key].withComponent('a');")] +#[case("import * as UI from './base'; export const A = UI.more.Dyn.withComponent('a');")] +#[case("import './base'; export const A = unknown.withComponent('a');")] +#[case("import './base'; export const A = factory().withComponent('a');")] +#[case("import { Dyn } from './base'; export const A = Dyn;")] +#[case("const A = 'withComponent';")] +#[case("import { Dyn } from './base'; // withComponent\nexport const A = Dyn;")] +#[case("import { Dyn } from './base'; export const A = ; // withComponent")] +#[serial] +fn unrelated_retargets_remain_unchanged(#[case] code: &str) { + reset(); + let option = option(); + assert!(!has_devup_ui_with( + "/src/app.tsx", + code, + &option.package, + &option.import_aliases, + Some(&resolve), + )); + let output = extract_with_modules("/src/app.tsx", code, option, false, &resolve).unwrap(); + assert_eq!(output.code, code); + assert_eq!(output.dependencies, Vec::::new()); + assert_eq!(output.css_file, None); +} + +#[test] +#[serial] +fn retargets_without_a_resolver_remain_unchanged() { + let code = "import { Dyn } from './base'; export const A = Dyn.withComponent('a');"; + let option = option(); + assert!(!has_devup_ui_with( + "/src/app.tsx", + code, + &option.package, + &option.import_aliases, + None, + )); + assert_eq!( + crate::extract_without_source_map("/src/app.tsx", code, option) + .unwrap() + .code, + code + ); +} + +#[rstest] +#[case("")] +#[case("import { styled } from '@devup-ui/react';")] +#[serial] +fn literal_namespace_retargets_match_dot_with_and_without_trigger(#[case] trigger: &str) { + let dot = format!( + "{trigger}import * as UI from './base'; export const A = (UI.Dyn).withComponent('a');" + ); + reset(); + let expected = extract_with_modules("/src/app.tsx", &dot, option(), false, &resolve).unwrap(); + let computed = dot.replace("UI.Dyn", "(UI['Dyn'] as typeof UI.Dyn)!"); + let option = option(); + assert!(has_devup_ui_with( + "/src/app.tsx", + &dot, + &option.package, + &option.import_aliases, + Some(&resolve), + )); + assert!(has_devup_ui_with( + "/src/app.tsx", + &computed, + &option.package, + &option.import_aliases, + Some(&resolve), + )); + reset(); + let actual = extract_with_modules("/src/app.tsx", &computed, option, false, &resolve).unwrap(); + // Staged rendering keeps the authored receiver. Normalize only that one + // expression, not imports, forwarding, target selection or render stages. + let receiver = "((UI[\"Dyn\"] as typeof UI.Dyn)!)"; + assert_eq!(actual.code.matches(receiver).count(), 1); + assert_eq!(expected.code.matches("(UI.Dyn)").count(), 1); + assert_eq!(actual.code.replace(receiver, "(UI.Dyn)"), expected.code); + assert!(!actual.code.contains(".withComponent(")); + assert!(actual.code.contains("import * as UI from \"./base\";")); + assert_eq!(actual.styles, expected.styles); + assert_eq!(actual.dependencies, expected.dependencies); + assert_eq!(actual.dependencies, ["/src/base.tsx"]); + assert!(actual.css_file.is_some()); +} + +#[rstest] +#[case("import { Dyn } from './base';", "Dyn", "Dyn")] +#[case("import * as UI from './base';", "UI", "UI.Dyn")] +#[case("import * as UI from './base';", "UI", "UI['Dyn']")] +#[serial] +fn admitted_retargets_preserve_shadowed_occurrences( + #[case] import: &str, + #[case] shadow: &str, + #[case] expression: &str, +) { + let code = format!( + "{import} export function untouched({shadow}) {{ return {expression}.withComponent('section'); }} export const A = {expression}.withComponent('a');" + ); + let option = option(); + assert!(has_devup_ui_with( + "/src/app.tsx", + &code, + &option.package, + &option.import_aliases, + Some(&resolve), + )); + reset(); + let output = extract_with_modules("/src/app.tsx", &code, option, false, &resolve).unwrap(); + assert_eq!(output.code.matches(".withComponent(").count(), 1); + let receiver = expression.replace('\'', "\""); + assert!(output.code.contains(&format!( + "export function untouched({shadow}) {{\n\treturn {receiver}.withComponent(\"section\");\n}}" + ))); + assert!(output.code.contains(&import.replace('\'', "\""))); + // The two receivers remain, plus any named import and parameter bindings. + // The admitted call must not copy the origin's dynamic callback. + assert_eq!( + output.code.matches(&receiver).count(), + 2 + import.matches(expression).count() + shadow.matches(expression).count() + ); + assert!(!output.code.contains("20px")); + assert!(output.css_file.is_some()); + assert_eq!(output.dependencies, ["/src/base.tsx"]); +} + +#[test] +#[serial] +fn unsupported_retarget_source_types_remain_irrelevant() { + let allocator = oxc_allocator::Allocator::default(); + let source = "import { Dyn } from './base'; export const A = Dyn.withComponent('a');"; + + let reading = crate::imported_styled::relevance::read( + &allocator, + "/src/app.unknown", + source, + &option(), + Some(&resolve), + ); + + assert_eq!(reading.components.len(), 0); + assert_eq!(reading.dependencies.len(), 0); +} diff --git a/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__named.snap b/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__named.snap new file mode 100644 index 000000000..802c4bd9d --- /dev/null +++ b/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__named.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/imported_styled/tests/relevance_tests.rs +expression: "format!(\"{}\\n{:?}\", output.code, output.dependencies)" +--- +"import { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=()=>(\"a\");const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[];const __devupRecord6=[[__devupRecord4[0][0]],__devupRecord4[1],[__devupRecord4[2],\"b--a\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"big\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(Dyn);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n[\"/src/base.tsx\"]" diff --git a/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__namespace.snap b/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__namespace.snap new file mode 100644 index 000000000..bf8eeb61a --- /dev/null +++ b/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__namespace.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/imported_styled/tests/relevance_tests.rs +expression: "format!(\"{}\\n{:?}\", output.code, output.dependencies)" +--- +"import { forwardRef as __devupForwardRef } from \"react\";\nimport * as UI from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=()=>(\"a\");const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[];const __devupRecord6=[[__devupRecord4[0][0]],__devupRecord4[1],[__devupRecord4[2],\"b--a\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"big\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(UI.Dyn);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n[\"/src/base.tsx\"]" diff --git a/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__reexport.snap b/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__reexport.snap new file mode 100644 index 000000000..41d359351 --- /dev/null +++ b/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__reexport.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/imported_styled/tests/relevance_tests.rs +expression: "format!(\"{}\\n{:?}\", output.code, output.dependencies)" +--- +"import { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn } from \"./forward\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=()=>(\"a\");const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[];const __devupRecord6=[[__devupRecord4[0][0]],__devupRecord4[1],[__devupRecord4[2],\"c--a\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"big\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(Dyn);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n[\"/src/base.tsx\", \"/src/forward.tsx\"]" diff --git a/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__trigger.snap b/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__trigger.snap new file mode 100644 index 000000000..802c4bd9d --- /dev/null +++ b/libs/extractor/src/imported_styled/tests/snapshots/extractor__imported_styled__tests__relevance_tests__trigger.snap @@ -0,0 +1,5 @@ +--- +source: libs/extractor/src/imported_styled/tests/relevance_tests.rs +expression: "format!(\"{}\\n{:?}\", output.code, output.dependencies)" +--- +"import { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (__devupRecord0=>{const {__devupCss0:__devupRecord1,__devupForwarded0:__devupRecord12,...__devupRecord2}=__devupRecord0;const __devupRecord14=()=>(\"a\");const __devupRecord15=(__devupRecord1&&__devupRecord1[3])??__devupRecord14;const __devupRecord13=__devupRecord1?__devupRecord1[2]:false;const __devupRecord8=(__devupRecord2,__devupRecord7,__devupRecord13)=>{const __devupRecord8=(__devupRecord2);return __devupRecord1?(0,__devupRecord1[0])(__devupRecord8,__devupRecord7,__devupRecord13):__devupRecord7(__devupRecord8);};const __devupRecord9=(__devupRecord3,__devupRecord4)=>{const {style,className,as:DevupAs=__devupRecord4[3],forwardedAs}=__devupRecord3;const rest=__devupRecord4[5];const __devupRecord5=[];const __devupRecord6=[[__devupRecord4[0][0]],__devupRecord4[1],[__devupRecord4[2],\"b--a\",].filter(Boolean).join(' '),__devupRecord4[3],__devupRecord4[4],rest];return __devupRecord1?(0,__devupRecord1[1])(__devupRecord3,__devupRecord6):__devupRecord6[4]({...Object.fromEntries(Object.entries((({ \"big\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\")),as:__devupRecord6[3],forwardedAs:__devupRecord3.forwardedAs,className:(__devupRecord6[0].length||__devupRecord6[2]!==void 0)?[...__devupRecord6[0],__devupRecord6[2],__devupRecord3.className].filter(Boolean).join(' '):__devupRecord3.className,style:__devupRecord6[1]===void 0?__devupRecord3.style:{...__devupRecord6[1],...__devupRecord3.style}});};return ((DevupStyled) => ())(Dyn);})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n[\"/src/base.tsx\"]" diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index cf1b89d95..ba013c429 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -515,6 +515,12 @@ fn extract_source( // gives an element a `css` prop, or had an import rewritten let has_relevant_import = transformed_code.contains(option.package.as_str()) || transformed_code.contains(STYLEX_PACKAGE); + let retargeted = if !has_relevant_import && css_prop == css_prop::CssProp::Off { + imported_styled::relevance::read(&allocator, filename, &transformed_code, &option, resolver) + } else { + imported_styled::Reading::default() + }; + let has_imported_retarget = !retargeted.components.is_empty(); let unchanged = || ExtractOutput { styles: FxHashSet::default(), code: code.to_string(), @@ -527,6 +533,7 @@ fn extract_source( .iter() .any(|(start, end, _)| &namespace_code[*start..*end] == "@emotion/react"); if !has_relevant_import + && !has_imported_retarget && css_prop == css_prop::CssProp::Off && (alias_edits.is_empty() || pragma_rewritten) { @@ -784,7 +791,9 @@ fn extract_source( failures.dedup(); return Err(failures.join("\n").into()); } - let imported = if processed_code.is_none() { + let imported = if has_imported_retarget { + retargeted + } else if processed_code.is_none() { imported_styled::read( &oxc_ast::builder::AstBuilder::new(&allocator), &program, @@ -823,6 +832,7 @@ fn extract_source( return Err(visitor.own_failures.join("\n").into()); } if !has_relevant_import + && !has_imported_retarget && alias_edits.is_empty() && namespace_edits.is_empty() && !visitor.compiled_css_prop @@ -1210,7 +1220,18 @@ pub fn has_devup_ui_with( import_aliases, ); !aliased.edits.is_empty() || aliased.css_prop != css_prop::CssProp::Off - }) + } + || (imported_styled::relevance::candidate(code) && { + let allocator = Allocator::default(); + let option = ExtractOption { + package: package.to_string(), + import_aliases: import_aliases.clone(), + ..ExtractOption::default() + }; + !imported_styled::relevance::read(&allocator, filename, code, &option, resolver) + .components + .is_empty() + })) } #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 3420566af..5e04e721b 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1409,6 +1409,7 @@ impl<'a> DevupVisitor<'a> { || import.source.value == "@stylexjs/stylex") }); (imports_api + || !self.imported_styled.is_empty() || self.css_prop != CssProp::Off || crate::scope::requires(program, &self.package)) .then(|| { diff --git a/packages/next-plugin/src/__tests__/imported-styled.test.ts b/packages/next-plugin/src/__tests__/imported-styled.test.ts index 763c18b17..9ed93501a 100644 --- a/packages/next-plugin/src/__tests__/imported-styled.test.ts +++ b/packages/next-plugin/src/__tests__/imported-styled.test.ts @@ -5,8 +5,10 @@ import { rmSync, writeFileSync, } from 'node:fs' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' import * as wasm from '@devup-ui/wasm' import { afterEach, beforeEach, describe, expect, it } from 'bun:test' @@ -112,6 +114,160 @@ describe('styled components another module defines', () => { expect(wasm.getCss(null, false)).toContain('color:green') }) + for (const singleCss of [false, true]) { + for (const trigger of ['', "import { styled } from '@devup-ui/react'\n"]) { + it.each([ + ["import { Dyn } from '@/Base'", 'Dyn', 'Dyn'], + ["import * as UI from '@/Base'", 'UI', 'UI.Dyn'], + [ + "import * as UI from '@/Base'", + 'UI', + "((UI['Dyn'] as typeof UI.Dyn)!)", + ], + ])( + `retargets an imported dynamic component with a shadow (${singleCss}, ${!!trigger}, %s)`, + async (imported, shadow, receiver) => { + // Given the same origin and resolver as the composing consumer. + const source = `${trigger}${imported}\nexport function untouched(${shadow}) { return ${receiver}.withComponent('section') }\nexport const Retargeted = ${receiver}.withComponent('a')\n` + const relevant = wasm.hasDevupUI( + 'src/Retargeted.tsx', + source, + '@devup-ui/react', + { '@emotion/react': null, '@emotion/styled': 'styled' }, + ) + const compile = (file: string, code: string) => + wasm.codeExtractWithoutSourceMap( + file, + code, + '@devup-ui/react', + '@devup-ui/react', + singleCss, + false, + false, + { '@emotion/react': null, '@emotion/styled': 'styled' }, + ) + const transpiler = new Bun.Transpiler({ + loader: 'tsx', + tsconfig: { compilerOptions: { jsx: 'react' } }, + }) + const preload = createRequire(import.meta.url).resolve( + 'bun-test-env-dom', + ) + const testing = createRequire(preload).resolve( + '@testing-library/react', + ) + const react = pathToFileURL( + createRequire(testing).resolve('react'), + ).href + const moduleUrl = (code: string) => { + const javascript = transpiler + .transformSync( + code.replace(/^import ["'][^"']+\.css["'];?\s*$/gm, ''), + ) + .replace(/from ["']react["']/g, `from ${JSON.stringify(react)}`) + return `data:text/javascript;base64,${Buffer.from(`import * as React from ${JSON.stringify(react)};\n${javascript}`).toString('base64')}` + } + const origin = compile( + 'src/Base.tsx', + readFileSync(join(root, 'src/Base.tsx'), 'utf8'), + ) + const base = moduleUrl(origin.code) + const original = await import(base) + + // When the complete emitted consumer module is evaluated and rendered. + const emitted = compile('src/Retargeted.tsx', source) + const compiled = await import( + moduleUrl( + emitted.code.replace( + /from ["']@\/Base["']/g, + `from ${JSON.stringify(base)}`, + ), + ) + ) + expect(emitted.dependencies).toEqual(['src/Base.tsx']) + expect(emitted.code.match(/\.withComponent\(/g)).toHaveLength(1) + const calls: string[] = [] + const sentinel = { + withComponent: (tag: string) => { + calls.push(tag) + return 'shadow-result' + }, + } + expect( + compiled.untouched(shadow === 'Dyn' ? sentinel : { Dyn: sentinel }), + ).toBe('shadow-result') + expect(calls).toEqual(['section']) + const React = await import(react) + const { render } = await import(pathToFileURL(testing).href) + const css = [origin.cssFile, emitted.cssFile] + .map((file) => { + const number = /devup-ui-(\d+)\.css$/.exec(file ?? '')?.[1] + return wasm.getCss(number ? Number(number) : null, false) + }) + .join('\n') + const ref = { current: null } + for (const [Component, tag, big] of [ + [original.Base, 'DIV', undefined], + [original.Dyn, 'BUTTON', false], + [original.Dyn, 'BUTTON', true], + [compiled.Retargeted, 'A', false], + [compiled.Retargeted, 'A', true], + ] as const) { + const rendered = render( + React.createElement( + Component, + { + ...(big === undefined ? {} : { big }), + ref, + className: 'caller', + style: { opacity: 0.5 }, + }, + 'child', + ), + ) + try { + // Then real React reaches the final host through origin-owned stages. + const element = rendered.container.firstElementChild + expect(element.tagName).toBe(tag) + expect(ref.current).toBe(element) + expect(element.textContent).toBe('child') + expect( + element.className + .split(' ') + .filter((name: string) => name === 'caller'), + ).toHaveLength(1) + expect(element.style.opacity).toBe('0.5') + const declarations = element.className + .split(' ') + .flatMap((name: string) => + [ + ...css.matchAll(new RegExp(`\\.${name}\\{([^}]+)\\}`, 'g')), + ].map((match) => match[1]), + ) + .join(';') + const fontSize = /font-size:([^;}]+)/ + .exec(declarations)?.[1] + ?.replace( + /var\((--[\w-]+)\)/g, + (_match: string, variable: string) => + element.style.getPropertyValue(variable), + ) + if (big === undefined) { + expect(declarations).toMatch(/(?:^|;)color:red(?:;|$)/) + expect(declarations).toMatch(/(?:^|;)padding:4px(?:;|$)/) + } else { + expect(fontSize).toBe(big ? '20px' : '10px') + } + } finally { + rendered.unmount() + } + } + expect(relevant).toBe(true) + }, + ) + } + } + it('selects a component by the class its own module gives it', () => { write( 'src/App.tsx',