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
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_vanilla_extract_loads.json
Original file line number Diff line number Diff line change
@@ -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"
}
3 changes: 2 additions & 1 deletion apps/landing/src/app/(detail)/docs/limitations/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
119 changes: 98 additions & 21 deletions libs/extractor/src/import_alias_visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions libs/extractor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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]
Expand Down
117 changes: 117 additions & 0 deletions libs/extractor/src/module_loads.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading