Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_compat_imports.json
Original file line number Diff line number Diff line change
@@ -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"
}
40 changes: 39 additions & 1 deletion libs/extractor/src/import_alias_visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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(
Expand Down
37 changes: 37 additions & 0 deletions libs/extractor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
51 changes: 51 additions & 0 deletions libs/extractor/src/style_values.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Reads<'s, 'a>> {
let scoping = self.scoping.as_ref()?;
(!self.values.is_empty()).then_some(Reads {
Expand All @@ -74,6 +94,37 @@ impl StyleValues {
}
}

struct Names<'s, 'a> {
ast: &'s AstBuilder<'a>,
scoping: &'s Scoping,
values: &'s FxHashMap<SymbolId, StyleValue>,
}

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,
Expand Down
1 change: 1 addition & 0 deletions libs/extractor/src/visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading