diff --git a/.changepacks/changepack_log_compat_imports.json b/.changepacks/changepack_log_compat_imports.json new file mode 100644 index 000000000..a440aa053 --- /dev/null +++ b/.changepacks/changepack_log_compat_imports.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Rewriting styled-components, Emotion and vanilla-extract imports keeps type-only imports type-only: `import { type DefaultTheme, ThemeProvider }` now leaves `import type { DefaultTheme }` on the package instead of a value import that made it a runtime dependency, and an `import type` declaration is left as written. `spin.getName()` on a `keyframes()` binding becomes the generated keyframes name instead of a call on a string that threw at runtime", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 158d0f420..406ea5423 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -272,7 +272,9 @@ pub fn transform_import_aliases_with_edits<'a>( if let Statement::ImportDeclaration(import_decl) = stmt { let source_value = import_decl.source.value.as_str(); - if let Some(alias) = import_aliases.get(source_value) { + if let Some(alias) = import_aliases.get(source_value) + && !import_decl.import_kind.is_type() + { let span = import_decl.span; let new_import = generate_transformed_import(import_decl, alias, package, redirect_every_name); @@ -475,6 +477,7 @@ fn generate_transformed_import( let mut redirected = String::new(); let mut compat = String::new(); let mut retained = String::new(); + let mut types = String::new(); let mut retained_default = None; // Handle default specifier first (at most one in valid JS); only its @@ -510,6 +513,12 @@ fn generate_transformed_import( if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { let local = spec.local.name.as_str(); let imported = imported_name(&spec.imported); + // Types are erased, so they stay on the package without making it a + // runtime dependency + if spec.import_kind.is_type() { + push_specifier(&mut types, &imported, local); + continue; + } match redirect_target(source, &imported, redirect_every_name) { Some(target) => push_redirect(&mut redirected, &mut compat, target, local), None => push_specifier(&mut retained, &imported, local), @@ -558,6 +567,16 @@ fn generate_transformed_import( result.push_str(source); result.push_str("';"); } + if !types.is_empty() { + if !result.is_empty() { + result.push(' '); + } + result.push_str("import type { "); + result.push_str(&types); + result.push_str(" } from '"); + result.push_str(source); + result.push_str("';"); + } result } @@ -728,6 +747,25 @@ mod tests { )); } + // Types are erased, so a type-only import never becomes a runtime import + #[test] + fn test_type_only_imports_stay_type_only() { + let code = transform_import_aliases( + "import styled, { type DefaultTheme, ThemeProvider, type CSSObject as O } from 'styled-components'\nimport type { CSSProperties } from 'styled-components'", + "test.tsx", + "@devup-ui/react", + &styled_components_alias(), + ); + for expected in [ + "import type { DefaultTheme, CSSObject as O } from 'styled-components';", + "import type { CSSProperties } from 'styled-components'", + "import { ThemeProvider } from '@devup-ui/react/compat';", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + assert!(!code.contains("import { DefaultTheme"), "{code}"); + } + #[test] fn test_no_matching_alias() { assert_snapshot!(transform_import_aliases( diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index a5853d5b5..f94fd931b 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -11201,6 +11201,43 @@ let color = "red"; )); } + // styled-components' keyframes object gives its name through `getName()` + #[test] + #[serial] + fn test_keyframes_get_name() { + reset_class_map(); + reset_file_map(); + let code = extract( + "test.tsx", + "import {keyframes, css} from '@devup-ui/core' +const spin = keyframes({ from: { opacity: 0 } }) +const box = css({ color: 'red' }) +export const a = spin.getName() +export const b = spin.getName(1) +export const c = box.getName() +export const d = other.getName() +export const e = spin.toString()", + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap() + .code; + for expected in [ + "export const a = \"a\";", + "export const b = spin.getName(1);", + "export const c = box.getName();", + "export const d = other.getName();", + "export const e = spin.toString();", + ] { + assert!(code.contains(expected), "{expected}\n{code}"); + } + } + #[test] #[serial] fn test_keyframes_no_args() { diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 77dc8f569..7f2076f20 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -62,6 +62,26 @@ impl StyleValues { } } + /// `program` with each `name.getName()` on a `keyframes()` binding read as + /// the name it gives, as styled-components' keyframes object returns it + pub fn read_names<'a>(&self, ast: &AstBuilder<'a>, program: &mut oxc_ast::ast::Program<'a>) { + let Some(scoping) = self.scoping.as_ref() else { + return; + }; + if self + .values + .values() + .any(|value| matches!(value, StyleValue::Keyframes(_))) + { + Names { + ast, + scoping, + values: &self.values, + } + .visit_program(program); + } + } + fn reads<'s, 'a>(&'s self, ast: &'s AstBuilder<'a>) -> Option> { let scoping = self.scoping.as_ref()?; (!self.values.is_empty()).then_some(Reads { @@ -74,6 +94,37 @@ impl StyleValues { } } +struct Names<'s, 'a> { + ast: &'s AstBuilder<'a>, + scoping: &'s Scoping, + values: &'s FxHashMap, +} + +impl<'a> VisitMut<'a> for Names<'_, 'a> { + fn visit_expression(&mut self, it: &mut Expression<'a>) { + if let Expression::CallExpression(call) = it + && call.arguments.is_empty() + && let Expression::StaticMemberExpression(member) = &call.callee + && member.property.name == "getName" + && let Expression::Identifier(identifier) = &member.object + && let Some(StyleValue::Keyframes(name)) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| self.values.get(&symbol)) + { + *it = Expression::new_string_literal( + SPAN, + Str::from_in(name.as_str(), self.ast.allocator()), + None, + self.ast, + ); + return; + } + walk_mut::walk_expression(self, it); + } +} + struct Reads<'s, 'a> { ast: &'s AstBuilder<'a>, scoping: &'s Scoping, diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 93846123e..9640cf98c 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1069,6 +1069,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ); } walk_program(self, it); + self.style_values.read_names(&self.ast, it); if !self.compiled_names.is_empty() { // Aliases only the calls and elements the build compiled read; at // the top level nothing can shadow what they alias