From 4a6ee3a203be3e9a231f40e3ba5c8ad84e774ceb Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 03:29:15 +0900 Subject: [PATCH] fix(extractor): keep type-only imports type-only and read keyframes getName() Refs #689 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_compat_imports.json | 7 +++ bindings/devup-ui-wasm/src/lib.rs | 8 +-- libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/import_alias_visit.rs | 40 ++++++++++++++- libs/extractor/src/lib.rs | 45 ++++++++++++++-- libs/extractor/src/style_values.rs | 51 +++++++++++++++++++ libs/extractor/src/tailwind.rs | 4 +- libs/extractor/src/visit.rs | 1 + 8 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 .changepacks/changepack_log_compat_imports.json 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/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f3..0a2347ac2 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1551,7 +1551,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1951,7 +1951,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +1975,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2000,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb021..3b2346aed 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] 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 68981f36c..959d4b5ae 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -836,8 +836,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -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() { @@ -13598,7 +13635,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -18778,7 +18815,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", 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/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f5178..70643aa7c 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -284,7 +284,7 @@ pub struct TailwindClass { /// non-overlapping) but mutates the existing buffer instead of allocating a new /// `String`. `needle` must be non-empty. fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); + debug_assert_ne!(needle, ""); let mut search_from = 0; while let Some(rel) = haystack[search_from..].find(needle) { let at = search_from + rel; @@ -3934,7 +3934,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test] 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