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_emotion_runtime_errors.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": "Emotion code the build cannot compile is a located build error instead of staying at runtime, and Emotion's `cx` merges the styles of classes `css()` gives in argument order, so `cx(blue, red)` is red as in Emotion instead of following stylesheet order. New build errors: `withComponent()` given a target the build cannot render (`Section.withComponent(make())`), as the styled component compiles to a plain function with no `withComponent`; an `@emotion/react` import with no Devup UI equivalent (`ThemeContext`, `withEmotionCache`, a default or namespace import) while `@emotion/react` is aliased, naming the export; and `@emotion/css/create-instance` loaded by an import, re-export, `require()` or `import()` while `@emotion/css` is aliased",
"date": "2026-11-15T00:00:00.000Z"
}
6 changes: 4 additions & 2 deletions apps/landing/src/app/(detail)/docs/limitations/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ Some composition only happens at runtime, so the build cannot merge it. There th

- a `className` passed in through props, an external class (CSS Modules, a design system) or a class string built at runtime, joined with Devup UI classes
- a `styled()` base the build cannot read (a `let`, a function result, a component from another package), which keeps wrapping it
- `withComponent(make())` and other targets only the runtime knows
- unknown spread props, which pass through as written
- StyleX arguments the build cannot read key by key (namespaces from other modules, `include()`)

Expand Down Expand Up @@ -80,7 +79,9 @@ A dynamic value such as `width` becomes a CSS variable set in `style`, so a comp
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `ServerStyleSheet`, `StyleSheetManager`, `CacheProvider` | Accepted and inert; Devup UI writes a real stylesheet at build time. `CacheProvider` ignores `value` |
| stylis plugins (`stylisPlugins`) | Not applied; there is no stylis pass at build time |
| `@emotion/css` `cache`, `flush`, `hydrate`, `merge`, `sheet` | Stay on `@emotion/css`; they work on styles inserted at runtime |
| `@emotion/css` `cache`, `flush`, `hydrate`, `sheet` | Stay on `@emotion/css`; they work on styles inserted at runtime |
| `@emotion/css/create-instance` | A build error while `@emotion/css` is aliased; import `css`, `cx` and the rest from `@emotion/css` |
| `@emotion/react` `ThemeContext`, `withEmotionCache`, `* as` | A build error while `@emotion/react` is aliased; import what Devup UI compiles by name |
| `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 |
Expand All @@ -91,6 +92,7 @@ A dynamic value such as `width` becomes a CSS variable set in `style`, so a comp
- `css()`, `globalCss()` and `keyframes()` have no element to set a CSS variable on, so a runtime value there is a build error. Move it to a style prop.
- A styled-components or Emotion style function may read the theme only as `theme.a.b` (or `theme.a[2]`). Calls, whole-theme reads and runtime keys are build errors.
- Inside `<ClassNames>`, `cx(getClass())` is a build error; put the result in a variable first, which then joins as a class.
- `withComponent` takes a tag name or a component written as a name or `a.b`. A target only the runtime knows, as `withComponent(make())`, is a build error: the styled component compiles to a plain function with no `withComponent`. Pass the target directly, or render with `as`.
- The plugins read `jsxImportSource` from the `tsconfig.json` (or `jsconfig.json`) in the working directory. Opt out with `importAliases: { '@emotion/react/jsx-runtime': false }`.
- Code that only the runtime can compute — other globals, `Date`, `Math.random`, `new`, classes, async code — is never run by the build; on an element it stays a CSS variable, elsewhere it is a build error.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ DevupUI({

## Uninstalling the original package

Once the imports are rewritten you can drop the dependency. TypeScript still has to resolve the specifier you wrote, so the plugins generate `<distDir>/compat.d.ts` with ambient declarations for the aliases you have enabled:
Once the imports are rewritten you can drop the dependency, as long as the build reports no error: an export with no Devup UI equivalent — `ThemeContext` or `withEmotionCache` from `@emotion/react`, `@emotion/css/create-instance`, vanilla-extract `recipes` and `sprinkles` — is a build error naming it rather than a package left behind. `@emotion/css`'s `cache`, `flush`, `hydrate` and `sheet` work on styles inserted at runtime and stay on `@emotion/css`, so a file using them keeps that package. TypeScript still has to resolve the specifier you wrote, so the plugins generate `<distDir>/compat.d.ts` with ambient declarations for the aliases you have enabled:

```ts
/// <reference types="@devup-ui/react/compat/styled-components" />
Expand Down
82 changes: 79 additions & 3 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 @@ -539,6 +539,82 @@ pub fn companion_errors(code: &str, filename: &str) -> Vec<(u32, String)> {
.collect()
}

