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_theme_reads.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "A styled-components or Emotion style function reading the theme by literal keys, such as `p => p.theme.space[2]` or `({ theme }) => theme.colors['brand']`, now reads the CSS variable ThemeProvider declares (`var(--space-2)`). A theme read the build cannot turn into a CSS variable, such as `p => p.theme`, `p => p.theme.brand()` or `theme.space[i]`, is a build error naming the file, line, column and code, where it used to read a theme object that does not exist at runtime and give undefined or throw",
"date": "2026-10-01T00:00:00.000Z"
}
85 changes: 83 additions & 2 deletions libs/extractor/src/css_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,17 @@ pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option<String> {
_ => return None,
};

let mut path = Vec::new();
let mut path: Vec<Cow<'_, str>> = Vec::new();
let mut cursor = arrow.body.as_expression()?;
loop {
match cursor {
Expression::StaticMemberExpression(member) => {
path.push(member.property.name.as_str());
path.push(Cow::Borrowed(member.property.name.as_str()));
cursor = &member.object;
}
// `theme.space[2]` and `theme.colors['brand']` read a key the build knows
Expression::ComputedMemberExpression(member) => {
path.push(get_string_by_literal_expression(&member.expression)?);
cursor = &member.object;
}
Expression::Identifier(ident) => {
Expand All @@ -100,6 +105,82 @@ pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option<String> {
Some(format!("var(--{})", path.join("-")))
}

/// Whether the style function `expr` reads the theme in a way
/// [`theme_var_reference`] cannot turn into a CSS variable, such as
/// `p => p.theme.brand()` or `({ theme }) => theme.space[i]`: no theme object
/// exists at runtime, so it would read `undefined` or throw
pub(crate) fn reads_unmapped_theme(expr: &Expression<'_>) -> bool {
use oxc_ast_visit::{Visit, walk};
struct ThemeReads<'n> {
props: Option<&'n str>,
found: bool,
}
impl<'a> Visit<'a> for ThemeReads<'_> {
fn visit_static_member_expression(
&mut self,
it: &oxc_ast::ast::StaticMemberExpression<'a>,
) {
if it.property.name == "theme"
&& matches!(&it.object, Expression::Identifier(object) if Some(object.name.as_str()) == self.props)
{
self.found = true;
}
walk::walk_static_member_expression(self, it);
}
fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) {
if self.props.is_none() && it.name == "theme" {
self.found = true;
}
}
}
let Expression::ArrowFunctionExpression(arrow) = expr else {
return false;
};
let [param] = arrow.params.items.as_slice() else {
return false;
};
let props = match &param.pattern {
BindingPattern::BindingIdentifier(ident) => Some(ident.name.as_str()),
BindingPattern::ObjectPattern(pattern)
if pattern
.properties
.iter()
.any(|p| get_str_by_property_key(&p.key).is_some_and(|key| key == "theme")) =>
{
None
}
_ => return false,
};
if theme_var_reference(expr).is_some() {
return false;
}
let mut reads = ThemeReads {
props,
found: false,
};
match &arrow.body {
oxc_ast::ast::ArrowFunctionBody::FunctionBody(body) => reads.visit_function_body(body),
body => {
if let Some(expression) = body.as_expression() {
reads.visit_expression(expression);
}
}
}
reads.found
}

/// The build error for a theme read [`reads_unmapped_theme`] finds
pub(crate) fn unmapped_theme_error(api: &str, expr: &Expression<'_>) -> (u32, String) {
(
expr.span().start,
crate::utils::build_time_error(
api,
&readable_code(expr),
"a theme read must be a path of names or literal keys, such as `p => p.theme.colors.brand` or `({ theme }) => theme.space[2]`, which reads the CSS variable `ThemeProvider` declares; compute other values outside the style",
),
)
}

