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 e9c457607..7886517ea 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 { @@ -347,6 +349,7 @@ impl<'a> Reader<'_, 'a> { &format!("{}/compat", self.option.package), ), names: FxHashSet::default(), + scoping: &scoping, }; used.visit_program(program); crate::style_values::selectors(program, &mut |expression| { @@ -460,6 +463,29 @@ struct Used<'s> { css_prop: bool, css: CssTakers<'s>, names: FxHashSet, + scoping: &'s oxc_semantic::Scoping, +} + +impl Used<'_> { + fn retarget(&self, expression: &Expression<'_>) -> Option { + let root = match unwrap_syntax_only(expression) { + Expression::Identifier(identifier) => identifier, + Expression::StaticMemberExpression(member) => { + let Expression::Identifier(identifier) = &member.object else { + return None; + }; + identifier + } + _ => return None, + }; + let symbol = self + .scoping + .get_reference(root.reference_id.get()?) + .symbol_id()?; + (self.scoping.get_root_binding(root.name) == Some(symbol)) + .then(|| written_name(expression)) + .flatten() + } } impl<'a> Visit<'a> for Used<'_> { @@ -473,7 +499,7 @@ impl<'a> Visit<'a> for Used<'_> { Expression::StaticMemberExpression(member) if member.property.name == "withComponent" => { - written_name(&member.object) + self.retarget(&member.object) } _ if self.css.property(call, |_| true).is_some() => call .arguments @@ -503,6 +529,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..47c9ff0b7 --- /dev/null +++ b/libs/extractor/src/imported_styled/relevance.rs @@ -0,0 +1,54 @@ +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::Visit; +use rustc_hash::FxHashSet; + +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(program) = crate::parsing::parse(allocator, filename, code) else { + return Reading::default(); + }; + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let mut used = Used { + styled: FxHashSet::default(), + css_prop: false, + css: CssTakers::new(&program, &scoping, CssProp::Off, ""), + names: FxHashSet::default(), + scoping: &scoping, + }; + used.visit_program(&program); + if used.names.is_empty() { + return Reading::default(); + } + let mut reading = super::read( + &AstBuilder::new(allocator), + &program, + filename, + option, + resolver, + CssProp::Off, + ); + reading + .components + .retain(|name, component| used.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..1d779d0b5 --- /dev/null +++ b/libs/extractor/src/imported_styled/tests/relevance_tests.rs @@ -0,0 +1,110 @@ +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(), + "./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 * 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 + ); +} 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..c134d8731 --- /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 \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"a\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--b d\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--c\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\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..d6a8076bc --- /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 \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as UI from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"a\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a a--b d\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--c\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\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..50ef58680 --- /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 \"@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 = \"a\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"c--a a--b d\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--c\": (p=>p.big?`20px`:`10px`)(rest) },\n\t...style\n}} />)({\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..94507b154 --- /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 \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Dyn } from \"./base\";\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"a\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"b--a 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}));\n\n[\"/src/base.tsx\"]" diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 81881f1b2..f972737e0 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -438,6 +438,13 @@ 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 allocator = Allocator::default(); + 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(), @@ -450,6 +457,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) { @@ -587,8 +595,6 @@ fn extract_source( css_files.push(main_css_path(&option.css_dir)); } css_files.push(css_file.clone()); - let allocator = Allocator::default(); - 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); @@ -612,7 +618,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, @@ -645,6 +653,7 @@ fn extract_source( visitor.reserve_styled(styled_capability); visitor.visit_program(&mut program); if !has_relevant_import + && !has_imported_retarget && alias_edits.is_empty() && namespace_edits.is_empty() && !visitor.compiled_css_prop @@ -1022,7 +1031,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 4e0eb49a4..eb29b1cbd 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1151,6 +1151,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 039e205d9..e88b05b2d 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' @@ -106,6 +108,101 @@ 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(`retargets an imported dynamic component without a styling import (${singleCss}, ${!!trigger})`, async () => { + // Given the same origin and resolver as the composing consumer. + const source = `${trigger}import { Dyn } from '@/Base'\nexport const Retargeted = Dyn.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 base = moduleUrl( + compile( + 'src/Base.tsx', + readFileSync(join(root, 'src/Base.tsx'), 'utf8'), + ).code, + ) + + // 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)}`, + ), + ) + ) + const ref = { current: null } + for (const big of [false, true]) { + const element = compiled.Retargeted.render( + { big, children: 'child' }, + ref, + ) + + // Then the retargeted element retains its ref, child and dynamic CSS. + expect(element.type).toBe('a') + expect(element.props.ref).toBe(ref) + expect(element.props.children).toBe('child') + const fileNumber = /devup-ui-(\d+)\.css$/.exec( + emitted.cssFile ?? '', + )?.[1] + const css = wasm.getCss(fileNumber ? Number(fileNumber) : null, false) + const declarations = element.props.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.props.style[variable], + ) + expect(fontSize).toBe(big ? '20px' : '10px') + } + expect(relevant).toBe(true) + }) + } + } + it('selects a component by the class its own module gives it', () => { write( 'src/App.tsx',