/// Emotion's factory of instances of its own, whose classes Devup UI does not
/// compile
const EMOTION_INSTANCE: &str = "@emotion/css/create-instance";

/// Where code compiled through the Emotion aliases would keep Emotion at
/// runtime, as errors at their offsets: an `@emotion/react` export Devup UI
/// has no equivalent of, and `@emotion/css/create-instance` loaded at all
pub fn emotion_errors(
code: &str,
filename: &str,
import_aliases: &HashMap<String, ImportAlias>,
) -> Vec<(u32, String)> {
let react = import_aliases.contains_key("@emotion/react") && code.contains("@emotion/react");
let instance = import_aliases.contains_key("@emotion/css") && code.contains(EMOTION_INSTANCE);
if !react && !instance {
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;
let packages: &[&str] = if instance { &[EMOTION_INSTANCE] } else { &[] };
let mut errors: Vec<(u32, String)> = crate::module_loads::module_loads(&program, packages)
.into_iter()
.map(|(offset, _)| (offset, emotion_instance_error()))
.collect();
for statement in program.body.iter().filter(|_| react) {
if let Statement::ImportDeclaration(import) = statement
&& !import.import_kind.is_type()
&& import.source.value == "@emotion/react"
{
errors.extend(unmapped_emotion_react(import));
}
}
errors
}

fn emotion_instance_error() -> String {
format!(
"`{EMOTION_INSTANCE}` creates an Emotion instance at runtime, whose `css`, `cx` and `keyframes` Devup UI does not compile, so its styles would stay at runtime. Fix: import `css`, `cx`, `keyframes` and `injectGlobal` from `@emotion/css`, or set `importAliases: {{ '@emotion/css': false }}` and build with Emotion"
)
}

/// The value imports of `import_decl` from `@emotion/react` with no Devup UI
/// equivalent, as errors at their specifiers
fn unmapped_emotion_react(import_decl: &ImportDeclaration<'_>) -> Vec<(u32, String)> {
import_decl
.specifiers
.iter()
.flatten()
.filter_map(|specifier| match specifier {
ImportDeclarationSpecifier::ImportSpecifier(spec)
if !spec.import_kind.is_type()
&& devup_equivalent("@emotion/react", imported_name(&spec.imported))
.is_none() =>
{
Some((spec.span.start, imported_name(&spec.imported).to_string()))
}
ImportDeclarationSpecifier::ImportSpecifier(_) => None,
ImportDeclarationSpecifier::ImportDefaultSpecifier(spec) => {
Some((spec.span.start, "default".to_string()))
}
ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec) => {
Some((spec.span.start, format!("* as {}", spec.local.name)))
}
})
.map(|(offset, export)| {
(
offset,
format!(
"`@emotion/react` has no Devup UI equivalent of `{export}`, so importing it would keep `@emotion/react` and its runtime. Fix: import only `css`, `keyframes`, `Global`, `ClassNames`, `jsx`, `ThemeProvider`, `useTheme`, `withTheme` and `CacheProvider` from `@emotion/react`, each by name, or set `importAliases: {{ '@emotion/react': false }}` and build with Emotion"
),
)
})
.collect()
}

/// A replacement of `code[start..end]` by text of `length` bytes
pub type Edit = (usize, usize, usize);