enum ThemeRoot<'a> {
Props(&'a str),
Theme,
Expand Down
7 changes: 7 additions & 0 deletions libs/extractor/src/extractor/extract_style_from_styled.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,13 @@ pub fn extract_style_from_styled<'a>(
let expression = &tag.quasi.expressions[index];
errors.push((expression.span().start, unplaced_error(expression)));
}
errors.extend(
tag.quasi
.expressions
.iter()
.filter(|expression| crate::css_utils::reads_unmapped_theme(expression))
.map(|expression| crate::css_utils::unmapped_theme_error("styled", expression)),
);
let mut props_styles: Vec<ExtractStyleProp<'_>> = styles
.into_iter()
.map(|ex| ExtractStyleProp::Static(ex.into()))
Expand Down
46 changes: 44 additions & 2 deletions libs/extractor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20630,11 +20630,10 @@ const Nested = styled.span`color: ${p => p.theme.colors.brand};`;
const Destructured = styled.p`color: ${({ theme }) => theme.colors.accent};`;
const Surrounded = styled.b`border: 1px solid ${p => p.theme.line};`;
const NotTheme = styled.i`color: ${p => p.color};`;
const BareTheme = styled.u`color: ${p => p.theme};`;
const OtherRoot = styled.s`color: ${p => q.theme.brand};`;
const ArrayParam = styled.q`color: ${([p]) => p.theme.brand};`;
const NoParam = styled.em`color: ${() => 'red'};`;
const CallBody = styled.strong`color: ${p => p.theme.brand()};`;",
const Indexed = styled.u`margin: ${p => p.theme.space[2]} ${({ theme }) => theme.colors['brand']};`;",
ExtractOption {
package: "@devup-ui/react".to_string(),
css_dir: "@devup-ui/react".to_string(),
Expand All @@ -20650,6 +20649,49 @@ const CallBody = styled.strong`color: ${p => p.theme.brand()};`;",
));
}

// A theme read the build cannot turn into a CSS variable has no theme
// object to read at runtime
#[test]
#[serial]
fn test_styled_components_unmapped_theme_reads_are_errors() {
reset_class_map();
reset_file_map();
let code = match extract(
"test.tsx",
"import {styled} from '@devup-ui/core'
const A = styled.u`color: ${p => p.theme};`
const B = styled.b`color: ${p => p.theme.brand()};`
const C = styled.i`color: ${({ theme }) => theme.space[i]};`
const D = styled.s`color: ${({ theme }) => { return theme.a + 1 }};`
const E = styled.em`color: ${p => p.color};`
const F = styled.q`color: ${({ tone }) => tone};`
const G = styled.p`color: ${([p]) => p};`
const H = styled.a`color: ${(a, b) => a};`
const I = styled.dd`color: ${function (p) { return p.theme }};`",
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(),
},
) {
Ok(output) => output.code,
Err(error) => error.to_string(),
};
for expected in [
"test.tsx:2:29: `styled()` cannot use `(p) => p.theme` at build time: a theme read must be a path of names or literal keys",
"`(p) => p.theme.brand()`",
"`({ theme }) => theme.space[i]`",
"theme.a + 1",
] {
assert!(code.contains(expected), "{expected}\n{code}");
}
for unexpected in ["p.color", "tone", "([p])", "(a, b)", "function (p)"] {
assert!(!code.contains(unexpected), "{unexpected}\n{code}");
}
}

