diff --git a/.changepacks/changepack_log_vanilla_extract_loads.json b/.changepacks/changepack_log_vanilla_extract_loads.json new file mode 100644 index 000000000..efb1ce49b --- /dev/null +++ b/.changepacks/changepack_log_vanilla_extract_loads.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "bindings/devup-ui-binding/package.json": "Patch" + }, + "note": "vanilla-extract code Devup UI does not compile is a located build error however it is loaded, instead of slipping past the check on import declarations. New build errors while `@vanilla-extract/css` is aliased: `@vanilla-extract/recipes` and `@vanilla-extract/sprinkles` loaded by `export … from`, `export *`, `require()`, `import x = require()` or `import()` (an import already was); `@vanilla-extract/dynamic` loaded in any of these ways; and `globalKeyframes` imported from `@vanilla-extract/css`, which failed with a bare `TypeError: not a callable function`", + "date": "2026-11-15T00:00:00.000Z" +} diff --git a/apps/landing/src/app/(detail)/docs/limitations/page.mdx b/apps/landing/src/app/(detail)/docs/limitations/page.mdx index c92635565..f31e98051 100644 --- a/apps/landing/src/app/(detail)/docs/limitations/page.mdx +++ b/apps/landing/src/app/(detail)/docs/limitations/page.mdx @@ -83,7 +83,8 @@ A dynamic value such as `width` becomes a CSS variable set in `style`, so a comp | `@emotion/css` `cache`, `flush`, `hydrate`, `merge`, `sheet` | Stay on `@emotion/css`; they work on styles inserted at runtime | | `ThemeProvider` | Renders a `display: contents` wrapper that sets the theme's CSS variables | | `jest-styled-components`, `@emotion/jest`, React Native targets | Not supported | -| vanilla-extract `recipes`, `sprinkles` | A build error while `@vanilla-extract/css` is aliased; use `css()` or the vanilla-extract plugin | +| vanilla-extract `recipes`, `sprinkles` | A build error however they load (import, re-export, `require()`, `import()`) while aliased | +| vanilla-extract `dynamic`, `globalKeyframes` | A build error while `@vanilla-extract/css` is aliased; use a style prop, or `keyframes()` | ## Known limitations diff --git a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx index 8645bf311..c9d38a73e 100644 --- a/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/vanilla-extract/page.mdx @@ -169,3 +169,7 @@ Moving those calls into a `.css.ts` file — where vanilla-extract wants them an A namespace stands for many named exports whose Devup UI counterparts are renamed (`style` → `css`), which a namespace access cannot express, so it is left alone: Use named imports to get the rewrite. + +## Unsupported packages and APIs + +While `@vanilla-extract/css` is aliased, `@vanilla-extract/recipes`, `@vanilla-extract/sprinkles` and `@vanilla-extract/dynamic` are build errors however a file loads them — an import, `export … from`, `export *`, `require()` or `import()` — since what they generate or assign would never reach the stylesheet. `globalKeyframes()` is a build error too: Devup UI names every keyframes itself, so declare them with `keyframes()` and use the name it returns. To keep any of these, set `importAliases: { '@vanilla-extract/css': false }` and build with the vanilla-extract plugin. diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 381c175be..08710f61e 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -18,9 +18,9 @@ use crate::utils::{ use oxc_allocator::Allocator; use oxc_ast::ast::{ Argument, ArrowFunctionBody, BindingIdentifier, CallExpression, Expression, - IdentifierReference, ImportDeclarationSpecifier, JSXAttributeItem, JSXAttributeValue, - JSXElement, JSXElementName, JSXOpeningElement, LogicalOperator, ModuleExportName, - ObjectPropertyKind, Statement, + IdentifierReference, ImportDeclaration, ImportDeclarationSpecifier, JSXAttributeItem, + JSXAttributeValue, JSXElement, JSXElementName, JSXOpeningElement, LogicalOperator, + ModuleExportName, ObjectPropertyKind, Statement, }; use oxc_ast_visit::{ Visit, @@ -500,38 +500,76 @@ pub struct Aliased<'a> { /// read numbers in as `px` lengths pub type PxMarks = rustc_hash::FxHashSet<(u32, u32)>; -/// Packages generating CSS on top of vanilla-extract's, which Devup UI does not +/// Packages working on top of vanilla-extract's, which Devup UI does not /// compile -const VANILLA_EXTRACT_COMPANIONS: [&str; 2] = - ["@vanilla-extract/recipes", "@vanilla-extract/sprinkles"]; - -/// The value imports of vanilla-extract's companion packages, as errors at -/// their offsets: while `@vanilla-extract/css` compiles to Devup UI, the -/// rules they generate would never reach the stylesheet +const VANILLA_EXTRACT_COMPANIONS: [&str; 3] = [ + "@vanilla-extract/recipes", + "@vanilla-extract/sprinkles", + VANILLA_EXTRACT_DYNAMIC, +]; + +/// vanilla-extract's package assigning variables at runtime +const VANILLA_EXTRACT_DYNAMIC: &str = "@vanilla-extract/dynamic"; + +/// Where code loads vanilla-extract's companion packages, by an import, a +/// re-export, `require()` or `import()`, and imports `globalKeyframes`, as +/// errors at their offsets: while `@vanilla-extract/css` compiles to Devup UI, +/// none of them reaches the stylesheet pub fn companion_errors(code: &str, filename: &str) -> Vec<(u32, String)> { if !VANILLA_EXTRACT_COMPANIONS .iter() .any(|package| code.contains(package)) + && !code.contains("globalKeyframes") { return Vec::new(); } let allocator = Allocator::default(); let source_type = crate::parsing::source_type(filename).unwrap_or_default(); let program = Parser::new(&allocator, code, source_type).parse().program; - program - .body + let mut errors: Vec<(u32, String)> = + crate::module_loads::module_loads(&program, &VANILLA_EXTRACT_COMPANIONS) + .into_iter() + .map(|(offset, package)| (offset, companion_error(package))) + .collect(); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement + && import.source.value == "@vanilla-extract/css" + { + errors.extend(global_keyframes_imports(import)); + } + } + errors.sort_unstable(); + errors +} + +fn companion_error(package: &str) -> String { + if package == VANILLA_EXTRACT_DYNAMIC { + return format!( + "`{VANILLA_EXTRACT_DYNAMIC}` assigns variables at runtime, which Devup UI does not compile, as nothing may stay at runtime. Fix: pass the value as a style prop, which Devup UI binds to a CSS variable, or set `importAliases: {{ '@vanilla-extract/css': false }}` and build with the vanilla-extract plugin" + ); + } + format!( + "`{package}` generates CSS that Devup UI does not compile, so with the `@vanilla-extract/css` alias its rules would never reach the stylesheet. Fix: write the styles with `css()`, or set `importAliases: {{ '@vanilla-extract/css': false }}` and build with the vanilla-extract plugin" + ) +} + +/// The value imports of `globalKeyframes` in `import_decl`, as errors at their +/// specifiers: Devup UI names keyframes itself, so it has no keyframes of a +/// global name to compile them to +fn global_keyframes_imports(import_decl: &ImportDeclaration<'_>) -> Vec<(u32, String)> { + import_decl + .specifiers .iter() - .filter_map(|statement| match statement { - Statement::ImportDeclaration(import) - if !import.import_kind.is_type() - && VANILLA_EXTRACT_COMPANIONS.iter().any(|name| import.source.value == *name) => + .flatten() + .filter_map(|specifier| match specifier { + ImportDeclarationSpecifier::ImportSpecifier(spec) + if !import_decl.import_kind.is_type() + && !spec.import_kind.is_type() + && imported_name(&spec.imported) == "globalKeyframes" => { Some(( - import.span.start, - format!( - "`{}` generates CSS that Devup UI does not compile, so with the `@vanilla-extract/css` alias its rules would never reach the stylesheet. Fix: write the styles with `css()`, or set `importAliases: {{ '@vanilla-extract/css': false }}` and build with the vanilla-extract plugin", - import.source.value.display() - ), + spec.span.start, + "`globalKeyframes()` from `@vanilla-extract/css` is not compiled by Devup UI, which names every keyframes itself, so the global name would never reach the stylesheet. Fix: declare them with `keyframes()` and use the name it returns, or set `importAliases: { '@vanilla-extract/css': false }` and build with the vanilla-extract plugin".to_string(), )) } _ => None, @@ -1373,6 +1411,45 @@ import { createSprinkles } from '@vanilla-extract/sprinkles'", ); } + #[test] + fn test_vanilla_extract_companions_are_reported_however_loaded() { + let errors = companion_errors( + "export { recipe } from '@vanilla-extract/recipes' +export * from '@vanilla-extract/sprinkles' +export type { RecipeVariants } from '@vanilla-extract/recipes' +const { assignInlineVars } = require('@vanilla-extract/dynamic') +const load = () => import('@vanilla-extract/recipes') +import { style, globalKeyframes as frames, type GlobalStyleRule } from '@vanilla-extract/css' +import type { globalKeyframes } from '@vanilla-extract/css' +import { type globalKeyframes as typed } from '@vanilla-extract/css' +import * as ve from '@vanilla-extract/css'", + "a.css.ts", + ); + let found: Vec<(u32, &str)> = errors + .iter() + .map(|(offset, message)| (*offset, &message[..message.find(' ').unwrap_or(0)])) + .collect(); + assert_eq!( + found, + vec![ + (0, "`@vanilla-extract/recipes`"), + (50, "`@vanilla-extract/sprinkles`"), + (185, "`@vanilla-extract/dynamic`"), + (240, "`@vanilla-extract/recipes`"), + (291, "`globalKeyframes()`"), + ], + "{errors:?}" + ); + assert!(errors[2].1.contains("assigns variables at runtime")); + assert_eq!( + companion_errors( + "import { style } from '@vanilla-extract/css'; const globalKeyframes = 1", + "a.css.ts" + ), + vec![] + ); + } + #[test] fn test_emotion_cache_provider_redirects_to_compat() { let code = transform_import_aliases( diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 310075c7f..3f0e0ae10 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -51,6 +51,7 @@ mod jsx_semantics_tests; #[cfg(test)] mod legacy_semantics_contract_tests; mod module_loader; +mod module_loads; pub mod module_reference; mod mutations; #[cfg(test)] @@ -12101,6 +12102,25 @@ export const e = spin.toString()", "{error}" ); assert!(extract("a.css.ts", code, option(false)).is_ok()); + // However the module loads, and `globalKeyframes`, the TypeError the + // stylesheet run gave named neither + for (filename, code, shown) in [ + ( + "a.tsx", + "import { style } from '@vanilla-extract/css'\nconst { assignInlineVars } = require('@vanilla-extract/dynamic')", + "a.tsx:2:30: `@vanilla-extract/dynamic`", + ), + ( + "a.css.ts", + "import { style, globalKeyframes } from '@vanilla-extract/css'\nglobalKeyframes('fade', { from: { opacity: 0 } })", + "a.css.ts:1:17: `globalKeyframes()`", + ), + ] { + let error = extract(filename, code, option(true)) + .unwrap_err() + .to_string(); + assert!(error.starts_with(shown), "{error}"); + } } #[rstest] diff --git a/libs/extractor/src/module_loads.rs b/libs/extractor/src/module_loads.rs new file mode 100644 index 000000000..d1dd895d9 --- /dev/null +++ b/libs/extractor/src/module_loads.rs @@ -0,0 +1,117 @@ +//! Where a program loads a module: an import, a re-export, `require()`, +//! `import x = require()` or `import()`, each naming it by a string literal + +use oxc_ast::ast::{ + Argument, CallExpression, ExportAllDeclaration, ExportFromDeclaration, Expression, + ImportDeclaration, ImportExpression, Program, StringLiteral, TSImportEqualsDeclaration, + TSModuleReference, +}; +use oxc_ast_visit::{Visit, walk}; + +/// Where `program` loads one of the `packages`, in source order: the offset +/// of the statement or call loading it, with the package. Type-only imports +/// and re-exports are erased, so they load nothing. +pub(crate) fn module_loads( + program: &Program<'_>, + packages: &[&'static str], +) -> Vec<(u32, &'static str)> { + let mut loads = Loads { + packages, + found: Vec::new(), + }; + loads.visit_program(program); + loads.found.sort_unstable(); + loads.found +} + +struct Loads<'p> { + packages: &'p [&'static str], + found: Vec<(u32, &'static str)>, +} + +impl Loads<'_> { + fn push(&mut self, offset: u32, source: &StringLiteral<'_>) { + if let Some(package) = self + .packages + .iter() + .find(|package| source.value == **package) + { + self.found.push((offset, *package)); + } + } +} + +impl<'a> Visit<'a> for Loads<'_> { + fn visit_import_declaration(&mut self, it: &ImportDeclaration<'a>) { + if !it.import_kind.is_type() { + self.push(it.span.start, &it.source); + } + } + + fn visit_export_from_declaration(&mut self, it: &ExportFromDeclaration<'a>) { + if !it.export_kind.is_type() { + self.push(it.span.start, &it.source); + } + } + + fn visit_export_all_declaration(&mut self, it: &ExportAllDeclaration<'a>) { + if !it.export_kind.is_type() { + self.push(it.span.start, &it.source); + } + } + + fn visit_ts_import_equals_declaration(&mut self, it: &TSImportEqualsDeclaration<'a>) { + if let TSModuleReference::ExternalModuleReference(reference) = &it.module_reference + && !it.import_kind.is_type() + { + self.push(it.span.start, &reference.expression); + } + } + + fn visit_import_expression(&mut self, it: &ImportExpression<'a>) { + if let Expression::StringLiteral(source) = &it.source { + self.push(it.span.start, source); + } + walk::walk_import_expression(self, it); + } + + fn visit_call_expression(&mut self, it: &CallExpression<'a>) { + if it.callee.is_specific_id("require") + && let [Argument::StringLiteral(source)] = it.arguments.as_slice() + { + self.push(it.span.start, source); + } + walk::walk_call_expression(self, it); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + use rstest::rstest; + + fn loads(code: &str) -> Vec<(u32, &'static str)> { + let allocator = Allocator::default(); + let program = Parser::new(&allocator, code, SourceType::ts()) + .parse() + .program; + module_loads(&program, &["a", "b"]) + } + + #[rstest] + #[case("import x from 'a';", vec![(0, "a")])] + #[case("import type x from 'a'; import 'c';", vec![])] + #[case("export { x } from 'b';", vec![(0, "b")])] + #[case("export type { x } from 'b'; export const y = 1;", vec![])] + #[case("export const y = require('a');", vec![(17, "a")])] + #[case("export * from 'a'; export type * from 'b';", vec![(0, "a")])] + #[case("import x = require('b'); import type y = require('a'); import z = y.w;", vec![(0, "b")])] + #[case("f(import('a'), import(name), require('b', 1), require(name), req('a'));", vec![(2, "a")])] + #[case("const x = () => require('b');", vec![(16, "b")])] + fn finds_every_way_a_module_loads(#[case] code: &str, #[case] expected: Vec<(u32, &str)>) { + assert_eq!(loads(code), expected); + } +}