Expand Down
98 changes: 96 additions & 2 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 @@ -495,6 +496,10 @@ fn extract_source(
return Err(located_errors(filename, code, &[], errors).into());
}
}
let errors = import_alias_visit::emotion_errors(code, filename, &option.import_aliases);
if evaluated.is_none() && !errors.is_empty() {
return Err(located_errors(filename, code, &[], errors).into());
}
if evaluated.is_none() {
match barrel::rewrite(code, filename, &option.package, resolver) {
barrel::Barreled::Unchanged => {}
Expand Down Expand Up @@ -15895,7 +15900,6 @@ export const Aside = Section.withComponent('aside')
export const Linked = Section.withComponent(Link)
export const Nested = Section.withComponent(motion.div)
export const Again = Aside.withComponent('nav')
export const Kept = Section.withComponent(make())
export const Other = other.withComponent('aside')
export const a = <Section as=\"a\" forwardedAs=\"b\" />";
let code = readable_code(source);
Expand All @@ -15908,7 +15912,6 @@ export const a = <Section as=\"a\" forwardedAs=\"b\" />";
assert!(body.contains(&format!("c-0-{name}")), "{body}");
}
for expected in [
"export const Kept = Section.withComponent(make());",
"export const Other = other.withComponent(\"aside\");",
"ref={__devupRef} role=\"region\"",
"<Section as=\"a\" forwardedAs=\"b\" />",
Expand All @@ -15918,6 +15921,33 @@ export const a = <Section as=\"a\" forwardedAs=\"b\" />";
let _proof = crate::retained_render_assertions::retarget(source);
}

// A styled component compiles to a plain function, so `withComponent`
// given a target the build cannot render is a located error rather than
// a call that would throw at runtime
#[rstest]
#[case("Section.withComponent(make())", "make()", 3, 21)]
#[case("Section.withComponent(Link, 'x')", "Link, \"x\"", 3, 21)]
#[case("Section.withComponent()", "", 3, 21)]
#[case("(Section).withComponent(a[b])", "a[b]", 3, 21)]
#[serial]
fn test_styled_with_component_unknown_target_is_located_error(
#[case] call: &str,
#[case] shown: &str,
#[case] line: usize,
#[case] column: usize,
) {
let source = format!(
"import {{styled}} from '@devup-ui/core'\nconst Section = styled.section({{ color: 'red' }})\nexport const Kept = {call}"
);
let error = readable_code(&source);
assert_eq!(
error,
format!(
"test.tsx:{line}:{column}: `withComponent()` cannot use `{shown}` at build time: it takes one tag name, as `'aside'`, or a component written as a name or `a.b`, as the styled component compiles to a plain function with no `withComponent` at runtime. Fix: pass the tag or component directly, or render the styled component with `as`"
)
);
}

// A styled component keeps the props its styles read, `$` props and `theme`
// away from the tag, follows `shouldForwardProp` as the build evaluates it,
// and an element using it drops what it neither reads nor passes on
Expand Down Expand Up @@ -24345,6 +24375,70 @@ export const j = cx({ a: null, b: 'x', c: '', d: 0, e: undefined, f: 2, g: false
));
}

// Emotion's `cx` merges the styles of the classes `css` gives, the last
// winning, rather than leave the stylesheet order to decide
#[test]
#[serial]
fn test_emotion_cx_merges_known_styles_in_order() {
let spread = emotion_class_call(
"const red = css({ color: 'red' });\nexport const a = (x) => cx(red, { ...x });",
);
assert!(spread.unwrap_err().contains("`...x`"));
assert_debug_snapshot!(emotion_class_call(
r"const red = css({ color: 'red' });
const blue = css({ color: 'blue', margin: 1 });
export const a = cx(blue, red);
export const b = cx(red, blue);
export const c = (on) => cx(blue, on && red, 'external');
export const d = (on) => cx(on ? red : blue, blue);
export const e = cx([blue, , red]);
export const g = (on) => cx(blue, red, { picked: true, off: false }, ({ 'a b': on }));
export const m = (on, k) => cx(blue, red, { [k]: on });
export const h = cx(blue, `${red} x`);
export const i = (on) => cx([blue, { picked: on }], red);
export const j = (on) => cx(on && { picked: on }, red);
export const k = (on) => cx(on ? { picked: on } : red, blue);
export const l = (on) => cx(on ? red : `x`, blue);"
));
}

// What the Emotion aliases cannot compile, and would keep at runtime, is a
// located error naming it
#[test]
#[serial]
fn test_emotion_runtime_imports_are_located_errors() {
let mut option = library_option();
option
.import_aliases
.insert("@emotion/css".to_string(), ImportAlias::NamedToNamed);
let errors: Vec<(&str, String)> = [
"import { css, ThemeContext, withEmotionCache as w, type SerializedStyles } from '@emotion/react';\nimport type { Theme } from '@emotion/react';",
"import React, { Global } from 'react';\nimport Emotion from '@emotion/react';\nimport * as E from '@emotion/react';",
"import createEmotion from '@emotion/css/create-instance';\nexport const { css } = createEmotion({ key: 'x' });",
"export { default } from '@emotion/css/create-instance';\nexport * from '@emotion/css/create-instance';\nconst a = require('@emotion/css/create-instance');\nconst b = () => import('@emotion/css/create-instance');",
"import { css, cx } from '@emotion/css';\nimport type Create from '@emotion/css/create-instance';",
]
.into_iter()
.map(|code| {
reset_class_map();
reset_file_map();
let error = extract("App.tsx", code, option.clone()).err();
(code, error.map_or_else(String::new, |error| error.to_string()))
})
.collect();
assert_debug_snapshot!(errors);
let mut without = library_option();
without.import_aliases.remove("@emotion/react");
assert!(
extract(
"App.tsx",
"import { ThemeContext } from '@emotion/react';\nimport createEmotion from '@emotion/css/create-instance';",
without,
)
.is_ok()
);
}

#[test]
#[serial]
fn test_emotion_rules_read_imported_constants_as_pixels() {
Expand Down
Loading
Loading