#[test]
#[serial]
fn test_namespace_import_of_a_default_only_package_redirects() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
source: libs/extractor/src/lib.rs
expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import styled from 'styled-components';\nconst Flat = styled.div`color: ${p => p.theme.brand};`;\nconst Nested = styled.span`color: ${p => p.theme.colors.brand};`;\nconst Destructured = styled.p`color: ${({ theme }) => theme.colors.accent};`;\nconst Surrounded = styled.b`border: 1px solid ${p => p.theme.line};`;\nconst NotTheme = styled.i`color: ${p => p.color};`;\nconst BareTheme = styled.u`color: ${p => p.theme};`;\nconst OtherRoot = styled.s`color: ${p => q.theme.brand};`;\nconst ArrayParam = styled.q`color: ${([p]) => p.theme.brand};`;\nconst NoParam = styled.em`color: ${() => 'red'};`;\nconst CallBody = styled.strong`color: ${p => p.theme.brand()};`;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"styled-components\".to_string(),\n ImportAlias::DefaultToNamed(\"styled\".to_string()))])\n},).unwrap())"
expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import styled from 'styled-components';\nconst Flat = styled.div`color: ${p => p.theme.brand};`;\nconst Nested = styled.span`color: ${p => p.theme.colors.brand};`;\nconst Destructured = styled.p`color: ${({ theme }) => theme.colors.accent};`;\nconst Surrounded = styled.b`border: 1px solid ${p => p.theme.line};`;\nconst NotTheme = styled.i`color: ${p => p.color};`;\nconst OtherRoot = styled.s`color: ${p => q.theme.brand};`;\nconst ArrayParam = styled.q`color: ${([p]) => p.theme.brand};`;\nconst NoParam = styled.em`color: ${() => 'red'};`;\nconst Indexed = styled.u`margin: ${p => p.theme.space[2]} ${({ theme }) => theme.colors['brand']};`;\",\nExtractOption\n{\n package: \"@devup-ui/react\".to_string(), css_dir:\n \"@devup-ui/react\".to_string(), single_css: true, import_main_css: false,\n import_aliases:\n HashMap::from([(\"styled-components\".to_string(),\n ImportAlias::DefaultToNamed(\"styled\".to_string()))])\n},).unwrap())"
---
ToBTreeSet {
styles: {
Expand Down Expand Up @@ -44,29 +44,21 @@ ToBTreeSet {
layer: None,
},
),
Dynamic(
ExtractDynamicStyle {
property: "color",
level: 0,
identifier: "(()=>`red`)(rest)",
selector: None,
style_order: None,
},
),
Dynamic(
ExtractDynamicStyle {
property: "color",
Static(
ExtractStaticStyle {
property: "margin",
value: "var(--space-2) var(--colors-brand)",
level: 0,
identifier: "(([p])=>p.theme.brand)(rest)",
selector: None,
style_order: None,
layer: None,
},
),
Dynamic(
ExtractDynamicStyle {
property: "color",
level: 0,
identifier: "(p=>p.color)(rest)",
identifier: "(()=>`red`)(rest)",
selector: None,
style_order: None,
},
Expand All @@ -75,7 +67,7 @@ ToBTreeSet {
ExtractDynamicStyle {
property: "color",
level: 0,
identifier: "(p=>p.theme)(rest)",
identifier: "(([p])=>p.theme.brand)(rest)",
selector: None,
style_order: None,
},
Expand All @@ -84,7 +76,7 @@ ToBTreeSet {
ExtractDynamicStyle {
property: "color",
level: 0,
identifier: "(p=>p.theme.brand())(rest)",
identifier: "(p=>p.color)(rest)",
selector: None,
style_order: None,
},
Expand All @@ -99,5 +91,5 @@ ToBTreeSet {
},
),
},
code: "import \"@devup-ui/react/devup-ui.css\";\nconst Flat = ({ style, className, ...rest }) => <div {...rest} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />;\nconst Nested = ({ style, className, ...rest }) => <span {...rest} className={[\"b\", className].filter(Boolean).join(\" \")} style={style} />;\nconst Destructured = ({ style, className, ...rest }) => <p {...rest} className={[\"c\", className].filter(Boolean).join(\" \")} style={style} />;\nconst Surrounded = ({ style, className, ...rest }) => <b {...rest} className={[\"d\", className].filter(Boolean).join(\" \")} style={style} />;\nconst NotTheme = ({ style, className, ...rest }) => <i {...rest} className={[\"e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (p=>p.color)(rest) },\n\t...style\n}} />;\nconst BareTheme = ({ style, className, ...rest }) => <u {...rest} className={[\"e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (p=>p.theme)(rest) },\n\t...style\n}} />;\nconst OtherRoot = ({ style, className, ...rest }) => <s {...rest} className={[\"e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (p=>q.theme.brand)(rest) },\n\t...style\n}} />;\nconst ArrayParam = ({ style, className, ...rest }) => <q {...rest} className={[\"e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (([p])=>p.theme.brand)(rest) },\n\t...style\n}} />;\nconst NoParam = ({ style, className, ...rest }) => <em {...rest} className={[\"e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (()=>`red`)(rest) },\n\t...style\n}} />;\nconst CallBody = ({ style, className, ...rest }) => <strong {...rest} className={[\"e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (p=>p.theme.brand())(rest) },\n\t...style\n}} />;\n",
code: "import \"@devup-ui/react/devup-ui.css\";\nconst Flat = ({ style, className, ...rest }) => <div {...rest} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />;\nconst Nested = ({ style, className, ...rest }) => <span {...rest} className={[\"b\", className].filter(Boolean).join(\" \")} style={style} />;\nconst Destructured = ({ style, className, ...rest }) => <p {...rest} className={[\"c\", className].filter(Boolean).join(\" \")} style={style} />;\nconst Surrounded = ({ style, className, ...rest }) => <b {...rest} className={[\"d\", className].filter(Boolean).join(\" \")} style={style} />;\nconst NotTheme = ({ style, className, ...rest }) => <i {...rest} className={[\"e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (p=>p.color)(rest) },\n\t...style\n}} />;\nconst OtherRoot = ({ style, className, ...rest }) => <s {...rest} className={[\"e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (p=>q.theme.brand)(rest) },\n\t...style\n}} />;\nconst ArrayParam = ({ style, className, ...rest }) => <q {...rest} className={[\"e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (([p])=>p.theme.brand)(rest) },\n\t...style\n}} />;\nconst NoParam = ({ style, className, ...rest }) => <em {...rest} className={[\"e\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--f\": (()=>`red`)(rest) },\n\t...style\n}} />;\nconst Indexed = ({ style, className, ...rest }) => <u {...rest} className={[\"g\", className].filter(Boolean).join(\" \")} style={style} />;\n",
}
Loading