diff --git a/.changepacks/changepack_log_emotion_runtime_errors.json b/.changepacks/changepack_log_emotion_runtime_errors.json new file mode 100644 index 000000000..1b6287f22 --- /dev/null +++ b/.changepacks/changepack_log_emotion_runtime_errors.json @@ -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" +} diff --git a/apps/landing/src/app/(detail)/docs/limitations/page.mdx b/apps/landing/src/app/(detail)/docs/limitations/page.mdx index c92635565..39e01390c 100644 --- a/apps/landing/src/app/(detail)/docs/limitations/page.mdx +++ b/apps/landing/src/app/(detail)/docs/limitations/page.mdx @@ -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()`) @@ -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 | @@ -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 ``, `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. diff --git a/apps/landing/src/app/(detail)/docs/migration/overview/page.mdx b/apps/landing/src/app/(detail)/docs/migration/overview/page.mdx index b41a651b5..9c494580d 100644 --- a/apps/landing/src/app/(detail)/docs/migration/overview/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/overview/page.mdx @@ -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 `/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 `/compat.d.ts` with ambient declarations for the aliases you have enabled: ```ts /// diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 381c175be..537926cfe 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, @@ -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, +) -> 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); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 310075c7f..52d359d69 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)] @@ -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 => {} @@ -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 =
"; let code = readable_code(source); @@ -15908,7 +15912,6 @@ export const a =
"; 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\"", "
", @@ -15918,6 +15921,33 @@ export const a =
"; 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 @@ -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() { 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); + } +} diff --git a/libs/extractor/src/retained_render_assertions.rs b/libs/extractor/src/retained_render_assertions.rs index a61e5230b..82ebac86d 100644 --- a/libs/extractor/src/retained_render_assertions.rs +++ b/libs/extractor/src/retained_render_assertions.rs @@ -137,13 +137,11 @@ pub fn forwarding(source: &str) -> RenderRecords { } pub fn retarget(source: &str) -> RenderRecords { - // Unknown make()/other operations remain extraction-only with their original assertions. + // The unknown `other` operation remains extraction-only with its original assertions. let definitions = source .lines() .filter(|line| { - !line.contains("make()") - && !line.contains("other.withComponent") - && !line.starts_with("export const a =") + !line.contains("other.withComponent") && !line.starts_with("export const a =") }) .collect::>() .join("\n"); diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_cx_merges_known_styles_in_order.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_cx_merges_known_styles_in_order.snap new file mode 100644 index 000000000..5856f1a4c --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_cx_merges_known_styles_in_order.snap @@ -0,0 +1,7 @@ +--- +source: libs/extractor/src/lib.rs +expression: "emotion_class_call(r\"const red = css({ color: 'red' });\nconst blue = css({ color: 'blue', margin: 1 });\nexport const a = cx(blue, red);\nexport const b = cx(red, blue);\nexport const c = (on) => cx(blue, on && red, 'external');\nexport const d = (on) => cx(on ? red : blue, blue);\nexport const e = cx([blue, , red]);\nexport const g = (on) => cx(blue, red, { picked: true, off: false }, ({ 'a b': on }));\nexport const m = (on, k) => cx(blue, red, { [k]: on });\nexport const h = cx(blue, `${red} x`);\nexport const i = (on) => cx([blue, { picked: on }], red);\nexport const j = (on) => cx(on && { picked: on }, red);\nexport const k = (on) => cx(on ? { picked: on } : red, blue);\nexport const l = (on) => cx(on ? red : `x`, blue);\")" +--- +Ok( + "import \"@devup-ui/react/devup-ui-0.css\";\nconst red = \"a-a\";\nconst blue = \"a-b a-c\";\nexport const a = \"a-c a-a\";\nexport const b = \"a-b a-c\";\nexport const c = (on) => `external a-c ${on ? \"a-a\" : \"a-b\"}`;\nexport const d = (on) => \"a-b a-c\";\nexport const e = \"a-c a-a\";\nexport const g = (on) => `picked a-c a-a ${on ? \"a b\" : \"\"}`;\nexport const m = (on, k) => `a-b a-c a-a ${on ? `${k}` : \"\"}`;\nexport const h = \"a-b a-c a-a x\";\nexport const i = (on) => `a-b a-c a-a ${on ? \"picked\" : \"\"}`;\nexport const j = (on) => `a-a ${on ? `${on ? \"picked\" : \"\"}` : \"\"}`;\nexport const k = (on) => `a-b a-c ${on ? `${on ? \"picked\" : \"\"}` : \"a-a\"}`;\nexport const l = (on) => `a-b a-c ${on ? \"a-a\" : \"x\"}`;\n", +) diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_cx_takes_class_names.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_cx_takes_class_names.snap index 59ef02f89..8d1f86254 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_cx_takes_class_names.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_cx_takes_class_names.snap @@ -3,5 +3,5 @@ source: libs/extractor/src/lib.rs expression: "emotion_class_call(r\"const red = css({ color: 'red' });\nexport const a = cx('external');\nexport const b = cx(red, 'external', { picked: true });\nexport const c = cx(red, false && 'x');\nexport const d = (on, k, cls, props) => cx(cls, { on, [k]: on, 'a b': props.x, off: false, 1: on, [`t`]: on }, [on && 'x', [cls]], on ? { y: 1 } : null, on || cls, cls ?? 'z', 0, 5, null, undefined, `t ${cls} u`, `glued${cls}`, props.make(), (on as boolean) && 'w', false || 'v');\nexport const e = cx();\nexport const f = cx([, 'a'], [' b c ']);\nexport const g = (on) => cx(on ? { a: on } : [on && 'b'], on ? (on ? 'c' : 'd') : 'e');\nexport const h = (on) => cx({ [on.key]: on.value });\nexport const i = () => cx(red, `${red} x`);\nexport const j = cx({ a: null, b: 'x', c: '', d: 0, e: undefined, f: 2, g: false, h: true });\")" --- Ok( - "import \"@devup-ui/react/devup-ui-0.css\";\nconst red = \"a-a\";\nexport const a = \"external\";\nexport const b = \"a-a external picked\";\nexport const c = \"a-a\";\nexport const d = (on, k, cls, props) => `5 t u v ${cls || \"\"} ${on ? \"on\" : \"\"} ${on ? `${k}` : \"\"} ${props.x ? \"a b\" : \"\"} ${on ? \"1\" : \"\"} ${on ? \"t\" : \"\"} ${on ? \"x\" : \"\"} ${cls || \"\"} ${on ? \"y\" : \"\"} ${on || `${cls || \"\"}` || \"\"} ${(cls ?? \"z\") || \"\"} ${cls || \"\"} glued${cls} ${props.make() || \"\"} ${(on as boolean) ? \"w\" : \"\"}`;\nexport const e = \"\";\nexport const f = \"a b c\";\nexport const g = (on) => `${on ? `${on ? \"a\" : \"\"}` : `${on ? \"b\" : \"\"}`} ${on ? `${on ? \"c\" : \"d\"}` : \"e\"}`;\nexport const h = (on) => on.value ? `${on.key}` : \"\";\nexport const i = () => \"a-a a-a x\";\nexport const j = \"b f h\";\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst red = \"a-a\";\nexport const a = \"external\";\nexport const b = \"external picked a-a\";\nexport const c = \"a-a\";\nexport const d = (on, k, cls, props) => `5 t u v ${cls || \"\"} ${on ? \"on\" : \"\"} ${on ? `${k}` : \"\"} ${props.x ? \"a b\" : \"\"} ${on ? \"1\" : \"\"} ${on ? \"t\" : \"\"} ${on ? \"x\" : \"\"} ${cls || \"\"} ${on ? \"y\" : \"\"} ${on || `${cls || \"\"}` || \"\"} ${(cls ?? \"z\") || \"\"} ${cls || \"\"} glued${cls} ${props.make() || \"\"} ${(on as boolean) ? \"w\" : \"\"}`;\nexport const e = \"\";\nexport const f = \"a b c\";\nexport const g = (on) => `${on ? `${on ? \"a\" : \"\"}` : `${on ? \"b\" : \"\"}`} ${on ? `${on ? \"c\" : \"d\"}` : \"e\"}`;\nexport const h = (on) => on.value ? `${on.key}` : \"\";\nexport const i = () => \"a-a a-a x\";\nexport const j = \"b f h\";\n", ) diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_runtime_imports_are_located_errors.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_runtime_imports_are_located_errors.snap new file mode 100644 index 000000000..aa8c9aa5b --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_runtime_imports_are_located_errors.snap @@ -0,0 +1,26 @@ +--- +source: libs/extractor/src/lib.rs +expression: errors +--- +[ + ( + "import { css, ThemeContext, withEmotionCache as w, type SerializedStyles } from '@emotion/react';\nimport type { Theme } from '@emotion/react';", + "App.tsx:1:15: `@emotion/react` has no Devup UI equivalent of `ThemeContext`, 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\nApp.tsx:1:29: `@emotion/react` has no Devup UI equivalent of `withEmotionCache`, 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", + ), + ( + "import React, { Global } from 'react';\nimport Emotion from '@emotion/react';\nimport * as E from '@emotion/react';", + "App.tsx:2:8: `@emotion/react` has no Devup UI equivalent of `default`, 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\nApp.tsx:3:8: `@emotion/react` has no Devup UI equivalent of `* as E`, 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", + ), + ( + "import createEmotion from '@emotion/css/create-instance';\nexport const { css } = createEmotion({ key: 'x' });", + "App.tsx:1:1: `@emotion/css/create-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", + ), + ( + "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');", + "App.tsx:1:1: `@emotion/css/create-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\nApp.tsx:2:1: `@emotion/css/create-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\nApp.tsx:3:11: `@emotion/css/create-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\nApp.tsx:4:17: `@emotion/css/create-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", + ), + ( + "import { css, cx } from '@emotion/css';\nimport type Create from '@emotion/css/create-instance';", + "", + ), +] diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 3bc78081f..00c182be9 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -174,6 +174,37 @@ fn coalesce_keeps_left(logical: &oxc_ast::ast::LogicalExpression<'_>) -> bool { ) } +/// `Component.withComponent(...)` on a styled component, given a target the +/// build cannot render: the component compiles to a plain function, so the +/// call cannot stay for the runtime +fn with_component_error(call: &CallExpression<'_>) -> String { + let target: Vec = call.arguments.iter().map(readable_argument).collect(); + build_time_error( + "withComponent", + &target.join(", "), + "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`", + ) +} + +/// Whether `expression`, as Emotion's `cx` takes it, holds no class map or +/// CSS text that composing would read as styles +fn names_classes(expression: &Expression<'_>) -> bool { + match unwrap_syntax_only(expression) { + Expression::ObjectExpression(_) | Expression::TemplateLiteral(_) => false, + Expression::ArrayExpression(array) => array + .elements + .iter() + .all(|element| element.as_expression().is_none_or(names_classes)), + Expression::LogicalExpression(logical) => { + names_classes(&logical.left) && names_classes(&logical.right) + } + Expression::ConditionalExpression(conditional) => { + names_classes(&conditional.consequent) && names_classes(&conditional.alternate) + } + _ => true, + } +} + fn style_property_into_string(style_property: StyleProperty) -> String { match style_property { StyleProperty::ClassName(name) => name, @@ -519,6 +550,45 @@ impl<'a> DevupVisitor<'a> { Some(result) } + /// Emotion's `cx(...)` given a class whose styles the build knows: like + /// Emotion, it merges the styles in order, a later class winning, rather + /// than join classes whose order in the stylesheet decides. `None` when no + /// class is such a class, or a part is one composing does not read. + fn compose_known_classes(&mut self, call: &CallExpression<'a>) -> Option> { + let mut errors = Vec::new(); + let arguments: Vec> = call + .arguments + .iter() + .map(|argument| { + Some(match unwrap_syntax_only(argument.as_expression()?) { + // A class map, as the classes it names on their conditions + Expression::ObjectExpression(_) => class_arguments( + &self.ast, + crate::class_arguments::ClassCall::Cx, + call.span.start, + std::slice::from_ref(argument), + &mut errors, + ), + expression => expression.clone_in_with_semantic_ids(self.ast.allocator()), + }) + }) + .collect::>()?; + if !errors.is_empty() + || !arguments.iter().all(names_classes) + || !arguments + .iter() + .any(|argument| self.reads_known_styles(argument)) + { + return None; + } + let mut parts = Vec::new(); + for argument in &arguments { + self.known_parts(argument, &mut parts, Text::Classes)?; + } + self.local_arguments("cx", &call.arguments); + Some(self.composed_class::(call.span.start, parts).0) + } + /// Whether `expression` builds a styled component: `styled.div(...)`, /// `styled("div")(...)`, `styled("div", {...})` or their CSS text forms fn is_styled_call(&self, expression: &Expression<'a>) -> bool { @@ -3610,6 +3680,15 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { *it = component; let takes_styles = definition.takes_styles; self.pending_styled = Some((start, definition, takes_styles)); + } else if let Expression::CallExpression(call) = it + && let Expression::StaticMemberExpression(member) = &call.callee + && member.property.name == "withComponent" + && self + .definition_of(unwrap_syntax_only(&member.object)) + .is_some() + { + self.errors + .push((call.span.start, with_component_error(call))); } // Handle StyleX: stylex.create({...}) calls @@ -4122,6 +4201,13 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { text.span = call.span; } *it = composed; + } else if let Expression::CallExpression(call) = it + && self + .util_type(&call.callee) + .is_some_and(|util| matches!(util.as_ref(), UtilType::Cx)) + && let Some(composed) = self.compose_known_classes(call) + { + *it = composed; } else if let Expression::CallExpression(call) = it { if let Some(util_type) = self.util_type(&call.callee) { let source_span = call.span;