diff --git a/.changepacks/changepack_log_compose_known_styles.json b/.changepacks/changepack_log_compose_known_styles.json
new file mode 100644
index 000000000..6aa4c50d9
--- /dev/null
+++ b/.changepacks/changepack_log_compose_known_styles.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch"
+ },
+ "note": "css(a, b) composing classes whose styles the build knows (a const bound to css() in the file, or a css() result another module exports with a rule object every value of which is known) merges their styles: a later part's declaration replaces an earlier one's for the same property, selector, breakpoint and layer, also under conditions (css(base, cond && danger)) and for ||/?? parts, where the classes used to be joined and the stylesheet order picked the winner. vanilla-extract style([a, b]) passes each composed style as its own argument, so the later one wins, and a style composed again later (style([a, b, a])) is kept. Classes the build does not know (strings, props, CSS Modules) are kept as they are",
+ "date": "2026-10-01T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_compose_styled.json b/.changepacks/changepack_log_compose_styled.json
new file mode 100644
index 000000000..4d05b2838
--- /dev/null
+++ b/.changepacks/changepack_log_compose_styled.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch"
+ },
+ "note": "styled(Base) extending a styled component the file binds to a const renders Base's tag directly with Base's styles composed under its own, so the extension's declarations replace Base's for the same property, selector, breakpoint and layer instead of losing to them by stylesheet order; Base's attrs apply before the extension's. attrs merge as styled-components merges them: className is joined with the caller's, style is merged and other props replace earlier ones. On an element, a className or style spread after the explicit prop now wins, as React merges props in the order they are written",
+ "date": "2026-10-01T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_styled_props.json b/.changepacks/changepack_log_styled_props.json
new file mode 100644
index 000000000..8dfbb440e
--- /dev/null
+++ b/.changepacks/changepack_log_styled_props.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch"
+ },
+ "note": "Styled components no longer pass every prop to the tag they render: `$` props, `theme` and the props their style functions or attrs read are kept away from a tag unless it takes them as attributes, and `shouldForwardProp` (Emotion options or styled-components withConfig) is evaluated at build time. A `shouldForwardProp` the build cannot evaluate is a build error naming the file, line and code, with the forms it accepts. An element using a styled component the file defines drops props the component neither reads nor passes on; spread props are passed as written",
+ "date": "2026-10-01T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_styled_targets.json b/.changepacks/changepack_log_styled_targets.json
new file mode 100644
index 000000000..a51d45fe7
--- /dev/null
+++ b/.changepacks/changepack_log_styled_targets.json
@@ -0,0 +1,7 @@
+{
+ "changes": {
+ "bindings/devup-ui-wasm/package.json": "Patch"
+ },
+ "note": "Styled components render what their `as` prop names, defaulting to the tag or component they were defined with, and pass `forwardedAs` on as `as`, as styled-components and Emotion do; `as` used to reach the rendered tag as an attribute. `Component.withComponent(target)` on a styled component the file binds to a const builds a component rendering the same styles and attrs as `target` (a tag name or a component JSX can name), where it used to call a method the generated function does not have and throw",
+ "date": "2026-10-01T00:00:00.000Z"
+}
diff --git a/.changepacks/changepack_log_transient_props.json b/.changepacks/changepack_log_transient_props.json
new file mode 100644
index 000000000..cbe2e2dd9
--- /dev/null
+++ b/.changepacks/changepack_log_transient_props.json
@@ -0,0 +1,9 @@
+{
+ "changes": {
+ "apps/landing/package.json": "Patch",
+ "bindings/devup-ui-wasm/package.json": "Patch",
+ "packages/react/package.json": "Patch"
+ },
+ "note": "Styled components now remove every runtime prop whose name starts with `$` before forwarding a rest spread to a tag or component, including props supplied by callers or attrs. Style and attrs functions can still read transient props, and all other build-time forwarding policies remain unchanged. The styled-components migration guide documents transient prop support. No new build errors",
+ "date": "2026-10-01T00:00:00.000Z"
+}
diff --git a/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx b/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx
index f6e92b699..801b33a05 100644
--- a/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx
+++ b/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx
@@ -34,7 +34,7 @@ Any base renders: a tag, a component, a member such as `motion.div`, or any othe
A `keyframes` or `css` result held in a `const` of the same file is a build-time value, so `` css`animation: ${fadeIn} 1s;` `` and `styled.div({ ['.' + card + ':hover &']: { margin: 0 } })` work. In CSS text a class standing where a declaration would is a mixin, composed with the component's class. One imported from another module is not: its name is only known once that file is built, so declare it where it is used.
-Object styles follow the libraries: several style arguments, or an array of them, merge into one rule set where a later declaration replaces an earlier one and nested rules merge; Emotion's options argument (`styled('div', { shouldForwardProp })(...)`) is accepted, and every prop is forwarded. Numbers mean pixels as they do in Emotion and styled-components — `padding: 8` is `8px`, unitless properties such as `lineHeight` stay as written — while Devup UI shorthands such as `p` keep the spacing scale.
+Object styles follow the libraries: several style arguments, or an array of them, merge into one rule set where a later declaration replaces an earlier one and nested rules merge; Emotion's options argument (`styled('div', { shouldForwardProp })(...)`) is accepted, and its `shouldForwardProp` is evaluated at build time. Numbers mean pixels as they do in Emotion and styled-components — `padding: 8` is `8px`, unitless properties such as `lineHeight` stay as written — while Devup UI shorthands such as `p` keep the spacing scale.
A style argument can be conditional — `cond && { ... }`, `cond ? { ... } : { ... }`, `value || { ... }` or `value ?? { ... }`, in `styled` as in `css`. Each property it sets becomes a per-property ternary over what came before (`color: cond ? 'blue' : 'red'`), so the result does not depend on the order atomic classes land in the stylesheet; a conditional class joins as `cond ? cls : ''`, and the left side of `||` / `??` joins only when it is a string, as the libraries skip other values. Classes passed beside rule objects compose in `styled` as in `css`.
@@ -44,7 +44,7 @@ A styled callback cannot return a class name known only at runtime (`styled.div(
Inside a template, an interpolation standing where a declaration would is a mixin: `${mixin}` and `${(p) => p.active && mixin}` compose their class with the component's. A literal or constant is written into the CSS wherever it stands — a value, a CSS snippet, a selector. Any other interpolation in a selector or a property name is a build error, as are runtime values in `css`, `keyframes` and global styles, which have no element to set a CSS variable on.
-`.attrs()` and `.withConfig()` chain onto any factory. Attrs are merged over the props in order, as styled-components does — an object is spread, a function is called with the props; `.withConfig()` only tunes runtime behavior, so it is dropped:
+`.attrs()` and `.withConfig()` chain onto any factory. Attrs are merged over the props in order, as styled-components does — an object is spread, a function is called with the props. In `.withConfig()`, `shouldForwardProp` is evaluated at build time; `displayName`, `componentId` and the other options are ignored and add no runtime behavior:
```tsx
const Button = styled.button
@@ -66,6 +66,23 @@ import * as Emotion from '@emotion/styled'
const Card = Emotion.div`color: red;`
```
+## Transient props
+
+Props whose names start with `$` are transient, as in styled-components: style functions read them, but a styled component never passes them on to what it renders — a tag, a component or what `as` names. That includes `$` props a runtime spread brings and those attrs create, even when `shouldForwardProp` allows them:
+
+```tsx
+const Title = styled.h1`
+ color: ${(p) => p.$tone};
+`
+
+function Heading(props) {
+ return
+}
+
+// `$tone` sets the color; the
gets `id` but neither `$tone` nor `$track`
+const heading =
+```
+
## Theme interpolation
`ThemeProvider` redefines CSS variables on a `display: contents` wrapper, so it scopes the theme to its subtree without touching layout. Interpolations that read the theme are resolved at build time — no runtime lookup:
@@ -128,7 +145,7 @@ Emotion's `` behaves the same way: the `styles` prop is e
| `css` prop, `jsx`, JSX runtime pragmas | compiled to `className` and `style` |
| `ClassNames` | replaced by what its child renders, each `css` / `cx` call compiled to classes |
| Component selectors (`${Child}`) | a marker class on the selected component |
-| `.attrs()`, `.withConfig()` | attrs merged over props; `withConfig` dropped |
+| `.attrs()`, `.withConfig()` | attrs merged over props; build-time `shouldForwardProp`, other options ignored |
## Limitations
diff --git a/libs/extractor/src/composition.rs b/libs/extractor/src/composition.rs
new file mode 100644
index 000000000..44dae55a6
--- /dev/null
+++ b/libs/extractor/src/composition.rs
@@ -0,0 +1,360 @@
+//! Styles composed in one place, such as `css(base, cond && danger)`: when the
+//! build knows the parts, a later part's declaration replaces an earlier one's
+//! for the same property, selector, breakpoint and layer. Atomic classes alone
+//! cannot express that, because which of two classes wins depends on their
+//! order in the stylesheet, not on the order they were composed in.
+
+use css::style_selector::StyleSelector;
+use oxc_allocator::{CloneIn, GetAllocator};
+use oxc_ast::ast::Expression;
+use oxc_ast::builder::AstBuilder;
+
+use crate::{ExtractStyleProp, ExtractStyleValue};
+
+/// What a declaration competes on: property, selector, breakpoint and layer
+#[derive(Clone, PartialEq, Eq)]
+struct CascadeKey {
+ property: String,
+ selector: Option,
+ level: u8,
+ layer: Option,
+}
+
+impl CascadeKey {
+ fn of(value: &ExtractStyleValue) -> Self {
+ let (property, selector, level, layer) = match value {
+ ExtractStyleValue::Static(style) => (
+ style.property(),
+ style.selector(),
+ style.level(),
+ style.layer(),
+ ),
+ ExtractStyleValue::Dynamic(style) => (
+ style.property(),
+ style.selector(),
+ style.level(),
+ style.layer(),
+ ),
+ // A preset: the only other value a style prop holds
+ _ => ("typography", None, 0, None),
+ };
+ Self {
+ property: property.to_string(),
+ selector: selector.cloned(),
+ level,
+ layer: layer.map(ToString::to_string),
+ }
+ }
+}
+
+/// What a key resolves to
+enum Choice<'a> {
+ Empty,
+ Atom(ExtractStyleValue),
+ Conditional {
+ test: Expression<'a>,
+ consequent: Box>,
+ alternate: Box>,
+ },
+}
+
+/// What a part does to a key
+enum Overlay<'a> {
+ /// Leaves what came before
+ Keep,
+ Atom(ExtractStyleValue),
+ Conditional {
+ test: Expression<'a>,
+ consequent: Box>,
+ alternate: Box>,
+ },
+}
+
+/// The parts composed so far, in source order: a key a later part sets moves
+/// to the end, as when merging objects
+#[derive(Default)]
+pub struct Composition<'a> {
+ entries: Vec<(CascadeKey, Choice<'a>)>,
+ /// Styles whose properties the build cannot pair up, kept as they are
+ unkeyed: Vec>,
+}
+
+impl<'a> Composition<'a> {
+ /// A part the build knows completely
+ pub fn apply(&mut self, ast_builder: &AstBuilder<'a>, props: Vec>) {
+ let mut overlays: Vec<(CascadeKey, Overlay<'a>)> = Vec::new();
+ for prop in props {
+ self.overlays(ast_builder, prop, &mut overlays);
+ }
+ for (key, overlay) in overlays {
+ self.overlay(ast_builder, key, overlay);
+ }
+ }
+
+ /// A part applying only while `test` holds, or `alternate` otherwise
+ pub fn apply_conditional(
+ &mut self,
+ ast_builder: &AstBuilder<'a>,
+ test: &Expression<'a>,
+ consequent: Vec>,
+ alternate: Vec>,
+ ) {
+ let prop = ExtractStyleProp::Conditional {
+ condition: test.clone_in(ast_builder.allocator()),
+ consequent: Some(Box::new(ExtractStyleProp::StaticArray(consequent))),
+ alternate: Some(Box::new(ExtractStyleProp::StaticArray(alternate))),
+ };
+ self.apply(ast_builder, vec![prop]);
+ }
+
+ /// Each key a prop sets, with what it sets it to
+ fn overlays(
+ &mut self,
+ ast_builder: &AstBuilder<'a>,
+ prop: ExtractStyleProp<'a>,
+ overlays: &mut Vec<(CascadeKey, Overlay<'a>)>,
+ ) {
+ match prop {
+ ExtractStyleProp::Static(value) => {
+ push_overlay(overlays, CascadeKey::of(&value), Overlay::Atom(value));
+ }
+ ExtractStyleProp::StaticArray(props) => {
+ for prop in props {
+ self.overlays(ast_builder, prop, overlays);
+ }
+ }
+ ExtractStyleProp::Conditional {
+ condition,
+ consequent,
+ alternate,
+ } if [&consequent, &alternate]
+ .into_iter()
+ .flatten()
+ .all(|side| keyed(side)) =>
+ {
+ let mut sides = [Vec::new(), Vec::new()];
+ for (side, prop) in sides.iter_mut().zip([consequent, alternate]) {
+ if let Some(prop) = prop {
+ self.overlays(ast_builder, *prop, side);
+ }
+ }
+ let [mut consequent, mut alternate] = sides;
+ let mut keys: Vec = Vec::new();
+ for (key, _) in consequent.iter().chain(&alternate) {
+ if !keys.contains(key) {
+ keys.push(key.clone());
+ }
+ }
+ for key in keys {
+ let consequent = take_overlay(&mut consequent, &key);
+ let alternate = take_overlay(&mut alternate, &key);
+ push_overlay(
+ overlays,
+ key,
+ Overlay::Conditional {
+ test: condition.clone_in(ast_builder.allocator()),
+ consequent: Box::new(consequent),
+ alternate: Box::new(alternate),
+ },
+ );
+ }
+ }
+ prop => self.unkeyed.push(prop),
+ }
+ }
+
+ fn overlay(&mut self, ast_builder: &AstBuilder<'a>, key: CascadeKey, overlay: Overlay<'a>) {
+ let previous = self
+ .entries
+ .iter()
+ .position(|(existing, _)| *existing == key)
+ .map_or(Choice::Empty, |index| self.entries.remove(index).1);
+ let choice = resolve(ast_builder, overlay, previous);
+ self.entries.push((key, choice));
+ }
+
+ /// The composed styles, for class names and the stylesheet
+ #[must_use]
+ pub fn into_props(self) -> Vec> {
+ self.entries
+ .into_iter()
+ .filter_map(|(_, choice)| into_prop(choice))
+ .chain(self.unkeyed)
+ .collect()
+ }
+
+ /// The composed atoms when no condition chooses between them
+ #[must_use]
+ pub fn unconditional(&self) -> Option> {
+ if !self.unkeyed.is_empty() {
+ return None;
+ }
+ let mut values = Vec::new();
+ for (_, choice) in &self.entries {
+ match choice {
+ Choice::Atom(value) => values.push(value.clone()),
+ // A key is empty only under a condition
+ Choice::Empty | Choice::Conditional { .. } => return None,
+ }
+ }
+ Some(values)
+ }
+}
+
+/// Styles a part of `css(...)` gives
+pub enum KnownStyles<'a> {
+ /// The styles of a `css()` class the file binds
+ Known(Vec),
+ /// A rule object
+ Rules(Expression<'a>),
+}
+
+/// A side of a condition among the parts of `css(...)`
+pub enum KnownSide<'a> {
+ Styles(Vec>),
+ /// A class the build does not know the styles of
+ Class(Expression<'a>),
+ Empty,
+}
+
+/// A part of `css(...)`, in source order
+pub enum KnownPart<'a> {
+ Styles(Vec>),
+ Conditional {
+ test: Expression<'a>,
+ consequent: Vec>,
+ alternate: Vec>,
+ },
+ Class(Expression<'a>),
+}
+
+/// `prop`'s styles at `order`, unless one sets its own
+pub fn set_prop_order(prop: &mut ExtractStyleProp<'_>, order: u8) {
+ match prop {
+ ExtractStyleProp::Static(value) => value.set_style_order(order),
+ ExtractStyleProp::StaticArray(props) => {
+ for prop in props {
+ set_prop_order(prop, order);
+ }
+ }
+ ExtractStyleProp::Conditional {
+ consequent,
+ alternate,
+ ..
+ } => {
+ for side in [consequent, alternate].into_iter().flatten() {
+ set_prop_order(side, order);
+ }
+ }
+ ExtractStyleProp::Enum { map, .. } => {
+ for prop in map.values_mut().flatten() {
+ set_prop_order(prop, order);
+ }
+ }
+ ExtractStyleProp::MemberExpression { map, .. } => {
+ for prop in map.values_mut() {
+ set_prop_order(prop, order);
+ }
+ }
+ // Class names the code gives, and styles reported as errors
+ ExtractStyleProp::Expression { .. } | ExtractStyleProp::Unreadable { .. } => {}
+ }
+}
+
+/// Whether every style `prop` holds has a key, so a condition around it can be
+/// applied key by key
+fn keyed(prop: &ExtractStyleProp<'_>) -> bool {
+ match prop {
+ ExtractStyleProp::Static(_) => true,
+ ExtractStyleProp::StaticArray(props) => props.iter().all(keyed),
+ ExtractStyleProp::Conditional {
+ consequent,
+ alternate,
+ ..
+ } => [consequent, alternate]
+ .into_iter()
+ .flatten()
+ .all(|side| keyed(side)),
+ _ => false,
+ }
+}
+
+/// `overlay` replacing what `overlays` already sets for the key: a part
+/// declaring a property twice keeps the later declaration
+fn push_overlay<'a>(
+ overlays: &mut Vec<(CascadeKey, Overlay<'a>)>,
+ key: CascadeKey,
+ overlay: Overlay<'a>,
+) {
+ overlays.retain(|(existing, _)| *existing != key);
+ overlays.push((key, overlay));
+}
+
+fn take_overlay<'a>(
+ overlays: &mut Vec<(CascadeKey, Overlay<'a>)>,
+ key: &CascadeKey,
+) -> Overlay<'a> {
+ overlays
+ .iter()
+ .position(|(existing, _)| existing == key)
+ .map_or(Overlay::Keep, |index| overlays.remove(index).1)
+}
+
+/// What `overlay` makes of `previous`
+fn resolve<'a>(
+ ast_builder: &AstBuilder<'a>,
+ overlay: Overlay<'a>,
+ previous: Choice<'a>,
+) -> Choice<'a> {
+ match overlay {
+ Overlay::Keep => previous,
+ Overlay::Atom(value) => Choice::Atom(value),
+ Overlay::Conditional {
+ test,
+ consequent,
+ alternate,
+ } => {
+ let copy = copy_choice(ast_builder, &previous);
+ Choice::Conditional {
+ test,
+ consequent: Box::new(resolve(ast_builder, *consequent, previous)),
+ alternate: Box::new(resolve(ast_builder, *alternate, copy)),
+ }
+ }
+ }
+}
+
+fn copy_choice<'a>(ast_builder: &AstBuilder<'a>, choice: &Choice<'a>) -> Choice<'a> {
+ match choice {
+ Choice::Empty => Choice::Empty,
+ Choice::Atom(value) => Choice::Atom(value.clone()),
+ Choice::Conditional {
+ test,
+ consequent,
+ alternate,
+ } => Choice::Conditional {
+ test: test.clone_in(ast_builder.allocator()),
+ consequent: Box::new(copy_choice(ast_builder, consequent)),
+ alternate: Box::new(copy_choice(ast_builder, alternate)),
+ },
+ }
+}
+
+fn into_prop(choice: Choice<'_>) -> Option> {
+ match choice {
+ Choice::Empty => None,
+ Choice::Atom(value) => Some(ExtractStyleProp::Static(value)),
+ Choice::Conditional {
+ test,
+ consequent,
+ alternate,
+ } => {
+ let (consequent, alternate) = (into_prop(*consequent), into_prop(*alternate));
+ (consequent.is_some() || alternate.is_some()).then(|| ExtractStyleProp::Conditional {
+ condition: test,
+ consequent: consequent.map(Box::new),
+ alternate: alternate.map(Box::new),
+ })
+ }
+ }
+}
diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs
index f09b03b07..74cf36ea2 100644
--- a/libs/extractor/src/extract_style/extract_dynamic_style.rs
+++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs
@@ -137,6 +137,10 @@ impl ExtractDynamicStyle {
self.identifier.as_str()
}
+ pub(crate) const fn identifier_mut(&mut self) -> &mut String {
+ &mut self.identifier
+ }
+
pub const fn style_order(&self) -> Option {
self.style_order
}
diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs
index c8ad9d20d..ef8f19f2a 100644
--- a/libs/extractor/src/extractor/extract_style_from_styled.rs
+++ b/libs/extractor/src/extractor/extract_style_from_styled.rs
@@ -10,10 +10,11 @@ use crate::{
ExtractResult,
extract_style_from_expression::{LiteralHandling, extract_style_from_expression},
styled_record::prepare,
- styled_record_evaluation::bind_render,
+ styled_record_evaluation::{Evaluations, bind_render, visit_style_expressions},
},
gen_class_name::{gen_class_names, merge_expression_for_class_name},
gen_style::gen_styles,
+ styled_reads::{Forward, Reads, withheld},
utils::{
STYLE_OBJECT, StyleArguments, build_time_error, call_with_values, merge_object_expressions,
readable_code, reads_directly, style_arguments, uncomposable_error, unplaced_error,
@@ -30,6 +31,7 @@ use oxc_ast::{
},
builder::AstBuilder,
};
+use oxc_ast_visit::VisitMut;
use oxc_span::{GetSpan, SPAN};
use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator};
@@ -50,7 +52,377 @@ struct Base<'a> {
bound: Option>,
}
+/// A styled component the file defines, which a component extending it
+/// renders in its place: what it renders, its classes and styles, and its
+/// attrs, as the extension applies them before its own
+pub struct StyledDefinition<'a> {
+ name: String,
+ target: Option>,
+ bound: Option>,
+ classes: Vec>,
+ styles: Vec>,
+ attrs: Vec>,
+ /// The props its styles and attrs read
+ reads: Reads,
+ /// Its `shouldForwardProp`, as the build evaluates it
+ forward: Option,
+ bindings: Vec<(String, Expression<'a>)>,
+}
+
+/// Whether a styled component renders a tag, which takes only valid
+/// attributes, rather than a component
+fn renders_tag(name: &str, bound: Option<&Expression<'_>>) -> bool {
+ bound.is_none() && name.starts_with(|c: char| c.is_ascii_lowercase()) && !name.contains('.')
+}
+
+/// `own` applying after `inherited`: a prop passes only when both pass it
+fn combine_forward(inherited: Option<&Forward>, own: Option) -> Option {
+ match (inherited.cloned(), own) {
+ (Some(inherited), Some(own)) => Some(Forward::And(Box::new(inherited), Box::new(own))),
+ (inherited, own) => own.or(inherited),
+ }
+}
+
+struct Rename<'b, 'a> {
+ builder: &'b AstBuilder<'a>,
+ names: FxHashMap,
+}
+
+impl<'a> VisitMut<'a> for Rename<'_, 'a> {
+ fn visit_identifier_reference(
+ &mut self,
+ identifier: &mut oxc_ast::ast::IdentifierReference<'a>,
+ ) {
+ let mut code = String::new();
+ // Dynamic identifiers can hold emitted JS code. Captured names
+ // were excluded from authored source, so only whole tokens change.
+ let separator = |ch: char| !ch.is_alphanumeric() && ch != '_' && ch != '$';
+ for chunk in identifier.name.split_inclusive(separator) {
+ let token = chunk.trim_end_matches(separator);
+ code.push_str(self.names.get(token).map_or(token, String::as_str));
+ code.push_str(&chunk[token.len()..]);
+ }
+ identifier.name = oxc_ast::ast::Ident::from_in(code.as_str(), self.builder.allocator());
+ }
+}
+
+impl<'a> StyledDefinition<'a> {
+ fn visit_expressions(
+ &mut self,
+ builder: &AstBuilder<'a>,
+ mut visit: impl FnMut(&mut Expression<'a>),
+ ) {
+ for expression in self
+ .target
+ .iter_mut()
+ .chain(&mut self.bound)
+ .chain(&mut self.classes)
+ .chain(&mut self.attrs)
+ {
+ visit(expression);
+ }
+ for (_, value) in &mut self.bindings {
+ visit(value);
+ }
+ for style in &mut self.styles {
+ visit_style_expressions(builder, style, &mut visit);
+ }
+ }
+
+ fn rebind(&self, builder: &AstBuilder<'a>, scope: &mut Expression<'a>) -> Self {
+ let allocator = builder.allocator();
+ let mut inherited = Self {
+ name: self.name.clone(),
+ target: self.target.as_ref().map(|value| value.clone_in(allocator)),
+ bound: self.bound.as_ref().map(|value| value.clone_in(allocator)),
+ classes: clone_all(builder, &self.classes).collect(),
+ styles: self
+ .styles
+ .iter()
+ .map(|style| style.clone_in(allocator))
+ .collect(),
+ attrs: clone_all(builder, &self.attrs).collect(),
+ reads: self.reads.clone(),
+ forward: self.forward.clone(),
+ bindings: self
+ .bindings
+ .iter()
+ .map(|(name, value)| (name.clone(), value.clone_in(allocator)))
+ .collect(),
+ };
+ let mut reserved = vec![scope.clone_in(allocator)];
+ inherited.visit_expressions(builder, |value| reserved.push(value.clone_in(allocator)));
+ reserved.extend(inherited.bindings.iter().map(|(name, _)| {
+ Expression::new_identifier(
+ SPAN,
+ oxc_ast::ast::Ident::from_in(name.as_str(), allocator),
+ builder,
+ )
+ }));
+ let reservation = Expression::new_array_expression(
+ SPAN,
+ oxc_allocator::Vec::from_iter_in(reserved.into_iter().map(Into::into), builder),
+ builder,
+ );
+ let mut reads = Evaluations::new(builder, readable_code(&reservation));
+ let mut rename = Rename {
+ builder,
+ names: FxHashMap::default(),
+ };
+ for (name, _) in &mut inherited.bindings {
+ let fresh = reads.fresh_name();
+ rename.names.insert(name.clone(), fresh.clone());
+ *name = fresh;
+ }
+ for (_, value) in &mut inherited.bindings {
+ rename.visit_expression(value);
+ }
+ for style in &mut inherited.styles {
+ visit_style_expressions(builder, style, &mut |value| rename.visit_expression(value));
+ }
+ let mut reserved = vec![reservation];
+ reserved.extend(inherited.bindings.iter().map(|(name, _)| {
+ Expression::new_identifier(
+ SPAN,
+ oxc_ast::ast::Ident::from_in(name.as_str(), allocator),
+ builder,
+ )
+ }));
+ *scope = Expression::new_array_expression(
+ SPAN,
+ oxc_allocator::Vec::from_iter_in(reserved.into_iter().map(Into::into), builder),
+ builder,
+ );
+ inherited
+ }
+}
+
+impl StyledDefinition<'_> {
+ /// Whether an element using this component keeps passing the prop `name`:
+ /// what its styles read, what it passes on, and what React or the
+ /// component itself takes
+ #[must_use]
+ pub fn takes(&self, name: &str) -> bool {
+ self.reads.whole
+ || self.reads.names.iter().any(|read| read == name)
+ || matches!(
+ name,
+ "className" | "style" | "as" | "forwardedAs" | "key" | "ref" | "children"
+ )
+ || crate::styled_reads::passes(
+ name,
+ renders_tag(&self.name, self.bound.as_ref()),
+ self.forward.as_ref(),
+ )
+ }
+
+ /// Whether an extension can render what this definition renders: a value
+ /// only the runtime gives is read again only when it is a binding
+ #[must_use]
+ pub fn extendable(&self) -> bool {
+ self.bound
+ .as_ref()
+ .is_none_or(|bound| matches!(bound, Expression::Identifier(_)))
+ }
+}
+
+/// `Component.withComponent(target)`: the styles and attrs of `definition`
+/// rendering `target`, a tag name or a component JSX can name; `None` for any
+/// other target
+pub fn with_component<'a>(
+ ast_builder: &AstBuilder<'a>,
+ definition: &StyledDefinition<'a>,
+ target: &Expression<'a>,
+ split_filename: Option<&str>,
+ helper: &mut Option,
+) -> Option<(Expression<'a>, StyledDefinition<'a>)> {
+ let name = match unwrap_syntax_only(target) {
+ Expression::StringLiteral(literal) => literal.value.to_string(),
+ target => jsx_name(target)?,
+ };
+ let allocator = ast_builder.allocator();
+ let base = Base {
+ target: match unwrap_syntax_only(target) {
+ Expression::StringLiteral(_) => None,
+ _ => Some(target.clone_in(allocator)),
+ },
+ ..Base::named(name)
+ };
+ let mut scope = target.clone_in(allocator);
+ let definition = definition.rebind(ast_builder, &mut scope);
+ let mut styles: Vec> = definition
+ .styles
+ .iter()
+ .map(|style| style.clone_in(allocator))
+ .collect();
+ let classes: Vec> = clone_all(ast_builder, &definition.classes).collect();
+ let class_name = merge_expression_for_class_name(
+ ast_builder,
+ clone_all(ast_builder, &classes).chain(gen_class_names(
+ ast_builder,
+ &mut styles,
+ None,
+ split_filename,
+ )),
+ );
+ let withheld = withheld(
+ &definition.reads,
+ renders_tag(&base.name, None),
+ definition.forward.as_ref(),
+ );
+ let component = create_styled_component(
+ ast_builder,
+ &base,
+ StyledRender {
+ class_name: &class_name,
+ style_vars: &gen_styles(ast_builder, &styles, None),
+ bindings: definition
+ .bindings
+ .iter()
+ .map(|(name, value)| (name.clone(), value.clone_in(allocator)))
+ .collect(),
+ withheld: &withheld,
+ },
+ helper,
+ );
+ let new_definition = base.definition(
+ ast_builder,
+ classes,
+ &styles,
+ &definition.attrs,
+ (definition.reads.clone(), definition.forward.clone()),
+ &definition.bindings,
+ );
+ Some((
+ apply_attrs(ast_builder, component, &definition.attrs),
+ new_definition,
+ ))
+}
+
+/// What extracting a styled component gives
+pub struct StyledExtraction<'a> {
+ pub result: ExtractResult<'a>,
+ pub expression: Expression<'a>,
+ pub errors: Vec<(u32, String)>,
+ pub definition: Option>,
+}
+
+/// The styles a component renders: those it extends or its base component
+/// brings, then its own, a later declaration replacing an earlier one
+fn compose_styles<'a>(
+ ast_builder: &AstBuilder<'a>,
+ inherited: Option<&StyledDefinition<'a>>,
+ defaults: Option>,
+ own: Vec>,
+) -> Vec> {
+ let mut earlier: Vec> = inherited.map_or_else(Vec::new, |inherited| {
+ inherited
+ .styles
+ .iter()
+ .map(|style| style.clone_in(ast_builder.allocator()))
+ .collect()
+ });
+ earlier.extend(defaults.into_iter().flatten().map(ExtractStyleProp::Static));
+ if earlier.is_empty() {
+ return own;
+ }
+ let mut composition = crate::composition::Composition::default();
+ composition.apply(ast_builder, earlier);
+ composition.apply(ast_builder, own);
+ composition.into_props()
+}
+
+/// The classes and attrs a component extending `inherited` starts from
+fn inherited_parts<'a>(
+ ast_builder: &AstBuilder<'a>,
+ inherited: Option<&StyledDefinition<'a>>,
+ attrs: &[Expression<'a>],
+) -> (Vec>, Vec>) {
+ let allocator = ast_builder.allocator();
+ let (classes, mut all_attrs) = inherited.map_or_else(
+ || (Vec::new(), Vec::new()),
+ |inherited| {
+ (
+ inherited
+ .classes
+ .iter()
+ .map(|c| c.clone_in(allocator))
+ .collect(),
+ inherited
+ .attrs
+ .iter()
+ .map(|a| a.clone_in(allocator))
+ .collect(),
+ )
+ },
+ );
+ all_attrs.extend(attrs.iter().map(|attr| attr.clone_in(allocator)));
+ (classes, all_attrs)
+}
+
impl<'a> Base<'a> {
+ /// What `inherited` renders, in place of the component defining it
+ fn extending(
+ self,
+ ast_builder: &AstBuilder<'a>,
+ inherited: Option<&StyledDefinition<'a>>,
+ ) -> Self {
+ match inherited {
+ Some(inherited) => Self {
+ name: inherited.name.clone(),
+ target: inherited
+ .target
+ .as_ref()
+ .map(|target| target.clone_in(ast_builder.allocator())),
+ styles: None,
+ bound: inherited
+ .bound
+ .as_ref()
+ .map(|bound| bound.clone_in(ast_builder.allocator())),
+ },
+ None => self,
+ }
+ }
+
+ /// The definition of the component rendering this base
+ fn definition(
+ &self,
+ ast_builder: &AstBuilder<'a>,
+ classes: Vec>,
+ styles: &[ExtractStyleProp<'a>],
+ attrs: &[Expression<'a>],
+ props: (Reads, Option),
+ bindings: &[(String, Expression<'a>)],
+ ) -> StyledDefinition<'a> {
+ let allocator = ast_builder.allocator();
+ let (reads, forward) = props;
+ StyledDefinition {
+ name: self.name.clone(),
+ target: self
+ .target
+ .as_ref()
+ .map(|target| target.clone_in(allocator)),
+ bound: self.bound.as_ref().map(|bound| bound.clone_in(allocator)),
+ classes,
+ styles: styles
+ .iter()
+ .map(|style| style.clone_in(allocator))
+ .collect(),
+ attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(),
+ reads,
+ forward,
+ bindings: bindings
+ .iter()
+ .map(|(name, value)| (name.clone(), value.clone_in(allocator)))
+ .collect(),
+ }
+ }
+
+ /// The props a component rendering this base keeps away from it
+ fn withheld(&self, reads: &Reads, forward: Option<&Forward>) -> Vec {
+ withheld(reads, renders_tag(&self.name, self.bound.as_ref()), forward)
+ }
+
const fn named(name: String) -> Self {
Self {
name,
@@ -70,6 +442,29 @@ impl<'a> Base<'a> {
}
}
+/// The component `styled(Component)` extends, as written: the argument naming
+/// it in a tagged template `styled(Component)`, `styled(Component)({...})` or
+/// `styled(Component, {...})`
+#[must_use]
+pub fn extended<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b Expression<'a>> {
+ let factory = match expression {
+ Expression::TaggedTemplateExpression(tag) => &tag.tag,
+ Expression::CallExpression(call)
+ if matches!(
+ unwrap_syntax_only(&call.callee),
+ Expression::CallExpression(_)
+ ) =>
+ {
+ &call.callee
+ }
+ expression => expression,
+ };
+ let Expression::CallExpression(call) = unwrap_syntax_only(factory) else {
+ return None;
+ };
+ unwrap_syntax_only(call.arguments.first()?.as_expression()?).into()
+}
+
fn extract_base_tag_and_class_name<'a>(
ast_builder: &AstBuilder<'a>,
input: &Expression<'a>,
@@ -191,10 +586,19 @@ pub fn extract_style_from_styled<'a>(
expression: &mut Expression<'a>,
split_filename: Option<&str>,
imports: &FxHashMap,
- attrs: &[Expression<'a>],
+ modifiers: (&[Expression<'a>], Option),
+ inherited: Option<&StyledDefinition<'a>>,
helper: &mut Option,
-) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) {
- let original_scope = expression.clone_in(ast_builder.allocator());
+) -> StyledExtraction<'a> {
+ let (attrs, forward) = modifiers;
+ let mut original_scope = expression.clone_in(ast_builder.allocator());
+ let inherited = inherited.map(|definition| definition.rebind(ast_builder, &mut original_scope));
+ let inherited = inherited.as_ref();
+ let forward = combine_forward(inherited.and_then(|i| i.forward.as_ref()), forward);
+ let mut reads = inherited.map_or_else(Reads::default, |inherited| inherited.reads.clone());
+ for attr in attrs {
+ reads.read_in(attr);
+ }
let mut composed_classes = Vec::new();
let mut errors = Vec::new();
if let Expression::CallExpression(call) = expression
@@ -220,7 +624,8 @@ pub fn extract_style_from_styled<'a>(
None => {}
}
}
- let (result, new_expr) = if let Expression::TaggedTemplateExpression(tag) = expression
+ let (result, new_expr, definition) = if let Expression::TaggedTemplateExpression(tag) =
+ expression
&& let Some(mut base) = extract_base_tag_and_class_name(ast_builder, &tag.tag, imports)
{
// Case 1: styled.div`css` or styled("div")`css`
@@ -240,13 +645,17 @@ pub fn extract_style_from_styled<'a>(
)
}));
if !errors.is_empty() {
- return (
- ExtractResult::default(),
- Expression::new_string_literal(SPAN, "", None, ast_builder),
+ return StyledExtraction {
+ result: ExtractResult::default(),
+ expression: Expression::new_string_literal(SPAN, "", None, ast_builder),
errors,
- );
+ definition: None,
+ };
}
+ for interpolation in &tag.quasi.expressions {
+ reads.read_in(interpolation);
+ }
let TemplateStyles {
styles,
statements,
@@ -263,16 +672,16 @@ pub fn extract_style_from_styled<'a>(
.filter(|expression| crate::css_utils::reads_unmapped_theme(expression))
.map(|expression| crate::css_utils::unmapped_theme_error("styled", expression)),
);
- let mut props_styles: Vec> = styles
+ let own: Vec> = styles
.into_iter()
.map(|ex| ExtractStyleProp::Static(ex.into()))
.collect();
+ let defaults = base.styles.take();
+ let base = base.extending(ast_builder, inherited);
+ let mut props_styles = compose_styles(ast_builder, inherited, defaults, own);
+ let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs);
- if let Some(default_class_name) = base.styles.take() {
- props_styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static));
- }
-
- let mixins = statements.into_iter().map(|index| {
+ classes.extend(statements.into_iter().map(|index| {
let mixin = &tag.quasi.expressions[index];
if matches!(
unwrap_syntax_only(mixin),
@@ -293,18 +702,15 @@ pub fn extract_style_from_styled<'a>(
} else {
mixin.clone_in(ast_builder.allocator())
}
- });
+ }));
let class_name = merge_expression_for_class_name(
ast_builder,
- mixins
- .collect::>()
- .into_iter()
- .chain(gen_class_names(
- ast_builder,
- &mut props_styles,
- None,
- split_filename,
- )),
+ clone_all(ast_builder, &classes).chain(gen_class_names(
+ ast_builder,
+ &mut props_styles,
+ None,
+ split_filename,
+ )),
);
let tag = Some(Expression::new_string_literal(
SPAN,
@@ -312,17 +718,35 @@ pub fn extract_style_from_styled<'a>(
None,
ast_builder,
));
+ let bindings = inherited.map_or_else(Vec::new, |definition| {
+ definition
+ .bindings
+ .iter()
+ .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator())))
+ .collect()
+ });
+ let withheld = base.withheld(&reads, forward.as_ref());
+ let definition = base.definition(
+ ast_builder,
+ classes,
+ &props_styles,
+ &attrs,
+ (reads, forward),
+ &bindings,
+ );
let component = create_styled_component(
ast_builder,
&base,
StyledRender {
class_name: &class_name,
style_vars: &gen_styles(ast_builder, &props_styles, None),
- bindings: Vec::new(),
+ bindings,
+ withheld: &withheld,
},
helper,
);
- let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs));
+ let styled_component =
+ base.render(ast_builder, apply_attrs(ast_builder, component, &attrs));
let result = ExtractResult {
styles: props_styles,
@@ -332,7 +756,7 @@ pub fn extract_style_from_styled<'a>(
props: None,
};
- (Some(result), Some(styled_component))
+ (Some(result), Some(styled_component), Some(definition))
} else if let Expression::CallExpression(call) = expression
&& let Some((mut base, style_index)) =
resolve_styled_call_target(ast_builder, call, imports)
@@ -340,16 +764,30 @@ pub fn extract_style_from_styled<'a>(
// Case 2: styled.div({ bg: "red" }), styled("div")({ bg: "red" }),
// or styled("div", { bg: "red" })
+ reads.read_in(
+ if let Argument::SpreadElement(spread) = &call.arguments[style_index] {
+ &spread.argument
+ } else {
+ call.arguments[style_index].to_expression()
+ },
+ );
let prepared = call.arguments[style_index]
.as_expression()
.and_then(|rules| prepare(ast_builder, rules, &original_scope));
- let bindings = match prepared {
+ let mut bindings: Vec<_> = inherited.map_or_else(Vec::new, |definition| {
+ definition
+ .bindings
+ .iter()
+ .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator())))
+ .collect()
+ });
+ bindings.extend(match prepared {
Some(prepared) => {
call.arguments[style_index] = Argument::from(prepared.rules);
prepared.bindings
}
None => Vec::new(),
- };
+ });
// Extract styles from object expression
let ExtractResult {
mut styles,
@@ -377,19 +815,35 @@ pub fn extract_style_from_styled<'a>(
build_time_error("styled", &code, requirement.unwrap_or(STYLE_OBJECT)),
)
}));
- if let Some(default_class_name) = base.styles.take() {
- styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static));
+ if let Some(order) = style_order {
+ for style in &mut styles {
+ crate::composition::set_prop_order(style, order);
+ }
}
+ let defaults = base.styles.take();
+ let base = base.extending(ast_builder, inherited);
+ let mut styles = compose_styles(ast_builder, inherited, defaults, styles);
+ let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs);
+ classes.extend(composed_classes);
let class_name = merge_expression_for_class_name(
ast_builder,
- composed_classes.into_iter().chain(gen_class_names(
+ clone_all(ast_builder, &classes).chain(gen_class_names(
ast_builder,
&mut styles,
- style_order,
+ None,
split_filename,
)),
);
+ let withheld = base.withheld(&reads, forward.as_ref());
+ let definition = base.definition(
+ ast_builder,
+ classes,
+ &styles,
+ &attrs,
+ (reads, forward),
+ &bindings,
+ );
let component = create_styled_component(
ast_builder,
&base,
@@ -397,10 +851,12 @@ pub fn extract_style_from_styled<'a>(
class_name: &class_name,
style_vars: &gen_styles(ast_builder, &styles, None),
bindings,
+ withheld: &withheld,
},
helper,
);
- let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs));
+ let styled_component =
+ base.render(ast_builder, apply_attrs(ast_builder, component, &attrs));
let result = ExtractResult {
styles,
@@ -410,7 +866,7 @@ pub fn extract_style_from_styled<'a>(
props,
};
- (Some(result), Some(styled_component))
+ (Some(result), Some(styled_component), Some(definition))
} else {
// Left as written it would call `styled` at runtime, which only the build runs
let code = match &*expression {
@@ -421,13 +877,23 @@ pub fn extract_style_from_styled<'a>(
expression.span().start,
build_time_error("styled", &code, STYLED_FACTORY),
));
- (None, None)
+ (None, None, None)
};
- (
- result.unwrap_or_else(ExtractResult::default),
- new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())),
+ StyledExtraction {
+ result: result.unwrap_or_else(ExtractResult::default),
+ expression: new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())),
errors,
- )
+ definition,
+ }
+}
+
+fn clone_all<'s, 'a>(
+ ast_builder: &'s AstBuilder<'a>,
+ expressions: &'s [Expression<'a>],
+) -> impl Iterator> + 's {
+ expressions
+ .iter()
+ .map(|expression| expression.clone_in(ast_builder.allocator()))
}
/// The name the attrs wrapper binds props to, chosen not to shadow what the
@@ -436,15 +902,16 @@ const ATTRS_PROPS: &str = "__devupProps";
/// Strip styled-components' `.attrs()` / `.withConfig()` off a styled factory
/// such as `styled.div.attrs(a).withConfig(c)`, returning the attrs in the
-/// order they apply. `withConfig` only tunes runtime behavior, so it is dropped.
+/// order they apply, and the `withConfig` options in the order they apply
pub fn take_styled_modifiers<'a>(
ast_builder: &AstBuilder<'a>,
factory: &mut Expression<'a>,
is_styled: impl Fn(&str) -> bool,
-) -> Vec> {
+) -> (Vec>, Vec>) {
let mut attrs = Vec::new();
+ let mut configs = Vec::new();
if !is_modified_styled(factory, is_styled) {
- return attrs;
+ return (attrs, configs);
}
while let Expression::CallExpression(call) = unwrap_syntax_only_mut(factory)
&& let CallExpression {
@@ -454,18 +921,62 @@ pub fn take_styled_modifiers<'a>(
&& matches!(member.property.name.as_str(), "attrs" | "withConfig")
{
let placeholder = || Expression::new_null_literal(SPAN, ast_builder);
- if member.property.name == "attrs"
- && let Some(argument) = arguments[0].as_expression_mut()
- {
- attrs.push(std::mem::replace(argument, placeholder()));
+ if let Some(argument) = arguments[0].as_expression_mut() {
+ let argument = std::mem::replace(argument, placeholder());
+ if member.property.name == "attrs" {
+ attrs.push(argument);
+ } else {
+ configs.push(argument);
+ }
}
let object = std::mem::replace(&mut member.object, placeholder());
*factory = object;
}
attrs.reverse();
- attrs
+ configs.reverse();
+ (attrs, configs)
}
+/// The `shouldForwardProp` the options objects give, the later applying after
+/// the earlier, with the offset and code of one the build cannot evaluate
+pub fn read_forward(options: &[&Expression<'_>]) -> (Option, Option<(u32, String)>) {
+ let mut forward = None;
+ for options in options {
+ let Expression::ObjectExpression(object) = unwrap_syntax_only(options) else {
+ continue;
+ };
+ for property in &object.properties {
+ let ObjectPropertyKind::ObjectProperty(property) = property else {
+ continue;
+ };
+ if property.computed
+ || property.key.static_name().as_deref() != Some("shouldForwardProp")
+ {
+ continue;
+ }
+ match Forward::read(&property.value) {
+ Some(own) => forward = combine_forward(forward.as_ref(), Some(own)),
+ None => {
+ return (
+ forward,
+ Some((
+ property.value.span().start,
+ build_time_error(
+ "styled",
+ &readable_code(&property.value),
+ SHOULD_FORWARD_PROP,
+ ),
+ )),
+ );
+ }
+ }
+ }
+ }
+ (forward, None)
+}
+
+const SHOULD_FORWARD_PROP: &str = "`shouldForwardProp` must be a function of the prop name that compares it with strings, `[...].includes(prop)`, `prop.startsWith(...)` or `isPropValid(prop)`, joined by `!`, `&&` and `||`";
+
fn is_modified_styled(expression: &Expression<'_>, is_styled: impl Fn(&str) -> bool) -> bool {
let mut expression = unwrap_syntax_only(expression);
let mut modified = false;
@@ -509,44 +1020,215 @@ fn apply_attrs<'a>(
if attrs.is_empty() {
return component;
}
- let props = || Expression::new_identifier(SPAN, ATTRS_PROPS, ast_builder);
- let mut merged = props();
+ let merged = if attrs.iter().all(sets_plain_props) {
+ spread_attrs(ast_builder, attrs)
+ } else {
+ merge_attrs(ast_builder, attrs)
+ };
+ props_arrow(
+ ast_builder,
+ wrap_direct_call(ast_builder, &component, &[merged]),
+ )
+}
+
+/// The binding each attrs step reads the props merged so far through
+const ATTRS_CONTEXT: &str = "__devupContext";
+/// The binding each attrs step reads what its attrs give through
+const ATTRS_VALUE: &str = "__devupAttrs";
+
+/// Whether `attr` is an object setting props other than `className` and
+/// `style` by name, which spreading over the props merges as attrs do
+fn sets_plain_props(attr: &Expression<'_>) -> bool {
+ let Expression::ObjectExpression(object) = unwrap_syntax_only(attr) else {
+ return false;
+ };
+ object.properties.iter().all(|property| {
+ matches!(property, ObjectPropertyKind::ObjectProperty(property)
+ if !property.computed
+ && property
+ .key
+ .static_name()
+ .is_some_and(|name| name != "className" && name != "style"))
+ })
+}
+
+/// The props with each attrs spread over them in order
+fn spread_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> {
+ let mut merged = identifier(ast_builder, ATTRS_PROPS);
for attr in attrs {
- let attr = attr.clone_in(ast_builder.allocator());
- if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) {
- merged = spread_objects(ast_builder, merged, attr);
- continue;
- }
- let called = wrap_direct_call(ast_builder, &attr, &[props()]);
- let resolved = if matches!(
- unwrap_syntax_only(&attr),
- Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
- ) {
- called
- } else {
- let is_function = Expression::new_binary_expression(
+ merged = spread_objects(ast_builder, merged, attr.clone_in(ast_builder.allocator()));
+ }
+ merged
+}
+
+/// The props merged as styled-components merges attrs: each attrs, or what
+/// calling it with the props merged so far gives, joins its `className` to
+/// theirs, merges its `style` over theirs and replaces their other props; the
+/// caller's `className` comes last
+fn merge_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> {
+ let mut merged = with_property(
+ ast_builder,
+ identifier(ast_builder, ATTRS_PROPS),
+ "className",
+ identifier(ast_builder, "undefined"),
+ );
+ for attr in attrs {
+ let resolved = spread_objects(
+ ast_builder,
+ Expression::new_object_expression(
SPAN,
- Expression::new_unary_expression(
- SPAN,
- UnaryOperator::Typeof,
- attr.clone_in(ast_builder.allocator()),
- ast_builder,
- ),
- BinaryOperator::StrictEquality,
- Expression::new_string_literal(SPAN, "function", None, ast_builder),
+ oxc_allocator::Vec::new_in(ast_builder),
ast_builder,
- );
- Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder)
- };
- let step = props_arrow(ast_builder, spread_objects(ast_builder, props(), resolved));
+ ),
+ resolve_attrs(ast_builder, attr.clone_in(ast_builder.allocator())),
+ );
+ let merge = call_with_values(
+ ast_builder,
+ vec![(ATTRS_VALUE.to_string(), resolved)],
+ merge_attrs_value(ast_builder),
+ );
+ let step = named_arrow(ast_builder, ATTRS_CONTEXT, merge);
merged = wrap_direct_call(ast_builder, &step, &[merged]);
}
- props_arrow(
+ let caller_class = joined_class(
+ ast_builder,
+ member(ast_builder, ATTRS_CONTEXT, "className"),
+ member(ast_builder, ATTRS_PROPS, "className"),
+ );
+ let last = named_arrow(
+ ast_builder,
+ ATTRS_CONTEXT,
+ with_property(
+ ast_builder,
+ identifier(ast_builder, ATTRS_CONTEXT),
+ "className",
+ caller_class,
+ ),
+ );
+ wrap_direct_call(ast_builder, &last, &[merged])
+}
+
+/// What `attr` gives: itself when it is an object, or what calling it with
+/// the props merged so far gives when it is a function
+fn resolve_attrs<'a>(ast_builder: &AstBuilder<'a>, attr: Expression<'a>) -> Expression<'a> {
+ if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) {
+ return attr;
+ }
+ let called = wrap_direct_call(
+ ast_builder,
+ &attr,
+ &[identifier(ast_builder, ATTRS_CONTEXT)],
+ );
+ if matches!(
+ unwrap_syntax_only(&attr),
+ Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
+ ) {
+ return called;
+ }
+ let is_function = Expression::new_binary_expression(
+ SPAN,
+ Expression::new_unary_expression(
+ SPAN,
+ UnaryOperator::Typeof,
+ attr.clone_in(ast_builder.allocator()),
+ ast_builder,
+ ),
+ BinaryOperator::StrictEquality,
+ Expression::new_string_literal(SPAN, "function", None, ast_builder),
+ ast_builder,
+ );
+ Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder)
+}
+
+/// `{ ...context, ...attrs, className, style }` with the classes joined and
+/// the styles merged
+fn merge_attrs_value<'a>(ast_builder: &AstBuilder<'a>) -> Expression<'a> {
+ let merged = spread_objects(
+ ast_builder,
+ identifier(ast_builder, ATTRS_CONTEXT),
+ identifier(ast_builder, ATTRS_VALUE),
+ );
+ let class_name = joined_class(
+ ast_builder,
+ member(ast_builder, ATTRS_CONTEXT, "className"),
+ member(ast_builder, ATTRS_VALUE, "className"),
+ );
+ let style = spread_objects(
+ ast_builder,
+ member(ast_builder, ATTRS_CONTEXT, "style"),
+ member(ast_builder, ATTRS_VALUE, "style"),
+ );
+ let merged = with_property(ast_builder, merged, "className", class_name);
+ with_property(ast_builder, merged, "style", style)
+}
+
+fn identifier<'a>(ast_builder: &AstBuilder<'a>, name: &'static str) -> Expression<'a> {
+ Expression::new_identifier(SPAN, name, ast_builder)
+}
+
+fn member<'a>(
+ ast_builder: &AstBuilder<'a>,
+ object: &'static str,
+ name: &'static str,
+) -> Expression<'a> {
+ Expression::StaticMemberExpression(oxc_ast::ast::StaticMemberExpression::boxed(
+ SPAN,
+ identifier(ast_builder, object),
+ oxc_ast::ast::IdentifierName::new(SPAN, name, ast_builder),
+ false,
+ ast_builder,
+ ))
+}
+
+/// `[first, second].filter(Boolean).join(" ") || undefined`
+fn joined_class<'a>(
+ ast_builder: &AstBuilder<'a>,
+ first: Expression<'a>,
+ second: Expression<'a>,
+) -> Expression<'a> {
+ let joined = wrap_array_filter(ast_builder, &[first, second])
+ .unwrap_or_else(|| identifier(ast_builder, "undefined"));
+ Expression::new_logical_expression(
+ SPAN,
+ joined,
+ LogicalOperator::Or,
+ identifier(ast_builder, "undefined"),
ast_builder,
- wrap_direct_call(ast_builder, &component, &[merged]),
)
}
+/// `object` with `name` set to `value`: an object spreading it, then setting
+/// the property
+fn with_property<'a>(
+ ast_builder: &AstBuilder<'a>,
+ object: Expression<'a>,
+ name: &'static str,
+ value: Expression<'a>,
+) -> Expression<'a> {
+ let mut properties = match object {
+ Expression::ObjectExpression(object) => object.unbox().properties,
+ object => oxc_allocator::Vec::from_array_in(
+ [ObjectPropertyKind::new_spread_property(
+ SPAN,
+ object,
+ ast_builder,
+ )],
+ ast_builder,
+ ),
+ };
+ properties.push(ObjectPropertyKind::new_object_property(
+ SPAN,
+ oxc_ast::ast::PropertyKind::Init,
+ PropertyKey::new_static_identifier(SPAN, name, ast_builder),
+ value,
+ false,
+ false,
+ false,
+ ast_builder,
+ ));
+ Expression::new_object_expression(SPAN, properties, ast_builder)
+}
+
fn spread_objects<'a>(
ast_builder: &AstBuilder<'a>,
first: Expression<'a>,
@@ -567,10 +1249,19 @@ fn spread_objects<'a>(
}
fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expression<'a> {
+ named_arrow(ast_builder, ATTRS_PROPS, body)
+}
+
+/// `(name) => body`
+fn named_arrow<'a>(
+ ast_builder: &AstBuilder<'a>,
+ name: &'static str,
+ body: Expression<'a>,
+) -> Expression<'a> {
let parameter = FormalParameter::new(
SPAN,
oxc_allocator::Vec::new_in(ast_builder),
- BindingPattern::new_binding_identifier(SPAN, ATTRS_PROPS, ast_builder),
+ BindingPattern::new_binding_identifier(SPAN, name, ast_builder),
None::>>,
None::>>,
false,
@@ -597,10 +1288,87 @@ fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expres
)
}
+/// `rest` without the props in `withheld`, as `(({ a: _0, ...p }) => p)(rest)`
+fn without_props<'a>(ast_builder: &AstBuilder<'a>, withheld: &[String]) -> Expression<'a> {
+ let rest = Expression::new_identifier(SPAN, "rest", ast_builder);
+ if withheld.is_empty() {
+ return rest;
+ }
+ let properties = withheld.iter().enumerate().map(|(index, name)| {
+ BindingProperty::new(
+ SPAN,
+ PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed(
+ SPAN,
+ Str::from_in(name.as_str(), ast_builder.allocator()),
+ None,
+ ast_builder,
+ )),
+ BindingPattern::new_binding_identifier(
+ SPAN,
+ Str::from_in(
+ format!("__devupOmit{index}").as_str(),
+ ast_builder.allocator(),
+ ),
+ ast_builder,
+ ),
+ false,
+ false,
+ ast_builder,
+ )
+ });
+ let pattern = BindingPattern::new_object_pattern(
+ SPAN,
+ oxc_allocator::Vec::from_iter_in(properties, ast_builder),
+ Some(BindingRestElement::boxed(
+ SPAN,
+ BindingPattern::new_binding_identifier(SPAN, "__devupDom", ast_builder),
+ ast_builder,
+ )),
+ ast_builder,
+ );
+ let parameter = FormalParameter::new(
+ SPAN,
+ oxc_allocator::Vec::new_in(ast_builder),
+ pattern,
+ None::>>,
+ None::>>,
+ false,
+ None,
+ false,
+ false,
+ ast_builder,
+ );
+ let arrow = Expression::new_arrow_function_expression(
+ SPAN,
+ false,
+ None::>>,
+ FormalParameters::boxed(
+ SPAN,
+ FormalParameterKind::ArrowFormalParameters,
+ oxc_allocator::Vec::from_iter_in([parameter], ast_builder),
+ None::>>,
+ ast_builder,
+ ),
+ None::>>,
+ Expression::new_identifier(SPAN, "__devupDom", ast_builder).into(),
+ ast_builder,
+ );
+ wrap_direct_call(
+ ast_builder,
+ &Expression::new_parenthesized_expression(SPAN, arrow, ast_builder),
+ &[rest],
+ )
+}
+
+/// The binding a styled component renders through: what `as` names, or the
+/// tag or component it was defined with
+const RENDERED: &str = "DevupAs";
+
struct StyledRender<'b, 'a> {
class_name: &'b Option>,
style_vars: &'b Option>,
bindings: Vec<(String, Expression<'a>)>,
+ withheld: &'b [String],
}
fn create_styled_component<'a>(
@@ -613,6 +1381,7 @@ fn create_styled_component<'a>(
class_name,
style_vars,
bindings,
+ withheld,
} = render;
let params = FormalParameters::boxed(
SPAN,
@@ -645,6 +1414,49 @@ fn create_styled_component<'a>(
false,
ast_builder,
),
+ BindingProperty::new(
+ SPAN,
+ PropertyKey::new_static_identifier(SPAN, "as", ast_builder),
+ BindingPattern::new_assignment_pattern(
+ SPAN,
+ BindingPattern::new_binding_identifier(
+ SPAN,
+ RENDERED,
+ ast_builder,
+ ),
+ base.target.as_ref().map_or_else(
+ || {
+ Expression::new_string_literal(
+ SPAN,
+ Str::from_in(&base.name, ast_builder.allocator()),
+ None,
+ ast_builder,
+ )
+ },
+ |target| target.clone_in(ast_builder.allocator()),
+ ),
+ ast_builder,
+ ),
+ false,
+ false,
+ ast_builder,
+ ),
+ BindingProperty::new(
+ SPAN,
+ PropertyKey::new_static_identifier(
+ SPAN,
+ "forwardedAs",
+ ast_builder,
+ ),
+ BindingPattern::new_binding_identifier(
+ SPAN,
+ "forwardedAs",
+ ast_builder,
+ ),
+ true,
+ false,
+ ast_builder,
+ ),
],
ast_builder,
),
@@ -695,34 +1507,37 @@ fn create_styled_component<'a>(
},
);
let body = if let Some(helper) = helper {
- let target = base.target.as_ref().map_or_else(
- || {
- Expression::new_string_literal(
- SPAN,
- Str::from_in(&base.name, ast_builder.allocator()),
- None,
- ast_builder,
- )
- },
- |target| target.clone_in(ast_builder.allocator()),
- );
- helper.render(ast_builder, target, (class_name, style))
+ helper.render(
+ ast_builder,
+ identifier(ast_builder, RENDERED),
+ (class_name, style, identifier(ast_builder, "forwardedAs")),
+ super::styled_transient::filter(ast_builder, without_props(ast_builder, withheld)),
+ )
} else {
Expression::new_jsx_element(
SPAN,
JSXOpeningElement::boxed(
SPAN,
- JSXElementName::new_identifier(
- SPAN,
- Str::from_in(&base.name, ast_builder.allocator()),
- ast_builder,
- ),
+ JSXElementName::new_identifier(SPAN, RENDERED, ast_builder),
None::>>,
oxc_allocator::Vec::from_iter_in(
vec![
JSXAttributeItem::new_spread_attribute(
SPAN,
- Expression::new_identifier(SPAN, "rest", ast_builder),
+ super::styled_transient::filter(
+ ast_builder,
+ without_props(ast_builder, withheld),
+ ),
+ ast_builder,
+ ),
+ JSXAttributeItem::new_attribute(
+ SPAN,
+ JSXAttributeName::new_identifier(SPAN, "as", ast_builder),
+ Some(JSXAttributeValue::new_expression_container(
+ SPAN,
+ identifier(ast_builder, "forwardedAs").into(),
+ ast_builder,
+ )),
ast_builder,
),
JSXAttributeItem::new_attribute(
diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs
index d95289c0d..09b9c1b27 100644
--- a/libs/extractor/src/extractor/mod.rs
+++ b/libs/extractor/src/extractor/mod.rs
@@ -14,6 +14,7 @@ mod styled_record;
mod styled_record_evaluation;
#[cfg(test)]
mod styled_record_tests;
+mod styled_transient;
/**
* type
diff --git a/libs/extractor/src/extractor/styled_jsx.rs b/libs/extractor/src/extractor/styled_jsx.rs
index 75af546ef..1616e8013 100644
--- a/libs/extractor/src/extractor/styled_jsx.rs
+++ b/libs/extractor/src/extractor/styled_jsx.rs
@@ -59,16 +59,13 @@ impl StyledJsx {
&mut self,
ast: &AstBuilder<'a>,
target: Expression<'a>,
- props: (Expression<'a>, Expression<'a>),
+ props: (Expression<'a>, Expression<'a>, Expression<'a>),
+ rest: Expression<'a>,
) -> Expression<'a> {
self.used = true;
- let mut properties = Vec::with_capacity_in(3, ast);
- properties.push(ObjectPropertyKind::new_spread_property(
- SPAN,
- Expression::new_identifier(SPAN, "rest", ast),
- ast,
- ));
- for (key, value) in [("className", props.0), ("style", props.1)] {
+ let mut properties = Vec::with_capacity_in(4, ast);
+ properties.push(ObjectPropertyKind::new_spread_property(SPAN, rest, ast));
+ for (key, value) in [("className", props.0), ("style", props.1), ("as", props.2)] {
properties.push(ObjectPropertyKind::new_object_property(
SPAN,
PropertyKind::Init,
diff --git a/libs/extractor/src/extractor/styled_jsx_tests.rs b/libs/extractor/src/extractor/styled_jsx_tests.rs
index 024c57303..cf5ad2889 100644
--- a/libs/extractor/src/extractor/styled_jsx_tests.rs
+++ b/libs/extractor/src/extractor/styled_jsx_tests.rs
@@ -22,7 +22,9 @@ fn commonjs_binding_resolves_factory_when_renderer_is_used() {
(
Expression::new_identifier(SPAN, "className", &ast),
Expression::new_identifier(SPAN, "style", &ast),
+ Expression::new_string_literal(SPAN, "nav", None, &ast),
),
+ Expression::new_identifier(SPAN, "rest", &ast),
);
// When: the real binding and renderer execute against a CommonJS factory export.
program.body.push(
@@ -46,7 +48,7 @@ fn commonjs_binding_resolves_factory_when_renderer_is_used() {
.as_string()
.unwrap_or_else(|| panic!("CommonJS renderer JSON result must be a string"))
.to_std_string_escaped(),
- r#"{"target":"article","props":{"children":"child","className":"caller","style":{"color":"olive"}}}"#,
+ r#"{"target":"article","props":{"children":"child","className":"caller","style":{"color":"olive"},"as":"nav"}}"#,
);
}
diff --git a/libs/extractor/src/extractor/styled_record_evaluation.rs b/libs/extractor/src/extractor/styled_record_evaluation.rs
index 6a2ac15c8..6dc227fcf 100644
--- a/libs/extractor/src/extractor/styled_record_evaluation.rs
+++ b/libs/extractor/src/extractor/styled_record_evaluation.rs
@@ -2,7 +2,7 @@ use oxc_allocator::{CloneIn, FromIn, GetAllocator};
use oxc_ast::{ast::Expression, builder::AstBuilder};
use oxc_span::{GetSpan, SPAN};
-use crate::utils::{call_with_values, is_same_expression};
+use crate::utils::is_same_expression;
/// Ordered reads shared by the class and variable expressions of one render.
pub(super) struct Evaluations<'b, 'a> {
@@ -23,13 +23,7 @@ impl<'b, 'a> Evaluations<'b, 'a> {
}
pub fn capture(&mut self, value: &Expression<'a>, guard: &Expression<'a>) -> Expression<'a> {
- let name = loop {
- let name = format!("__devupRecord{}", self.next);
- self.next += 1;
- if !self.source.contains(&name) {
- break name;
- }
- };
+ let name = self.fresh_name();
let value = self.choose(guard, value, &self.undefined());
self.bindings.push((name.clone(), value));
Expression::new_identifier(
@@ -39,6 +33,16 @@ impl<'b, 'a> Evaluations<'b, 'a> {
)
}
+ pub fn fresh_name(&mut self) -> String {
+ loop {
+ let name = format!("__devupRecord{}", self.next);
+ self.next += 1;
+ if !self.source.contains(&name) {
+ return name;
+ }
+ }
+ }
+
pub fn boolean(&self, value: bool) -> Expression<'a> {
Expression::new_boolean_literal(SPAN, value, self.builder)
}
@@ -73,76 +77,161 @@ impl<'b, 'a> Evaluations<'b, 'a> {
}
}
-pub(super) fn bind_render<'a>(
+fn visit_style_value_expression<'a>(
builder: &AstBuilder<'a>,
- bindings: Vec<(String, Expression<'a>)>,
- mut body: Expression<'a>,
-) -> Expression<'a> {
- // Dependent arguments must be inside the scope of the preceding read.
- for binding in bindings.into_iter().rev() {
- body = call_with_values(builder, vec![binding], body);
+ style: &mut crate::ExtractStyleValue,
+ visit: &mut impl FnMut(&mut Expression<'a>),
+) {
+ use crate::ExtractStyleValue;
+ match style {
+ ExtractStyleValue::Dynamic(value) => {
+ let mut expression = Expression::new_identifier(
+ SPAN,
+ oxc_ast::ast::Ident::from_in(value.identifier(), builder.allocator()),
+ builder,
+ );
+ visit(&mut expression);
+ *value.identifier_mut() = crate::utils::readable_code(&expression);
+ }
+ ExtractStyleValue::Static(_)
+ | ExtractStyleValue::Typography(_)
+ | ExtractStyleValue::Css(_)
+ | ExtractStyleValue::Import(_)
+ | ExtractStyleValue::FontFace(_)
+ | ExtractStyleValue::Keyframes(_) => {}
}
- body
}
-#[cfg(test)]
-mod tests {
- use boa_engine::{Context, Source};
- use oxc_allocator::Allocator;
- use oxc_ast::ast::Statement;
- use oxc_parser::Parser;
- use oxc_span::SourceType;
- use rstest::rstest;
-
- use super::*;
- use crate::utils::readable_code;
-
- #[rstest]
- #[case::equal_true(true, true)]
- #[case::equal_false(false, false)]
- #[case::different_true(true, false)]
- #[case::different_false(false, true)]
- fn boolean_choice_when_test_is_captured(
- #[case] yes: bool,
- #[case] no: bool,
- #[values(false, true)] on: bool,
- ) {
- let allocator = Allocator::default();
- let builder = AstBuilder::new(&allocator);
- let source = "(trace.push('test'), on);";
- let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse();
- assert_eq!(parsed.diagnostics.len(), 0);
- let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else {
- panic!("test fixture must be an expression");
- };
- let mut reads = Evaluations::new(&builder, source.to_string());
- let test = reads.capture(&statement.expression, &reads.boolean(true));
- let choice = reads.choose(&test, &reads.boolean(yes), &reads.boolean(no));
- match &choice {
- Expression::BooleanLiteral(value) => {
- assert_eq!(yes, no);
- assert_eq!(value.value, yes);
+pub(super) fn visit_style_expressions<'a>(
+ builder: &AstBuilder<'a>,
+ style: &mut crate::ExtractStyleProp<'a>,
+ visit: &mut impl FnMut(&mut Expression<'a>),
+) {
+ use crate::ExtractStyleProp;
+ match style {
+ ExtractStyleProp::Static(value) => visit_style_value_expression(builder, value, visit),
+ ExtractStyleProp::Unreadable { .. } => {}
+ ExtractStyleProp::StaticArray(styles) => {
+ for style in styles {
+ visit_style_expressions(builder, style, visit);
+ }
+ }
+ ExtractStyleProp::Conditional {
+ condition,
+ consequent,
+ alternate,
+ } => {
+ visit(condition);
+ for style in consequent.iter_mut().chain(alternate.iter_mut()) {
+ visit_style_expressions(builder, style, visit);
+ }
+ }
+ ExtractStyleProp::Enum { condition, map } => {
+ visit(condition);
+ for style in map.values_mut().flatten() {
+ visit_style_expressions(builder, style, visit);
+ }
+ }
+ ExtractStyleProp::Expression { expression, styles } => {
+ visit(expression);
+ for style in styles {
+ visit_style_value_expression(builder, style, visit);
}
- Expression::ConditionalExpression(_) => assert_ne!(yes, no),
- _ => panic!("Boolean choice must be constant or conditional"),
}
- let render = bind_render(&builder, reads.bindings, choice);
- let script = format!(
- "const trace = []; const on = {on}; const result = {}; JSON.stringify([result, trace]);",
- readable_code(&render)
+ ExtractStyleProp::MemberExpression { expression, map } => {
+ visit(expression);
+ for style in map.values_mut() {
+ visit_style_expressions(builder, style, visit);
+ }
+ }
+ }
+}
+
+pub(super) fn bind_render<'a>(
+ builder: &AstBuilder<'a>,
+ bindings: Vec<(String, Expression<'a>)>,
+ body: Expression<'a>,
+) -> Expression<'a> {
+ use oxc_allocator::Vec as ArenaVec;
+ use oxc_ast::ast::{
+ ArrowFunctionBody, BindingPattern, FormalParameterKind, FormalParameters, Statement,
+ VariableDeclaration, VariableDeclarationKind, VariableDeclarator,
+ };
+ if bindings.is_empty() {
+ return body;
+ }
+ // Sibling declarations preserve dependent read order without one scope per capture.
+ let mut statements = ArenaVec::with_capacity_in(bindings.len() + 1, builder);
+ for (name, value) in bindings {
+ let name = oxc_ast::ast::Ident::from_in(name.as_str(), builder.allocator());
+ // Sequence evaluation prevents declaration-name inference for anonymous definitions.
+ let value = Expression::new_sequence_expression(
+ SPAN,
+ ArenaVec::from_array_in(
+ [
+ Expression::new_numeric_literal(
+ SPAN,
+ 0.0,
+ None,
+ oxc_syntax::number::NumberBase::Decimal,
+ builder,
+ ),
+ value,
+ ],
+ builder,
+ ),
+ builder,
);
- let mut context = Context::default();
- let result = match context.eval(Source::from_bytes(script.as_bytes())) {
- Ok(result) => result,
- Err(error) => panic!("Boolean choice evaluation failed: {error}"),
- };
- let result = match result.as_string() {
- Some(result) => result.to_std_string_escaped(),
- None => panic!("Boolean choice must return a JSON string"),
- };
- assert_eq!(
- result,
- format!(r#"[{},["test"]]"#, if on { yes } else { no })
+ let declaration = VariableDeclarator::new(
+ SPAN,
+ BindingPattern::new_binding_identifier(SPAN, name, builder),
+ None,
+ Some(value),
+ false,
+ builder,
);
+ statements.push(Statement::VariableDeclaration(VariableDeclaration::boxed(
+ SPAN,
+ VariableDeclarationKind::Const,
+ ArenaVec::from_array_in([declaration], builder),
+ false,
+ builder,
+ )));
}
+ statements.push(Statement::new_return_statement(SPAN, Some(body), builder));
+ let arrow = Expression::new_arrow_function_expression(
+ SPAN,
+ false,
+ None,
+ FormalParameters::boxed(
+ SPAN,
+ FormalParameterKind::ArrowFormalParameters,
+ ArenaVec::new_in(builder),
+ None,
+ builder,
+ ),
+ None,
+ ArrowFunctionBody::new_function_body(SPAN, ArenaVec::new_in(builder), statements, builder),
+ builder,
+ );
+ Expression::new_call_expression(
+ SPAN,
+ Expression::new_parenthesized_expression(SPAN, arrow, builder),
+ None,
+ ArenaVec::new_in(builder),
+ false,
+ builder,
+ )
}
+
+#[cfg(test)]
+#[path = "styled_record_evaluation_visitor_tests.rs"]
+mod visitor_tests;
+
+#[cfg(test)]
+#[path = "styled_record_evaluation_depth_tests.rs"]
+mod depth_tests;
+
+#[cfg(test)]
+#[path = "styled_record_evaluation_tests.rs"]
+mod tests;
diff --git a/libs/extractor/src/extractor/styled_record_evaluation_depth_tests.rs b/libs/extractor/src/extractor/styled_record_evaluation_depth_tests.rs
new file mode 100644
index 000000000..77d53d175
--- /dev/null
+++ b/libs/extractor/src/extractor/styled_record_evaluation_depth_tests.rs
@@ -0,0 +1,185 @@
+use boa_engine::{Context, Source};
+use oxc_allocator::{Allocator, CloneIn};
+use oxc_ast::{ast::Statement, builder::AstBuilder};
+use oxc_parser::Parser;
+use oxc_span::SourceType;
+use rstest::rstest;
+
+use super::{Evaluations, bind_render};
+use crate::utils::{call_with_values, readable_code};
+
+#[rstest]
+fn ordered_captures_when_many_guarded_reads_depend_on_prior_bindings(
+ #[values(false, true)] outer: bool,
+ #[values(false, true)] inner: bool,
+) {
+ // Given: shallow authored expressions, reserved names and dependent guarded reads.
+ let allocator = Allocator::default();
+ let builder = AstBuilder::new(&allocator);
+ let mut reads = Evaluations::new(&builder, "__devupRecord0 __devupRecord1".to_string());
+ let parse = |source: &str| {
+ let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse();
+ assert_eq!(parsed.diagnostics.len(), 0);
+ let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else {
+ panic!("capture fixture must be an expression");
+ };
+ statement.expression.clone_in(&allocator)
+ };
+ let outer_read = reads.capture(
+ &parse("(trace.push('outer'), outer);"),
+ &reads.boolean(true),
+ );
+ let inner_read = reads.capture(&parse("(trace.push('inner'), inner);"), &outer_read);
+ let guard = reads.choose(&outer_read, &inner_read, &reads.boolean(false));
+ let mut previous = "-1".to_string();
+ let mut values = Vec::new();
+ for index in 0..32 {
+ let value = reads.capture(
+ &parse(&format!("(trace.push('leaf:{index}'), {previous} + 1);")),
+ &guard,
+ );
+ previous = readable_code(&value);
+ values.push(previous.clone());
+ }
+ let body = parse(&format!(
+ "JSON.stringify([__devupRecord0,__devupRecord1,[{}].filter(v=>v!==undefined),trace]);",
+ values.join(",")
+ ));
+
+ // When: parse and execute production binding emission at the default test stack.
+ let render = bind_render(&builder, reads.bindings, body);
+ let script = format!(
+ "const trace=[];const outer={outer},inner={inner},__devupRecord0='authored',__devupRecord1='collision';{};",
+ readable_code(&render)
+ );
+ let mut context = Context::default();
+ let result = context
+ .eval(Source::from_bytes(script.as_bytes()))
+ .unwrap_or_else(|error| panic!("dependent capture execution failed: {error}"));
+ let result = result
+ .as_string()
+ .unwrap_or_else(|| panic!("dependent capture observation must be JSON"))
+ .to_std_string_escaped();
+
+ // Then: every selected read runs once in order; absent branches never read leaves.
+ let mut trace = vec!["outer".to_string()];
+ let mut expected_values = Vec::new();
+ if outer {
+ trace.push("inner".to_string());
+ if inner {
+ for index in 0..32 {
+ trace.push(format!("leaf:{index}"));
+ expected_values.push(index);
+ }
+ }
+ }
+ let expected = serde_json::to_string(&("authored", "collision", expected_values, trace))
+ .unwrap_or_else(|error| panic!("capture observation serialization failed: {error}"));
+ assert_eq!(result, expected);
+}
+
+#[rstest]
+#[case::class("class {static {trace.push(this.name)}}", "")]
+#[case::function("function(){trace.push(arguments.callee.name)}", "controller();")]
+fn anonymous_names_when_prepared_controller_is_bound(
+ #[case] initializer: &str,
+ #[case] observe: &str,
+ #[values(false, true)] bounded: bool,
+) {
+ // Given: the authored styled conditional spread reaches typed preparation.
+ let allocator = Allocator::default();
+ let builder = AstBuilder::new(&allocator);
+ let source = format!("styled.div({{color:'orange',...({initializer}?{{color:'red'}}:{{}})}});");
+ let parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse();
+ assert_eq!(parsed.diagnostics.len(), 0);
+ let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else {
+ panic!("styled fixture must be an expression");
+ };
+ let oxc_ast::ast::Expression::CallExpression(call) = &statement.expression else {
+ panic!("styled fixture must be a call");
+ };
+ let rules = call.arguments[0].to_expression();
+ let prepared = super::super::styled_record::prepare(&builder, rules, &statement.expression)
+ .unwrap_or_else(|| panic!("anonymous controller must require preparation"));
+ assert_eq!(prepared.bindings.len(), 1);
+ let body_source = format!(
+ "((record,controller)=>{{{observe}return JSON.stringify([record.color,controller.name,trace]);}})({},{});",
+ readable_code(&prepared.rules),
+ prepared.bindings[0].0
+ );
+ let body = Parser::new(&allocator, &body_source, SourceType::tsx()).parse();
+ assert_eq!(body.diagnostics.len(), 0);
+ let Statement::ExpressionStatement(body) = &body.program.body[0] else {
+ panic!("observation must be an expression");
+ };
+
+ // When: execute either the old argument seam or the production bounded binder.
+ let body = body.expression.clone_in(&allocator);
+ let render = if bounded {
+ bind_render(&builder, prepared.bindings, body)
+ } else {
+ call_with_values(&builder, prepared.bindings, body)
+ };
+ let script = format!("const trace=[];{};", readable_code(&render));
+ let mut context = Context::default();
+ let result = context
+ .eval(Source::from_bytes(script.as_bytes()))
+ .unwrap_or_else(|error| panic!("prepared anonymous controller execution failed: {error}"));
+ let result = result
+ .as_string()
+ .unwrap_or_else(|| panic!("anonymous controller observation must be JSON"))
+ .to_std_string_escaped();
+
+ // Then: later color wins and static/self-name observers see the original empty name.
+ assert_eq!(result, r#"["red","",[""]]"#);
+}
+
+#[rstest]
+#[case::class("class {static {trace.push(this.name)}}", "")]
+#[case::function("function(){trace.push(arguments.callee.name)}", "value();")]
+fn anonymous_names_when_capture_initializer_is_bound(
+ #[case] initializer: &str,
+ #[case] observe: &str,
+ #[values(false, true)] bounded: bool,
+) {
+ // Given: an anonymous expression captured under the production literal-true guard.
+ let allocator = Allocator::default();
+ let builder = AstBuilder::new(&allocator);
+ let source = format!("({initializer});");
+ let parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse();
+ assert_eq!(parsed.diagnostics.len(), 0);
+ let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else {
+ panic!("anonymous fixture must be an expression");
+ };
+ let mut reads = Evaluations::new(&builder, source.clone());
+ let value = reads.capture(&statement.expression, &reads.boolean(true));
+ let body_source = format!(
+ "(value=>{{{observe}return JSON.stringify([value.name,trace]);}})({});",
+ readable_code(&value)
+ );
+ let body = Parser::new(&allocator, &body_source, SourceType::tsx()).parse();
+ assert_eq!(body.diagnostics.len(), 0);
+ let Statement::ExpressionStatement(body) = &body.program.body[0] else {
+ panic!("observation must be an expression");
+ };
+
+ // When: execute either the old argument seam or the production bounded binder.
+ let body = body.expression.clone_in(&allocator);
+ let render = if bounded {
+ bind_render(&builder, reads.bindings, body)
+ } else {
+ call_with_values(&builder, reads.bindings, body)
+ };
+ let script = format!("const trace=[];{};", readable_code(&render));
+ let mut context = Context::default();
+ let result = context
+ .eval(Source::from_bytes(script.as_bytes()))
+ .unwrap_or_else(|error| panic!("anonymous capture execution failed: {error}"));
+ let result = result
+ .as_string()
+ .unwrap_or_else(|| panic!("anonymous capture observation must be JSON"))
+ .to_std_string_escaped();
+
+ // Then: both the value's name and its authored observer retain argument semantics.
+ assert_eq!(result, r#"["",[""]]"#);
+}
diff --git a/libs/extractor/src/extractor/styled_record_evaluation_tests.rs b/libs/extractor/src/extractor/styled_record_evaluation_tests.rs
new file mode 100644
index 000000000..f6c326ec6
--- /dev/null
+++ b/libs/extractor/src/extractor/styled_record_evaluation_tests.rs
@@ -0,0 +1,58 @@
+use boa_engine::{Context, Source};
+use oxc_allocator::Allocator;
+use oxc_ast::ast::Statement;
+use oxc_parser::Parser;
+use oxc_span::SourceType;
+use rstest::rstest;
+
+use super::*;
+use crate::utils::readable_code;
+
+#[rstest]
+#[case::equal_true(true, true)]
+#[case::equal_false(false, false)]
+#[case::different_true(true, false)]
+#[case::different_false(false, true)]
+fn boolean_choice_when_test_is_captured(
+ #[case] yes: bool,
+ #[case] no: bool,
+ #[values(false, true)] on: bool,
+) {
+ let allocator = Allocator::default();
+ let builder = AstBuilder::new(&allocator);
+ let source = "(trace.push('test'), on);";
+ let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse();
+ assert_eq!(parsed.diagnostics.len(), 0);
+ let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else {
+ panic!("test fixture must be an expression");
+ };
+ let mut reads = Evaluations::new(&builder, source.to_string());
+ let test = reads.capture(&statement.expression, &reads.boolean(true));
+ let choice = reads.choose(&test, &reads.boolean(yes), &reads.boolean(no));
+ match &choice {
+ Expression::BooleanLiteral(value) => {
+ assert_eq!(yes, no);
+ assert_eq!(value.value, yes);
+ }
+ Expression::ConditionalExpression(_) => assert_ne!(yes, no),
+ _ => panic!("Boolean choice must be constant or conditional"),
+ }
+ let render = bind_render(&builder, reads.bindings, choice);
+ let script = format!(
+ "const trace = []; const on = {on}; const result = {}; JSON.stringify([result, trace]);",
+ readable_code(&render)
+ );
+ let mut context = Context::default();
+ let result = match context.eval(Source::from_bytes(script.as_bytes())) {
+ Ok(result) => result,
+ Err(error) => panic!("Boolean choice evaluation failed: {error}"),
+ };
+ let result = match result.as_string() {
+ Some(result) => result.to_std_string_escaped(),
+ None => panic!("Boolean choice must return a JSON string"),
+ };
+ assert_eq!(
+ result,
+ format!(r#"[{},["test"]]"#, if on { yes } else { no })
+ );
+}
diff --git a/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs b/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs
new file mode 100644
index 000000000..8cc7b8dcf
--- /dev/null
+++ b/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs
@@ -0,0 +1,301 @@
+use std::collections::BTreeMap;
+
+use oxc_allocator::{Allocator, FromIn, GetAllocator};
+use oxc_ast::{ast::Expression, builder::AstBuilder};
+use oxc_span::SPAN;
+use rstest::rstest;
+
+use super::visit_style_expressions;
+use crate::{
+ ExtractStyleProp, ExtractStyleValue,
+ extract_style::{
+ extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle,
+ },
+ utils::readable_code,
+};
+
+#[derive(Clone, Copy)]
+enum Container {
+ Conditional,
+ StaticArray,
+ Enum,
+ Expression,
+ MemberExpression,
+}
+
+fn identifier<'a>(builder: &AstBuilder<'a>, name: &str) -> Expression<'a> {
+ Expression::new_identifier(
+ SPAN,
+ oxc_ast::ast::Ident::from_in(name, builder.allocator()),
+ builder,
+ )
+}
+
+fn payload<'a>(builder: &AstBuilder<'a>, name: &str) -> ExtractStyleProp<'a> {
+ ExtractStyleProp::Expression {
+ expression: identifier(builder, name),
+ styles: vec![
+ ExtractStyleValue::Dynamic(ExtractDynamicStyle::new("color", 2, name, None)),
+ ExtractStyleValue::Dynamic(ExtractDynamicStyle::new(
+ "background",
+ 4,
+ &format!("{name} + {name}.color !important"),
+ None,
+ )),
+ ExtractStyleValue::Static(ExtractStaticStyle::new(
+ "--authored",
+ "__devupRecord0",
+ 3,
+ None,
+ )),
+ ],
+ }
+}
+
+fn assert_identifier(expression: &Expression<'_>, expected: &str) {
+ let Expression::Identifier(identifier) = expression else {
+ panic!("visitor must preserve the identifier expression");
+ };
+ assert_eq!(identifier.name.as_str(), expected);
+ assert_eq!(readable_code(expression), expected);
+}
+
+fn assert_payload(style: &ExtractStyleProp<'_>, expected: &str) {
+ let ExtractStyleProp::Expression { expression, styles } = style else {
+ panic!("fixture payload must remain an Expression");
+ };
+ assert_identifier(expression, expected);
+ assert_eq!(styles.len(), 3);
+ let ExtractStyleValue::Dynamic(simple) = &styles[0] else {
+ panic!("first payload must remain dynamic");
+ };
+ assert_eq!(simple.identifier(), expected);
+ assert_eq!(
+ simple,
+ &ExtractDynamicStyle::new("color", 2, expected, None)
+ );
+ let ExtractStyleValue::Dynamic(code) = &styles[1] else {
+ panic!("second payload must remain dynamic code");
+ };
+ assert_eq!(code.identifier(), format!("{expected} + {expected}.color"));
+ assert_eq!(
+ code,
+ &ExtractDynamicStyle::new(
+ "background",
+ 4,
+ &format!("{expected} + {expected}.color !important"),
+ None,
+ )
+ );
+ assert_eq!(
+ styles[2],
+ ExtractStyleValue::Static(ExtractStaticStyle::new(
+ "--authored",
+ "__devupRecord0",
+ 3,
+ None,
+ ))
+ );
+}
+
+#[test]
+#[should_panic(expected = "visitor must preserve the identifier expression")]
+fn identifier_guard_rejects_when_expression_is_null() {
+ // Given: an AST expression with the wrong shape.
+ let allocator = Allocator::default();
+ let builder = AstBuilder::new(&allocator);
+ let expression = Expression::new_null_literal(SPAN, &builder);
+ // When/Then: the existing identifier assertion rejects it at its shape guard.
+ assert_identifier(&expression, "__devupRecord0");
+}
+
+#[test]
+#[should_panic(expected = "fixture payload must remain an Expression")]
+fn payload_guard_rejects_when_container_is_an_array() {
+ // Given: a non-Expression container.
+ let style = ExtractStyleProp::StaticArray(vec![]);
+ // When/Then: the existing payload assertion rejects the container shape.
+ assert_payload(&style, "__devupRecord0");
+}
+
+#[test]
+#[should_panic(expected = "first payload must remain dynamic")]
+fn payload_guard_rejects_when_first_declaration_is_static() {
+ // Given: a valid payload with only its first declaration's shape changed.
+ let allocator = Allocator::default();
+ let builder = AstBuilder::new(&allocator);
+ let mut style = payload(&builder, "__devupRecord0");
+ if let ExtractStyleProp::Expression { styles, .. } = &mut style {
+ styles[0] = styles[2].clone();
+ }
+ // When/Then: earlier identifier/length checks pass, then the first guard rejects.
+ assert_payload(&style, "__devupRecord0");
+}
+
+#[test]
+#[should_panic(expected = "second payload must remain dynamic code")]
+fn payload_guard_rejects_when_second_declaration_is_static() {
+ // Given: a valid payload with only its second declaration's shape changed.
+ let allocator = Allocator::default();
+ let builder = AstBuilder::new(&allocator);
+ let mut style = payload(&builder, "__devupRecord0");
+ if let ExtractStyleProp::Expression { styles, .. } = &mut style {
+ styles[1] = styles[2].clone();
+ }
+ // When/Then: the first declaration checks pass, then the second guard rejects.
+ assert_payload(&style, "__devupRecord0");
+}
+
+#[rstest]
+#[case::conditional(Container::Conditional)]
+#[case::static_array(Container::StaticArray)]
+#[case::enum_map(Container::Enum)]
+#[case::expression(Container::Expression)]
+#[case::member_map(Container::MemberExpression)]
+fn payloads_are_rewritten_when_style_expressions_are_visited(#[case] container: Container) {
+ // Given: two distinct nested captures, including serialized dynamic code.
+ let allocator = Allocator::default();
+ let builder = AstBuilder::new(&allocator);
+ let first = payload(&builder, "__devupRecord0");
+ let second = payload(&builder, "__devupRecord1");
+ let control = identifier(&builder, "__devupRecord2");
+ let mut style = match container {
+ Container::Conditional => ExtractStyleProp::Conditional {
+ condition: control,
+ consequent: Some(Box::new(first)),
+ alternate: Some(Box::new(second)),
+ },
+ Container::StaticArray => ExtractStyleProp::StaticArray(vec![first, second]),
+ Container::Enum => ExtractStyleProp::Enum {
+ condition: control,
+ map: BTreeMap::from([
+ ("__devupRecord0".to_string(), vec![first, second]),
+ ("empty".to_string(), vec![]),
+ ]),
+ },
+ Container::Expression => first,
+ Container::MemberExpression => ExtractStyleProp::MemberExpression {
+ expression: control,
+ map: BTreeMap::from([
+ ("__devupRecord0".to_string(), Box::new(first)),
+ ("second".to_string(), Box::new(second)),
+ ]),
+ },
+ };
+
+ // When: the callback rewrites actual AST identifier references/code payloads.
+ visit_style_expressions(&builder, &mut style, &mut |expression| {
+ let Expression::Identifier(value) = expression else {
+ panic!("fixture visitor receives identifier expressions");
+ };
+ let renamed = value.name.replace("__devupRecord", "__devupInherited");
+ value.name = oxc_ast::ast::Ident::from_in(renamed.as_str(), builder.allocator());
+ });
+
+ // Then: every branch/map/array entry and AST control was rewritten.
+ match &style {
+ ExtractStyleProp::Conditional {
+ condition,
+ consequent,
+ alternate,
+ } => {
+ assert_identifier(condition, "__devupInherited2");
+ let (Some(consequent), Some(alternate)) = (consequent, alternate) else {
+ panic!("both conditional branches must remain present");
+ };
+ assert_payload(consequent, "__devupInherited0");
+ assert_payload(alternate, "__devupInherited1");
+ }
+ ExtractStyleProp::StaticArray(styles) => {
+ assert_eq!(styles.len(), 2);
+ assert_payload(&styles[0], "__devupInherited0");
+ assert_payload(&styles[1], "__devupInherited1");
+ }
+ ExtractStyleProp::Enum { condition, map } => {
+ assert_identifier(condition, "__devupInherited2");
+ assert_eq!(map.len(), 2);
+ assert_eq!(map["empty"].len(), 0);
+ assert_payload(&map["__devupRecord0"][0], "__devupInherited0");
+ assert_payload(&map["__devupRecord0"][1], "__devupInherited1");
+ }
+ ExtractStyleProp::Expression { .. } => assert_payload(&style, "__devupInherited0"),
+ ExtractStyleProp::MemberExpression { expression, map } => {
+ assert_identifier(expression, "__devupInherited2");
+ assert_eq!(map.len(), 2);
+ assert_payload(&map["__devupRecord0"], "__devupInherited0");
+ assert_payload(&map["second"], "__devupInherited1");
+ }
+ ExtractStyleProp::Static(_) | ExtractStyleProp::Unreadable { .. } => {
+ panic!("container must remain unchanged");
+ }
+ }
+}
+
+#[test]
+fn metadata_is_unchanged_when_style_expressions_are_visited() {
+ // Given: static declarations and unreadable diagnostics are not expressions.
+ let allocator = Allocator::default();
+ let builder = AstBuilder::new(&allocator);
+ let static_value = ExtractStyleValue::Static(ExtractStaticStyle::new(
+ "--authored",
+ "__devupRecord0",
+ 3,
+ None,
+ ));
+ let mut style = ExtractStyleProp::StaticArray(vec![
+ ExtractStyleProp::Static(static_value.clone()),
+ ExtractStyleProp::Unreadable {
+ offset: 17,
+ code: "__devupRecord0".to_string(),
+ prop: true,
+ requirement: Some("a valid selector"),
+ },
+ ExtractStyleProp::Static(ExtractStyleValue::Dynamic(ExtractDynamicStyle::new(
+ "color",
+ 2,
+ "__devupRecord0",
+ None,
+ ))),
+ ]);
+
+ // When: only the dynamic declaration is visited.
+ visit_style_expressions(&builder, &mut style, &mut |expression| {
+ assert_identifier(expression, "__devupRecord0");
+ *expression = identifier(&builder, "__devupInherited0");
+ });
+
+ // Then: metadata stays byte-identical and the dynamic declaration changes.
+ let ExtractStyleProp::StaticArray(styles) = style else {
+ panic!("array fixture");
+ };
+ let ExtractStyleProp::Static(value) = &styles[0] else {
+ panic!("static fixture");
+ };
+ assert_eq!(value, &static_value);
+ let ExtractStyleProp::Unreadable {
+ offset,
+ code,
+ prop,
+ requirement,
+ } = &styles[1]
+ else {
+ panic!("unreadable fixture");
+ };
+ assert_eq!(
+ (*offset, code.as_str(), *prop),
+ (17, "__devupRecord0", true)
+ );
+ assert_eq!(*requirement, Some("a valid selector"));
+ let ExtractStyleProp::Static(value) = &styles[2] else {
+ panic!("dynamic fixture");
+ };
+ assert_eq!(
+ value,
+ &ExtractStyleValue::Dynamic(ExtractDynamicStyle::new(
+ "color",
+ 2,
+ "__devupInherited0",
+ None,
+ ))
+ );
+}
diff --git a/libs/extractor/src/extractor/styled_record_tests.rs b/libs/extractor/src/extractor/styled_record_tests.rs
index 4f999f15d..2a46c72a0 100644
--- a/libs/extractor/src/extractor/styled_record_tests.rs
+++ b/libs/extractor/src/extractor/styled_record_tests.rs
@@ -9,6 +9,206 @@ use rstest::rstest;
use super::{styled_record::prepare, styled_record_evaluation::bind_render};
use crate::utils::readable_code;
+#[rstest]
+#[case::prepared_base("const Ext=styled(Base)({'p':1});", "", "4px")]
+#[case::two_levels(
+ "const Mid=styled(Base)({'p':1});const Ext=styled(Mid)({'p':2});",
+ "",
+ "8px"
+)]
+#[case::tagged_extension("const Ext=styled(Base)`padding:4px`;", "", "4px")]
+#[case::retargeted_extension(
+ "const Mid=Base.withComponent('section');const Ext=styled(Mid)({'p':1});",
+ "",
+ "4px"
+)]
+#[case::capture_collisions(
+ "const Mid=styled(Base)({backgroundColor:'white',...((trace.push('own'),own)?{backgroundColor:'black'}:{})});const Ext=styled(Mid)(__devupRecord0,{p:1,...((trace.push('authored'),__devupRecord1)?{p:2}:{})});",
+ ",own,__devupRecord0,__devupRecord1",
+ "8px"
+)]
+#[serial_test::serial]
+fn inherited_captures_when_native_extension_renders(
+ #[case] extension: &str,
+ #[case] parameters: &str,
+ #[case] padding: &str,
+ #[values(false, true)] on: bool,
+ #[values(false, true)] authored: bool,
+ #[values(false, true)] single_css: bool,
+) {
+ // Given: independent inherited, own and authored controllers.
+ reset_class_map();
+ reset_file_map();
+ let code = format!(
+ "import {{styled}} from '@devup-ui/react';const trace=[];function make(on{parameters}){{const Base=styled.div({{color:'red',...((trace.push('base'),on)?{{color:'blue'}}:{{}})}});{extension}return Ext({{id:'extension',className:'caller'}});}}const rendered=make({on},{},'authored',{authored});",
+ !on
+ );
+ let output = match crate::extract(
+ "test.tsx",
+ &code,
+ crate::ExtractOption {
+ single_css,
+ ..crate::ExtractOption::default()
+ },
+ ) {
+ Ok(output) => output,
+ Err(error) => panic!("extension fixture must extract: {error}"),
+ };
+ let mut declarations = Vec::new();
+ for style in &output.styles {
+ let crate::ExtractStyleValue::Static(value) = style else {
+ panic!("fixture declarations must be static");
+ };
+ let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) =
+ style.extract(if single_css { None } else { Some("test.tsx") })
+ else {
+ panic!("fixture declaration must have a class");
+ };
+ declarations.push(format!(
+ "['{name}','{}','{}']",
+ value.property(),
+ value.value()
+ ));
+ }
+ // When: execute the emitted native render, not a generated-name assertion.
+ let rendered = evaluate_native_render(
+ &output.code,
+ &format!(
+ "const names=rendered.props.className.split(' ');const styles=[{}].filter(([name])=>names.includes(name)).map(([,prop,value])=>[prop,value]).sort();JSON.stringify([rendered.type,rendered.props.id,styles,names.filter(name=>name==='authored'||name==='caller'),trace]);",
+ declarations.join(",")
+ ),
+ );
+ // Then: only the selected declarations survive, with ordered single reads.
+ let color = if on { "blue" } else { "red" };
+ let target = if extension.contains("withComponent") {
+ "section"
+ } else {
+ "div"
+ };
+ let expected = if parameters.is_empty() {
+ format!(
+ r#"["{target}","extension",[["color","{color}"],["padding","{padding}"]],["caller"],["base"]]"#
+ )
+ } else {
+ let background = if on { "white" } else { "black" };
+ let padding = if authored { padding } else { "4px" };
+ format!(
+ r#"["div","extension",[["background-color","{background}"],["color","{color}"],["padding","{padding}"]],["authored","caller"],["base","own","authored"]]"#
+ )
+ };
+ assert_eq!(rendered, expected);
+}
+
+#[rstest]
+#[serial_test::serial]
+fn inherited_captures_when_dynamic_native_base_renders(
+ #[values(false, true)] on: bool,
+ #[values(false, true)] single_css: bool,
+) {
+ // Given: a lowercase runtime target binding and an inherited captured guard.
+ reset_class_map();
+ reset_file_map();
+ let code = format!(
+ "import {{styled}} from '@devup-ui/react';const trace=[];function make(on,target){{const Base=styled(target)({{color:'red',...((trace.push('base'),on)?{{color:'blue'}}:{{}})}});const Ext=styled(Base)({{p:1}});return Ext({{id:'extension',className:'caller'}});}}const rendered=make({on},'div');"
+ );
+ let output = match crate::extract(
+ "test.tsx",
+ &code,
+ crate::ExtractOption {
+ single_css,
+ ..crate::ExtractOption::default()
+ },
+ ) {
+ Ok(output) => output,
+ Err(error) => panic!("dynamic base extension must extract: {error}"),
+ };
+ let mut declarations = Vec::new();
+ for style in &output.styles {
+ let crate::ExtractStyleValue::Static(value) = style else {
+ panic!("fixture declarations must be static");
+ };
+ let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) =
+ style.extract(if single_css { None } else { Some("test.tsx") })
+ else {
+ panic!("fixture declaration must have a class");
+ };
+ declarations.push(format!(
+ "['{name}','{}','{}']",
+ value.property(),
+ value.value()
+ ));
+ }
+ // When: execute the emitted extension with its actual runtime tag value.
+ let rendered = evaluate_native_render(
+ &output.code,
+ &format!(
+ "const names=rendered.props.className.split(' ');const styles=[{}].filter(([name])=>names.includes(name)).map(([,prop,value])=>[prop,value]).sort();JSON.stringify([rendered.type,rendered.props.id,styles,names.filter(name=>name==='caller'),trace]);",
+ declarations.join(",")
+ ),
+ );
+ // Then: the tag, selected color, extension padding and single read survive.
+ let color = if on { "blue" } else { "red" };
+ assert_eq!(
+ rendered,
+ format!(
+ r#"["div","extension",[["color","{color}"],["padding","4px"]],["caller"],["base"]]"#
+ )
+ );
+}
+
+#[rstest]
+#[serial_test::serial]
+fn inherited_dependent_reads_when_attrs_and_later_styles_compose(
+ #[values(false, true)] outer: bool,
+ #[values(false, true)] inner: bool,
+ #[values(false, true)] single_css: bool,
+) {
+ // Given: nested captured guards, a dynamic leaf and an authored attrs name.
+ reset_class_map();
+ reset_file_map();
+ let code = format!(
+ "import {{styled}} from '@devup-ui/react';const trace=[];function make(outer,inner,__devupRecord0){{const Base=styled.div.attrs({{title:__devupRecord0,id:'base'}})({{color:'red',backgroundColor:'white',...((trace.push('outer'),outer)?{{...((trace.push('inner'),inner)?{{backgroundColor:(trace.push('leaf'),'gold')}}:{{}})}}:{{}})}});const Ext=styled(Base).attrs({{id:'extension'}})({{color:'orange'}});return Ext({{}});}}const rendered=make({outer},{inner},'authored-title');"
+ );
+ let output = match crate::extract(
+ "test.tsx",
+ &code,
+ crate::ExtractOption {
+ single_css,
+ ..crate::ExtractOption::default()
+ },
+ ) {
+ Ok(output) => output,
+ Err(error) => panic!("dependent extension must extract: {error}"),
+ };
+ let mut colors = Vec::new();
+ for style in &output.styles {
+ if let crate::ExtractStyleValue::Static(value) = style
+ && value.property() == "color"
+ && let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) =
+ style.extract(if single_css { None } else { Some("test.tsx") })
+ {
+ colors.push(format!("['{name}','{}']", value.value()));
+ }
+ }
+ // When: render the flattened extension through the existing native harness.
+ let rendered = evaluate_native_render(
+ &output.code,
+ &format!(
+ "const names=rendered.props.className.split(' ');JSON.stringify([rendered.props.id,rendered.props.title,[{}].filter(([name])=>names.includes(name)).map(([,value])=>value),Object.values(rendered.props.style||{{}}).filter(value=>value!=null),trace]);",
+ colors.join(",")
+ ),
+ );
+ // Then: guarded reads stay single and ordered, attrs and later color win.
+ let expected = match (outer, inner) {
+ (true, true) => {
+ r#"["extension","authored-title",["orange"],["gold"],["outer","inner","leaf"]]"#
+ }
+ (true, false) => r#"["extension","authored-title",["orange"],[],["outer","inner"]]"#,
+ (false, _) => r#"["extension","authored-title",["orange"],[],["outer"]]"#,
+ };
+ assert_eq!(rendered, expected);
+}
+
fn evaluate_record(rules: &str, setup: &str) -> String {
let allocator = Allocator::default();
let builder = AstBuilder::new(&allocator);
@@ -287,7 +487,7 @@ fn original_record_when_unaffected_or_unreadable(#[case] rules: &str) {
assert_eq!(readable_code(&statement.expression), original);
}
-/// Evaluate the emitted render scope, lowering only its empty native JSX element.
+/// Evaluate the emitted render scope, lowering its empty native or bound JSX element.
fn evaluate_native_render(code: &str, observation: &str) -> String {
use oxc_ast::ast::{
ImportDeclaration, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElement,
@@ -306,10 +506,17 @@ fn evaluate_native_render(code: &str, observation: &str) -> String {
fn visit_jsx_element(&mut self, element: &JSXElement<'a>) {
assert_eq!(element.children.len(), 0);
- let JSXElementName::Identifier(tag) = &element.opening_element.name else {
- panic!("fixture must render a native element");
+ let tag = match &element.opening_element.name {
+ JSXElementName::Identifier(tag) => {
+ assert_eq!(tag.name, "div");
+ "'div'"
+ }
+ JSXElementName::IdentifierReference(tag) => {
+ assert!(["DevupStyled", "DevupAs"].contains(&tag.name.as_str()));
+ tag.name.as_str()
+ }
+ _ => panic!("fixture must render a native element"),
};
- assert_eq!(tag.name, "div");
let props: Vec<_> = element
.opening_element
.attributes
@@ -336,7 +543,7 @@ fn evaluate_native_render(code: &str, observation: &str) -> String {
.collect();
self.0.push((
element.span,
- format!("({{type:'div',props:{{{}}}}})", props.join(",")),
+ format!("({{type:{tag},props:{{{}}}}})", props.join(",")),
));
}
}
@@ -369,6 +576,113 @@ fn evaluate_native_render(code: &str, observation: &str) -> String {
}
}
+#[rstest]
+#[serial_test::serial]
+fn captures_and_filter_survive_when_prepared_base_retargets_then_extends(
+ #[values("test.tsx", "test.ts")] filename: &str,
+ #[values(false, true)] on: bool,
+ #[values(false, true)] single_css: bool,
+) {
+ // Given: conditional captures, prop-reading attrs and an inherited filter.
+ reset_class_map();
+ reset_file_map();
+ let source = format!(
+ "import {{styled}} from '@devup-ui/react';const trace=[];const on={on};const Base=styled.div.attrs(p=>({{'data-tone':p.tone}})).withConfig({{shouldForwardProp:p=>p!=='tone'&&p!=='blocked'}})({{color:'red',...((trace.push('base'),on)?{{color:'blue'}}:{{}})}});const Mid=Base.withComponent('section');const Card=styled(Mid)({{p:1}});"
+ );
+ let output = match crate::extract(
+ filename,
+ &source,
+ crate::ExtractOption {
+ single_css,
+ ..crate::ExtractOption::default()
+ },
+ ) {
+ Ok(output) => output,
+ Err(error) => panic!("prepared filter extraction failed: {error}"),
+ };
+ let color_classes: Vec<_> = output
+ .styles
+ .iter()
+ .filter_map(|style| match style {
+ crate::ExtractStyleValue::Static(value)
+ if value.property() == "color"
+ && value.value() == if on { "blue" } else { "red" } =>
+ {
+ match style.extract(if single_css { None } else { Some(filename) }) {
+ Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) => {
+ Some(name)
+ }
+ _ => panic!("selected color must have a class"),
+ }
+ }
+ _ => None,
+ })
+ .collect();
+ assert_ne!(color_classes.len(), 0);
+ let winning = serde_json::to_string(&color_classes)
+ .unwrap_or_else(|error| panic!("class fixture serialization failed: {error}"));
+ // When: the emitted extension renders with distinct input, selected target and forwarded target.
+ let observation = format!(
+ "const __devupJsx0=(type,props)=>({{type,props}});const rendered=Card({{tone:'olive',blocked:9,id:'card',ref:7,children:'child',className:'caller',as:'article',forwardedAs:'nav'}});JSON.stringify([rendered.type,rendered.props.as,rendered.props.id,rendered.props.ref,rendered.props.children,rendered.props.className.includes('caller'),{winning}.some(c=>rendered.props.className.split(' ').includes(c)),rendered.props['data-tone'],'tone' in rendered.props,'blocked' in rendered.props,trace]);"
+ );
+ let result = evaluate_native_render(&output.code, &observation);
+ // Then: captures run once, attrs reads survive, filtered props disappear and identity/ordinary props remain.
+ assert_eq!(
+ result,
+ r#"["article","nav","card",7,"child",true,true,"olive",false,false,["base"]]"#
+ );
+}
+
+#[rstest]
+#[serial_test::serial]
+fn transient_inputs_survive_when_prepared_typescript_renderer_forwards_rest(
+ #[values(false, true)] single_css: bool,
+) {
+ // Given: inherited captures, attrs producing unknown transient keys and a transient style input.
+ reset_class_map();
+ reset_file_map();
+ let source = "import {styled} from '@devup-ui/react';const trace=[];const Base=styled.div.attrs(p=>({...p.extra,'data-tone':p.$tone})).withConfig({shouldForwardProp:p=>p!=='blocked'})({color:'red',...((trace.push('base'),true)?{color:'blue'}:{})});const Mid=Base.withComponent('section');const Card=styled(Mid)`background: ${p=>p.$tone};`;";
+ let output = crate::extract(
+ "test.ts",
+ source,
+ crate::ExtractOption {
+ single_css,
+ ..crate::ExtractOption::default()
+ },
+ )
+ .unwrap_or_else(|error| panic!("transient TS extraction failed: {error}"));
+ let winning: Vec<_> = output
+ .styles
+ .iter()
+ .filter_map(|style| match style {
+ crate::ExtractStyleValue::Static(value)
+ if value.property() == "color" && value.value() == "blue" =>
+ {
+ match style.extract(if single_css { None } else { Some("test.ts") }) {
+ Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) => {
+ Some(name)
+ }
+ _ => panic!("selected color must have a class"),
+ }
+ }
+ _ => None,
+ })
+ .collect();
+ assert_ne!(winning.len(), 0);
+ let winning = serde_json::to_string(&winning)
+ .unwrap_or_else(|error| panic!("class fixture serialization failed: {error}"));
+ // When: the emitted non-JSX renderer receives keys absent from the source and renders once.
+ let observation = format!(
+ "const __devupJsx0=(type,props)=>({{type,props}});const input={{$runtimeOnly:'secret',$tone:'orchid',extra:{{$fromAttrs:'secret',title:'attrs-title'}},blocked:9,id:'card',ref:7,children:'child',className:'caller',style:{{opacity:.25}},as:'article',forwardedAs:'nav'}};const before=JSON.stringify(input);const rendered=Card(input);JSON.stringify([Object.keys(rendered.props).filter(k=>k.startsWith('$')),Object.values(rendered.props.style).includes('orchid'),rendered.type,rendered.props.as,rendered.props.id,rendered.props.ref,rendered.props.children,rendered.props.className.includes('caller'),{winning}.some(c=>rendered.props.className.split(' ').includes(c)),rendered.props['data-tone'],rendered.props.title,rendered.props.style.opacity,'blocked' in rendered.props,before===JSON.stringify(input),trace]);"
+ );
+ let result = evaluate_native_render(&output.code, &observation);
+ // Then: forwarding alone is filtered; original reads, classes, captures and caller props survive.
+ assert_eq!(
+ result,
+ r#"[[],true,"article","nav","card",7,"child",true,true,"orchid","attrs-title",0.25,false,true,["base"]]"#
+ );
+}
+
#[rstest]
#[serial_test::serial]
fn template_key_precedence_when_public_component_renders(
diff --git a/libs/extractor/src/extractor/styled_transient.rs b/libs/extractor/src/extractor/styled_transient.rs
new file mode 100644
index 000000000..8136603bd
--- /dev/null
+++ b/libs/extractor/src/extractor/styled_transient.rs
@@ -0,0 +1,100 @@
+use oxc_allocator::Vec;
+use oxc_ast::{
+ ast::{
+ BindingPattern, Expression, FormalParameter, FormalParameterKind, FormalParameters,
+ IdentifierName, StaticMemberExpression,
+ },
+ builder::AstBuilder,
+};
+use oxc_span::SPAN;
+use oxc_syntax::{number::NumberBase, operator::BinaryOperator};
+
+/// Filter only the forwarded copy, leaving styled reads on the original rest object.
+pub(super) fn filter<'a>(builder: &AstBuilder<'a>, props: Expression<'a>) -> Expression<'a> {
+ let key = Expression::new_computed_member_expression(
+ SPAN,
+ Expression::new_identifier(SPAN, "k", builder),
+ Expression::new_numeric_literal(SPAN, 0.0, None, NumberBase::Decimal, builder),
+ false,
+ builder,
+ );
+ let body = Expression::new_binary_expression(
+ SPAN,
+ key,
+ BinaryOperator::StrictInequality,
+ Expression::new_string_literal(SPAN, "$", None, builder),
+ builder,
+ );
+ let pattern = BindingPattern::new_array_pattern(
+ SPAN,
+ Vec::from_array_in(
+ [Some(BindingPattern::new_binding_identifier(
+ SPAN, "k", builder,
+ ))],
+ builder,
+ ),
+ None,
+ builder,
+ );
+ let parameter = FormalParameter::new(
+ SPAN,
+ Vec::new_in(builder),
+ pattern,
+ None::>>,
+ None::>>,
+ false,
+ None,
+ false,
+ false,
+ builder,
+ );
+ let params = FormalParameters::boxed(
+ SPAN,
+ FormalParameterKind::ArrowFormalParameters,
+ Vec::from_array_in([parameter], builder),
+ None::>>,
+ builder,
+ );
+ let predicate = Expression::new_arrow_function_expression(
+ SPAN,
+ false,
+ None::>>,
+ params,
+ None::>>,
+ body.into(),
+ builder,
+ );
+ let object = || Expression::new_identifier(SPAN, "Object", builder);
+ let entries = call(builder, member(builder, object(), "entries"), props);
+ let filtered = call(builder, member(builder, entries, "filter"), predicate);
+ call(builder, member(builder, object(), "fromEntries"), filtered)
+}
+
+fn member<'a>(
+ builder: &AstBuilder<'a>,
+ object: Expression<'a>,
+ name: &'static str,
+) -> Expression<'a> {
+ Expression::StaticMemberExpression(StaticMemberExpression::boxed(
+ SPAN,
+ object,
+ IdentifierName::new(SPAN, name, builder),
+ false,
+ builder,
+ ))
+}
+
+fn call<'a>(
+ builder: &AstBuilder<'a>,
+ callee: Expression<'a>,
+ argument: Expression<'a>,
+) -> Expression<'a> {
+ Expression::new_call_expression(
+ SPAN,
+ callee,
+ None::>>,
+ Vec::from_array_in([argument.into()], builder),
+ false,
+ builder,
+ )
+}
diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs
index a600d1deb..82ad48295 100644
--- a/libs/extractor/src/imported_constants.rs
+++ b/libs/extractor/src/imported_constants.rs
@@ -21,8 +21,13 @@ use oxc_syntax::operator::BinaryOperator;
use oxc_syntax::symbol::SymbolId;
use rustc_hash::{FxHashMap, FxHashSet};
+use crate::composition::{Composition, set_prop_order};
+use crate::extractor::ExtractResult;
+use crate::extractor::extract_style_from_expression::{
+ LiteralHandling, extract_style_from_expression,
+};
use crate::stylex::StylexFunction;
-use crate::{ExtractOption, ModuleResolver};
+use crate::{ExtractOption, ExtractStyleValue, ModuleResolver};
#[derive(Clone, Debug)]
enum Constant {
@@ -44,8 +49,9 @@ enum Constant {
/// The class a `StyleX` theme applies
Theme(String),
/// What another style API gives: a class, a component or a keyframes
- /// name, never rules
- Style,
+ /// name, never rules; for a `css()` class, the styles behind it when
+ /// they are known
+ Style(Option>>),
/// An object or array code changes, or a value read from one
Changed(Rc),
}
@@ -154,6 +160,8 @@ pub(crate) struct Inlined {
pub dependencies: BTreeSet,
pub stylex_vars: FxHashMap>,
pub stylex_themes: FxHashMap,
+ /// The styles behind imported `css()` classes
+ pub css_styles: FxHashMap>,
pub unknown: Unknown,
pub changed: Changed,
}
@@ -471,6 +479,11 @@ pub(crate) fn inline_constants<'a>(
Constant::Theme(class) => {
inlined.stylex_themes.insert(name.clone(), class.clone());
}
+ Constant::Style(Some(styles)) => {
+ inlined
+ .css_styles
+ .insert(name.clone(), styles.as_ref().clone());
+ }
_ => {}
}
for symbol in bindings.get(name.as_str()).into_iter().flatten() {
@@ -1032,6 +1045,55 @@ impl<'p, 'a> ModuleScope<'p, 'a> {
})
}
+ /// The styles behind `css(rules)`, the package's own `css` given one
+ /// rule object every value of which is known, as the module's class names
+ /// do not tell them
+ fn css_styles(
+ &mut self,
+ modules: &mut Modules<'_>,
+ call: &oxc_ast::ast::CallExpression<'_>,
+ ) -> Option>> {
+ let Expression::Identifier(callee) = &call.callee else {
+ return None;
+ };
+ let (source, Imported::Named(export)) = self.imports.get(callee.name.as_str())? else {
+ return None;
+ };
+ if export != "css" || !source.starts_with(modules.option.package.as_str()) {
+ return None;
+ }
+ let [argument] = call.arguments.as_slice() else {
+ return None;
+ };
+ let rules = self.evaluate(modules, argument.as_expression()?)?;
+ let allocator = Allocator::default();
+ let builder = AstBuilder::new(&allocator);
+ let mut rules = match constant_literal(&builder, &rules, true)? {
+ rules @ Expression::ObjectExpression(_) => rules,
+ _ => return None,
+ };
+ let ExtractResult {
+ mut styles,
+ style_order,
+ ..
+ } = extract_style_from_expression(
+ &builder,
+ None,
+ &mut rules,
+ 0,
+ &None,
+ LiteralHandling::ExpandResponsiveThemeToken,
+ );
+ if let Some(order) = style_order {
+ for prop in &mut styles {
+ set_prop_order(prop, order);
+ }
+ }
+ let mut composition = Composition::default();
+ composition.apply(&builder, styles);
+ composition.unconditional().map(Rc::new)
+ }
+
fn is_style_import(&self, option: &ExtractOption, name: &str) -> bool {
self.style_imports.contains(name)
|| self.style_names.contains(name)
@@ -1465,13 +1527,15 @@ impl<'p, 'a> ModuleScope<'p, 'a> {
}
fold_math(callee.property.name.as_str(), &arguments)
}
- callee if self.is_style_api(modules, callee) => Some(Constant::Style),
+ callee if self.is_style_api(modules, callee) => {
+ Some(Constant::Style(self.css_styles(modules, call)))
+ }
_ => self.evaluate_stylex(modules, call),
},
Expression::TaggedTemplateExpression(tagged)
if self.is_style_api(modules, &tagged.tag) =>
{
- Some(Constant::Style)
+ Some(Constant::Style(None))
}
Expression::TSAsExpression(inner) => self.evaluate(modules, &inner.expression),
Expression::TSSatisfiesExpression(inner) => self.evaluate(modules, &inner.expression),
@@ -1837,55 +1901,7 @@ impl<'a> Inline<'_, 'a> {
}
fn literal(&self, constant: &Constant) -> Option> {
- let builder = self.ast_builder;
- match constant {
- Constant::String(value) => Some(Expression::new_string_literal(
- SPAN,
- Str::from_in(value.as_str(), builder.allocator()),
- None,
- builder,
- )),
- Constant::Number(value) => Some(Expression::new_numeric_literal(
- SPAN,
- *value,
- None,
- NumberBase::Decimal,
- builder,
- )),
- Constant::Null => Some(Expression::new_null_literal(SPAN, builder)),
- Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)),
- Constant::Record(entries) if self.objects => {
- let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder);
- for (key, value) in entries.iter() {
- properties.push(ObjectPropertyKind::new_object_property(
- SPAN,
- oxc_ast::ast::PropertyKind::Init,
- oxc_ast::ast::PropertyKey::StringLiteral(
- oxc_ast::ast::StringLiteral::boxed(
- SPAN,
- Str::from_in(key.as_str(), builder.allocator()),
- None,
- builder,
- ),
- ),
- self.literal(value)?,
- false,
- false,
- false,
- builder,
- ));
- }
- Some(Expression::new_object_expression(SPAN, properties, builder))
- }
- Constant::Array(values) if self.objects => {
- let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder);
- for value in values.iter() {
- elements.push(self.literal(value)?.into());
- }
- Some(Expression::new_array_expression(SPAN, elements, builder))
- }
- _ => None,
- }
+ constant_literal(self.ast_builder, constant, self.objects)
}
fn reading_objects(&mut self, objects: bool, visit: impl FnOnce(&mut Self) -> T) -> T {
@@ -1904,6 +1920,60 @@ impl<'a> Inline<'_, 'a> {
}
}
+/// `constant` written as a literal, objects and arrays too when `objects`
+fn constant_literal<'a>(
+ builder: &AstBuilder<'a>,
+ constant: &Constant,
+ objects: bool,
+) -> Option> {
+ match constant {
+ Constant::String(value) => Some(Expression::new_string_literal(
+ SPAN,
+ Str::from_in(value.as_str(), builder.allocator()),
+ None,
+ builder,
+ )),
+ Constant::Number(value) => Some(Expression::new_numeric_literal(
+ SPAN,
+ *value,
+ None,
+ NumberBase::Decimal,
+ builder,
+ )),
+ Constant::Null => Some(Expression::new_null_literal(SPAN, builder)),
+ Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)),
+ Constant::Record(entries) if objects => {
+ let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder);
+ for (key, value) in entries.iter() {
+ properties.push(ObjectPropertyKind::new_object_property(
+ SPAN,
+ oxc_ast::ast::PropertyKind::Init,
+ oxc_ast::ast::PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed(
+ SPAN,
+ Str::from_in(key.as_str(), builder.allocator()),
+ None,
+ builder,
+ )),
+ constant_literal(builder, value, objects)?,
+ false,
+ false,
+ false,
+ builder,
+ ));
+ }
+ Some(Expression::new_object_expression(SPAN, properties, builder))
+ }
+ Constant::Array(values) if objects => {
+ let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder);
+ for value in values.iter() {
+ elements.push(constant_literal(builder, value, objects)?.into());
+ }
+ Some(Expression::new_array_expression(SPAN, elements, builder))
+ }
+ _ => None,
+ }
+}
+
impl<'a> VisitMut<'a> for Inline<'_, 'a> {
fn visit_expression(&mut self, expression: &mut Expression<'a>) {
if self.styles {
diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs
index 732ede591..065aba011 100644
--- a/libs/extractor/src/lib.rs
+++ b/libs/extractor/src/lib.rs
@@ -2,6 +2,7 @@ mod as_visit;
mod build_time_values;
mod class_arguments;
mod component;
+mod composition;
mod css_utils;
mod emotion_namespace;
pub mod extract_style;
@@ -13,10 +14,12 @@ mod imported_constants;
mod module_loader;
mod mutations;
mod prop_modify_utils;
+mod prop_valid;
#[cfg(test)]
mod responsive_selector_tests;
mod source_map;
mod style_values;
+mod styled_reads;
#[cfg(test)]
mod styled_source_tests;
mod stylex;
@@ -510,6 +513,7 @@ fn extract_source(
if global { None } else { Some(bucket) },
);
visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes);
+ visitor.import_css(inlined.css_styles);
visitor.unknown_bindings(&inlined.unknown);
visitor.changed_bindings(inlined.changed.clone());
visitor.visit_program(&mut program);
@@ -843,6 +847,8 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool {
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
+ mod styled_transient_tests;
+
mod emotion_namespace_tests {
include!("emotion_namespace_tests.rs");
}
@@ -14751,6 +14757,449 @@ const Button = styled.button({ bg: 'red' })
));
}
+ /// `code` extracted with readable class names
+ fn readable_code(code: &str) -> String {
+ reset_class_map();
+ reset_file_map();
+ css::debug::set_debug(true);
+ let output = extract(
+ "test.tsx",
+ code,
+ 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(),
+ },
+ );
+ css::debug::set_debug(false);
+ match output {
+ Ok(output) => output.code,
+ Err(error) => error.to_string(),
+ }
+ }
+
+ // A later part of `css(...)` replaces an earlier part's declaration of the
+ // same property, selector, breakpoint and layer, whatever the stylesheet
+ // order of their classes
+ #[rstest]
+ #[case(
+ "css(yellow, azure)",
+ r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""#
+ )]
+ #[case(
+ "css(azure, yellow)",
+ r#""background-0-black--255 color-0-red-_a__c_hover-255 color-0-yellow--255""#
+ )]
+ #[case(
+ "css({ color: 'green' }, yellow)",
+ r#""color-0-red-_a__c_hover-255 color-0-yellow--255""#
+ )]
+ #[case(
+ "css(yellow, { color: 'green' })",
+ r#""color-0-red-_a__c_hover-255 color-0-green--255""#
+ )]
+ #[case(
+ "css([yellow, azure])",
+ r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""#
+ )]
+ #[case(
+ "css(...[yellow, azure])",
+ r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""#
+ )]
+ #[case(
+ "css(yellow, null, undefined, false, azure)",
+ r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""#
+ )]
+ #[case(
+ "css(yellow, on && azure)",
+ r#"`color-0-red-_a__c_hover-255 ${on ? "background-0-black--255" : ""} ${on ? "color-0-azure--255" : "color-0-yellow--255"}`"#
+ )]
+ #[case(
+ "css(on ? yellow : azure)",
+ r#"`${on ? "color-0-red-_a__c_hover-255" : ""} ${on ? "color-0-yellow--255" : "color-0-azure--255"} ${on ? "" : "background-0-black--255"}`"#
+ )]
+ #[case(
+ "css(yellow, on ? { color: 'pink' } : null)",
+ r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-yellow--255"}`"#
+ )]
+ #[case(
+ "css(yellow, { color: on ? 'pink' : 'teal' })",
+ r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-teal--255"}`"#
+ )]
+ #[case(
+ "css(yellow, ext, azure)",
+ r"`color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255 ${ext}`"
+ )]
+ #[case(
+ "css(yellow, on ? 'plain' : azure)",
+ r#"`${on ? "plain" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"#
+ )]
+ #[case(
+ "css(azure, wide)",
+ r#""background-0-black--255 color-0-a1--255 color-1-b1--255""#
+ )]
+ #[case(
+ "css(wide, { color: [null, 'x2'] })",
+ r#""color-0-a1--255 color-1-x2--255""#
+ )]
+ #[case(
+ "css(yellow, ordered)",
+ r#""color-0-red-_a__c_hover-255 color-0-navy--3""#
+ )]
+ #[case("css(heading, body)", r#""typo-body""#)]
+ #[case(
+ "css(chained, { color: 'gold' })",
+ r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-gold--255""#
+ )]
+ #[case(
+ "css(yellow, { color: { a: 'red', b: 'blue' }[size] })",
+ r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{"
+ )]
+ #[case(
+ "css(yellow, on ? { color: { a: 'red' }[size] } : null)",
+ r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? { "a": "color-0-red--255" }[size] || "" : ""}`"#
+ )]
+ #[case(
+ "css(yellow, on || azure)",
+ r#"`${on ? typeof on === "string" ? on : "" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"#
+ )]
+ #[case(
+ "css(yellow, ext ?? azure)",
+ r#"`${ext != null ? typeof ext === "string" ? ext : "" : ""} ${`color-0-red-_a__c_hover-255 ${ext != null ? "" : "background-0-black--255"} ${ext != null ? "color-0-yellow--255" : "color-0-azure--255"}`}`"#
+ )]
+ #[case(
+ "css(yellow, null ?? azure)",
+ r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""#
+ )]
+ #[case(
+ "css(yellow, false ?? azure)",
+ r#""color-0-red-_a__c_hover-255 color-0-yellow--255""#
+ )]
+ #[case(
+ "css(azure || yellow)",
+ r#""background-0-black--255 color-0-azure--255""#
+ )]
+ #[case(
+ "css(yellow, on || 'plain')",
+ r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? typeof on === "string" ? on : "" : "plain"}`"#
+ )]
+ #[case(
+ "css(yellow, { color: on ? 'a' : 'b', m: [1, 2], styleOrder: 2 })",
+ r#"`color-0-red-_a__c_hover-255 margin-0-4px--2 margin-1-8px--2 ${on ? "color-0-a--2" : "color-0-b--2"}`"#
+ )]
+ #[case(
+ "css(yellow, { color: { a: 'x' }[size], styleOrder: 2 })",
+ r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{ "a": "color-0-x--2" }[size] || ""}`"#
+ )]
+ #[case(
+ "css(yellow, on ? { color: other ? 'a' : 'b' } : null)",
+ r#"`color-0-red-_a__c_hover-255 ${on ? other ? "color-0-a--255" : "color-0-b--255" : "color-0-yellow--255"}`"#
+ )]
+ #[case(
+ "css(yellow, fade)",
+ r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${fade}`"
+ )]
+ #[case(
+ "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })",
+ "`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{\n\t\"0\": \"color-0-x--2\",\n\t\"1\": \"color-0-y--2\"\n}[idx] || \"\"}`"
+ )]
+ #[case(
+ "css(yellow, { color: 'pink' } || azure)",
+ r#""color-0-red-_a__c_hover-255 color-0-pink--255""#
+ )]
+ #[case(
+ "css(yellow, undefined ?? azure)",
+ r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""#
+ )]
+ #[case(
+ "css(yellow, false ?? azure)",
+ r#""color-0-red-_a__c_hover-255 color-0-yellow--255""#
+ )]
+ #[serial]
+ fn test_css_composes_known_classes(#[case] call: &str, #[case] expected: &str) {
+ let code = readable_code(&format!(
+ "import {{css, keyframes}} from '@devup-ui/core'
+const yellow = css({{ color: 'yellow', _hover: {{ color: 'red' }} }})
+const azure = css({{ color: 'azure', bg: 'black' }})
+const wide = css({{ color: ['a1', 'b1'] }})
+const ordered = css({{ color: 'navy', styleOrder: 3 }})
+const heading = css({{ typography: 'heading' }})
+const body = css({{ typography: 'body' }})
+const chained = css(yellow, azure)
+const fade = keyframes({{ from: {{ opacity: 0 }} }})
+export const result = {call}
+"
+ ));
+ assert!(
+ code.contains(&format!("export const result = {expected}")),
+ "{code}"
+ );
+ }
+
+ #[test]
+ #[serial]
+ fn test_styled_extension_composes_base() {
+ let code = readable_code(
+ "import {styled} from '@devup-ui/core'
+const Base = styled.button`color: red; background: white; &:hover { color: red; }`
+const Ext = styled(Base)`color: blue; &:hover { color: blue; }`
+const Obj = styled.div({ color: 'red', p: 2 })
+const ObjExt = styled(Obj)({ color: 'blue' })
+const Twice = styled(ObjExt, { m: 1 })
+const Runtime = styled(make())({ color: 'red' })
+const FromRuntime = styled(Runtime)({ color: 'blue' })
+let Changing = styled.div({ color: 'red' })
+const FromChanging = styled(Changing)({ color: 'blue' })
+const Ordered = styled.div({ color: 'red', styleOrder: 3 })",
+ );
+ for expected in [
+ "const Ext = ({ style, className, as: DevupAs = \"button\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"color-0-blue-_a__c_hover-255 color-0-blue--255 background-0-white--255\", className]",
+ "const ObjExt = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"color-0-blue--255 padding-0-8px--255\", className]",
+ "const Twice = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"margin-0-4px--255 color-0-blue--255 padding-0-8px--255\", className]",
+ "const FromRuntime = ({ style, className, as: DevupAs = Runtime, forwardedAs, ...rest }) => k[0] !== \"$\"))}",
+ "const FromChanging = ({ style, className, as: DevupAs = Changing, forwardedAs, ...rest }) => k[0] !== \"$\"))}",
+ "const Ordered = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"color-0-red--3\", className]",
+ ] {
+ assert!(code.contains(expected), "{expected}\n{code}");
+ }
+ }
+
+ // `withComponent` renders the same styles as another tag or component, and
+ // `as` changes what a styled component renders while `forwardedAs` passes on
+ #[test]
+ #[serial]
+ fn test_styled_with_component_and_as() {
+ let code = readable_code(
+ "import {styled} from '@devup-ui/core'
+const Section = styled.section.attrs({ role: 'region' })({ color: 'red' })
+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 = ",
+ );
+ for expected in [
+ "export const Aside = (__devupProps) => (({ style, className, as: DevupAs = \"aside\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"color-0-red--255\", className]",
+ "as: DevupAs = Link,",
+ "as: DevupAs = motion.div,",
+ "export const Again = (__devupProps) => (({ style, className, as: DevupAs = \"nav\",",
+ "export const Kept = Section.withComponent(make());",
+ "export const Other = other.withComponent(\"aside\");",
+ "...{ role: \"region\" }",
+ "",
+ ] {
+ assert!(code.contains(expected), "{expected}\n{code}");
+ }
+ }
+
+ // 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
+ #[test]
+ #[serial]
+ fn test_styled_prop_forwarding() {
+ let code = readable_code(
+ "import {styled} from '@devup-ui/core'
+import isPropValid from '@emotion/is-prop-valid'
+const A = styled('h1', { shouldForwardProp: (prop) => prop !== 'tone' })({ color: 'red' })
+const B = styled.div`color: ${(p) => p.$c}; background: ${(p) => p.tone};`
+const C = styled.div.withConfig({ shouldForwardProp: (prop, valid) => valid(prop) && !['x', 'y'].includes(prop) })({ color: 'red' })
+const D = styled('p', { shouldForwardProp: (prop) => isPropValid(prop) || prop.startsWith('data-') })({ color: 'red' })
+const E = styled('p', { shouldForwardProp: () => true })({ color: 'red' })
+const F = styled(Link)`color: ${(p) => p.$c};`
+const G = styled('p', { shouldForwardProp: function (prop) { return prop === 'id' } })({ color: 'red' })
+const H = styled.div`color: ${function (p) { return p.c }};`
+const I = styled.div`color: ${({ tone, ...more }) => tone};`
+const J = styled.div`color: ${(p) => p[key]}; margin: ${([a]) => a}; padding: ${() => 1};`
+const K = styled(A, { shouldForwardProp: (prop) => 'id' === prop || prop === 'title' })({ color: 'blue' })
+const L = styled.div.withConfig(config)`color: ${(p) => { return p.level }}; margin: ${({ [k]: v }) => v};`
+const M = styled('p', { shouldForwardProp: (prop) => { return prop === 'id' } })({ color: 'red' })
+export const a =
+export const b =
+export const j =
+export const f =
+export const k = ",
+ );
+ for expected in [
+ "{...Object.fromEntries(Object.entries((({ \"tone\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))}",
+ "{...Object.fromEntries(Object.entries((({ \"$c\": __devupOmit0, \"tone\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))}",
+ "{...Object.fromEntries(Object.entries((({ \"x\": __devupOmit0, \"y\": __devupOmit1, \"theme\": __devupOmit2, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))}",
+ "{...Object.fromEntries(Object.entries((({ \"$c\": __devupOmit0, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))}",
+ "export const a = ",
+ "export const b = ",
+ "export const j = ",
+ "export const f = ",
+ "export const k = ",
+ "{...Object.fromEntries(Object.entries((({ \"level\": __devupOmit0, \"theme\": __devupOmit1, ...__devupDom }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))}",
+ ] {
+ assert!(code.contains(expected), "{expected}\n{code}");
+ }
+ let error = readable_code(
+ "import {styled} from '@devup-ui/core'
+const A = styled('h1', { shouldForwardProp: (prop) => allowed.has(prop) })({ color: 'red' })
+const B = styled('h1', { shouldForwardProp: (prop) => prop === other })({ color: 'red' })
+const C = styled('h1', { shouldForwardProp: (prop) => prop > 'a' })({ color: 'red' })
+const D = styled('h1', { shouldForwardProp: (prop) => prop ?? 'a' })({ color: 'red' })
+const E = styled('h1', { shouldForwardProp: (prop) => { const x = 1; return x } })({ color: 'red' })
+const F = styled('h1', { shouldForwardProp: check })({ color: 'red' })
+const G = styled('h1', { shouldForwardProp: ({ a }) => a })({ color: 'red' })
+const H = styled('h1', { shouldForwardProp: (prop) => [x].includes(prop) })({ color: 'red' })
+const I = styled('h1', { shouldForwardProp: (prop) => list.includes(prop) })({ color: 'red' })
+const J = styled('h1', { shouldForwardProp: (prop) => prop.endsWith('a') })({ color: 'red' })
+const K = styled('h1', { [k]: 1, ...o, shouldForwardProp: (prop) => valid(prop, 1) })({ color: 'red' })
+const L = styled('h1', { shouldForwardProp: (prop) => prop === 'a' ?? prop === 'b' })({ color: 'red' })
+const M = styled('h1', { shouldForwardProp: (prop) => x === y })({ color: 'red' })
+const N = styled('h1', { shouldForwardProp: (prop) => { prop === 'a' } })({ color: 'red' })",
+ );
+ assert!(
+ error.contains("`shouldForwardProp` must be a function"),
+ "{error}"
+ );
+ assert!(error.contains("allowed.has(prop)"), "{error}");
+ }
+
+ #[test]
+ #[serial]
+ fn test_styled_attrs_merge_in_order() {
+ let code = readable_code(
+ "import {styled} from '@devup-ui/core'
+const Base = styled.input.attrs({ type: 'text', title: 'base' })({ color: 'red' })
+const Ext = styled(Base).attrs({ type: 'password' })({ color: 'blue' })
+const X = styled.div.attrs({ className: 'from-attrs', style: { color: 'green' } })({ color: 'red' })
+const F = styled.div.attrs((p) => ({ id: p.id }))({ color: 'red' })
+const V = styled.div.attrs(extra)({ color: 'red' })",
+ );
+ for expected in [
+ "...{\n\t\t...__devupProps,\n\t\t...{\n\t\t\ttype: \"text\",\n\t\t\ttitle: \"base\"\n\t\t}\n\t},\n\t...{ type: \"password\" }",
+ "className: [__devupContext.className, __devupAttrs.className].filter(Boolean).join(\" \") || undefined",
+ "className: [__devupContext.className, __devupProps.className].filter(Boolean).join(\" \") || undefined",
+ "...__devupContext.style,\n\t\t...__devupAttrs.style",
+ "((p) => ({ id: p.id }))(__devupContext)",
+ "typeof extra === \"function\" ? extra(__devupContext) : extra",
+ ] {
+ assert!(code.contains(expected), "{expected}\n{code}");
+ }
+ }
+
+ #[rstest]
+ #[case(
+ "",
+ "className=\"spread\" style={{ opacity: 2 }}"
+ )]
+ #[case(
+ "",
+ "className=\"direct\" style={{ opacity: 1 }}"
+ )]
+ #[case(
+ "",
+ "className={(\"className\" in Object(rest) ? rest.className : \"direct\") || \"\"} style={\"style\" in Object(rest) ? rest.style : { opacity: 1 }}"
+ )]
+ #[case(
+ "",
+ "className=\"last\""
+ )]
+ #[case(
+ "",
+ "className={{\n\tclassName: \"first\",\n\t...rest\n}.className || \"\"}"
+ )]
+ #[case(
+ " color=\"red\" />",
+ ""
+ )]
+ #[case(
+ "",
+ "className={(\"className\" in Object(more) ? more.className : rest?.className) || \"\"}"
+ )]
+ #[serial]
+ fn test_jsx_props_written_later_win(#[case] element: &str, #[case] expected: &str) {
+ let code = readable_code(&format!(
+ "import {{Box}} from '@devup-ui/core'
+export const a = {element}"
+ ));
+ assert!(code.contains(expected), "{expected}\n{code}");
+ }
+
+ #[test]
+ #[serial]
+ fn test_css_composing_reports_runtime_values() {
+ let code = readable_code(
+ "import {css} from '@devup-ui/core'
+const base = css({ color: 'red' })
+export const a = css(base, { color: tone })
+export const b = css(base, { [key]: 'x', styleOrder: 2 })
+export const c = css(base, getStyles())
+export const d = css(base, { positioning: side, styleOrder: 2 })",
+ );
+ assert!(!code.contains("test.tsx:6:"), "{code}");
+ assert!(code.contains("`css()` cannot use `tone`"), "{code}");
+ assert!(
+ code.contains("Cannot compose `\"color-0-red--255\", getStyles()`"),
+ "{code}"
+ );
+ }
+
+ // The styles of a `css()` class another module exports compose as well
+ #[test]
+ #[serial]
+ fn test_css_composes_imported_classes() {
+ reset_class_map();
+ reset_file_map();
+ let modules: &[(&str, &str)] = &[(
+ "/src/styles.ts",
+ "import { css, keyframes } from '@devup-ui/react';
+import * as Devup from '@devup-ui/react';
+const brand = 'teal';
+export const base = css({ color: brand, _hover: { color: 'red' } });
+export const danger = css({ color: 'crimson' });
+export const twice = css({ color: 'a' }, { m: 1 });
+export const text = css`color: blue;`;
+export const runtime = css({ color: globalThis.tone });
+export const listed = css([{ color: 'b' }]);
+export const spaced = Devup.css({ color: 'c' });
+export const fade = keyframes({ from: { opacity: 0 } });
+export const called = Devup({ color: 'e' });
+export const ordered = css({ color: 'f', m: 2, styleOrder: 2 });",
+ )];
+ let resolver = memory_resolver(modules);
+ css::debug::set_debug(true);
+ let output = extract_with_modules(
+ "/src/App.tsx",
+ "import { css } from '@devup-ui/react';
+import { base, danger, twice, text, runtime, listed, spaced, fade, called, ordered } from './styles';
+export const a = css(base, danger);
+export const b = css(danger, base, { m: 1 });
+export const c = css(twice, text, runtime, listed, spaced, fade);
+export const d = css(ordered, danger, called);",
+ ExtractOption {
+ import_aliases: HashMap::from([(
+ "@emotion/css".to_string(),
+ ImportAlias::NamedToNamed,
+ )]),
+ ..ExtractOption::default()
+ },
+ false,
+ &resolver,
+ )
+ .unwrap();
+ css::debug::set_debug(false);
+ for expected in [
+ r#"export const a = "color-0-red-_a__c_hover-255-a color-0-crimson--255-a";"#,
+ r#"export const b = "color-0-red-_a__c_hover-255-a color-0-teal--255-a margin-0-4px--255-a";"#,
+ "export const c = `${twice} ${text} ${runtime} ${listed} ${spaced} ${fade}`;",
+ "export const d = `margin-0-8px--2-a color-0-crimson--255-a ${called}`;",
+ ] {
+ assert!(
+ output.code.contains(expected),
+ "{expected} in {}",
+ output.code
+ );
+ }
+ }
+
// Each Tailwind class becomes the classes of its styles; every other class,
// and every class that runs into an interpolation, stays as written
#[rstest]
diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs
index 4b2a8c588..16f0d7daf 100644
--- a/libs/extractor/src/prop_modify_utils.rs
+++ b/libs/extractor/src/prop_modify_utils.rs
@@ -62,20 +62,13 @@ fn resolve_class_name_expression<'a>(
class_name_prop: &Option>,
styles: &mut [ExtractStyleProp<'a>],
style_order: Option,
- spread_props: &[Expression<'a>],
filename: Option<&str>,
conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>,
) -> (Option>, Vec) {
if let Some((condition, alt_styles, alt_style_order)) = conditional_branch {
// Conditional styleOrder: generate className for both branches
- let (con_expr, con_tailwind) = get_class_name_expression(
- ast_builder,
- class_name_prop,
- styles,
- style_order,
- spread_props,
- filename,
- );
+ let (con_expr, con_tailwind) =
+ get_class_name_expression(ast_builder, class_name_prop, styles, style_order, filename);
let alt_class_name_prop = class_name_prop
.as_ref()
.map(|c| c.clone_in(ast_builder.allocator()));
@@ -84,7 +77,6 @@ fn resolve_class_name_expression<'a>(
&alt_class_name_prop,
alt_styles,
alt_style_order,
- spread_props,
filename,
);
@@ -95,14 +87,7 @@ fn resolve_class_name_expression<'a>(
all_tailwind.extend(alt_tailwind);
(combined_expr, all_tailwind)
} else {
- get_class_name_expression(
- ast_builder,
- class_name_prop,
- styles,
- style_order,
- spread_props,
- filename,
- )
+ get_class_name_expression(ast_builder, class_name_prop, styles, style_order, filename)
}
}
@@ -121,38 +106,44 @@ pub fn modify_prop_object<'a>(
filename: Option<&str>,
conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>,
) -> Vec {
- let mut class_name_prop = None;
- let mut style_prop = None;
- let mut spread_props = vec![];
- for idx in (0..props.len()).rev() {
- let prop = props.remove(idx);
+ let mut class_names = Vec::new();
+ let mut style_values = Vec::new();
+ let written = std::mem::replace(props, oxc_allocator::Vec::new_in(ast_builder));
+ for prop in written {
match prop {
- ObjectPropertyKind::ObjectProperty(attr) => {
- if let Some(name) = get_str_by_property_key(&attr.key) {
- if name == "className" {
- class_name_prop = Some(attr.value.clone_in(ast_builder.allocator()));
- } else if name == "style" {
- style_prop = Some(attr.value.clone_in(ast_builder.allocator()));
- } else {
- props.insert(idx, ObjectPropertyKind::ObjectProperty(attr));
- }
+ ObjectPropertyKind::ObjectProperty(attr)
+ if matches!(
+ get_str_by_property_key(&attr.key).as_deref(),
+ Some("className" | "style")
+ ) =>
+ {
+ let value = Written::Prop(attr.value.clone_in(ast_builder.allocator()));
+ if get_str_by_property_key(&attr.key).as_deref() == Some("className") {
+ class_names.push(value);
} else {
- props.insert(idx, ObjectPropertyKind::ObjectProperty(attr));
+ style_values.push(value);
}
}
ObjectPropertyKind::SpreadProperty(spread) => {
- spread_props.push(spread.argument.clone_in(ast_builder.allocator()));
- props.insert(idx, ObjectPropertyKind::SpreadProperty(spread));
+ class_names.push(Written::Spread(
+ spread.argument.clone_in(ast_builder.allocator()),
+ ));
+ style_values.push(Written::Spread(
+ spread.argument.clone_in(ast_builder.allocator()),
+ ));
+ props.push(ObjectPropertyKind::SpreadProperty(spread));
}
+ prop @ ObjectPropertyKind::ObjectProperty(_) => props.push(prop),
}
}
+ let class_name_prop = last_written(ast_builder, &class_names, "className");
+ let style_prop = last_written(ast_builder, &style_values, "style");
let (class_name_expr, tailwind_styles) = resolve_class_name_expression(
ast_builder,
&class_name_prop,
styles,
style_order,
- &spread_props,
filename,
conditional_branch,
);
@@ -169,14 +160,8 @@ pub fn modify_prop_object<'a>(
ast_builder,
));
}
- if let Some(ex) = get_style_expression(
- ast_builder,
- &style_prop,
- styles,
- &style_vars,
- &spread_props,
- filename,
- ) {
+ if let Some(ex) = get_style_expression(ast_builder, &style_prop, styles, &style_vars, filename)
+ {
props.push(ObjectPropertyKind::new_object_property(
SPAN,
PropertyKind::Init,
@@ -212,54 +197,54 @@ pub fn modify_props<'a>(
filename: Option<&str>,
conditional_branch: Option<(Expression<'a>, &mut [ExtractStyleProp<'a>], Option)>,
) -> Vec {
- let mut class_name_prop = None;
- let mut style_prop = None;
- let mut spread_props = vec![];
- for idx in (0..props.len()).rev() {
- let prop = props.remove(idx);
+ let mut class_names = Vec::new();
+ let mut style_values = Vec::new();
+ let written = std::mem::replace(props, oxc_allocator::Vec::new_in(ast_builder));
+ for prop in written {
match prop {
- JSXAttributeItem::Attribute(attr) => {
- if let Identifier(ident) = &attr.name
- && let Some(value) = &attr.value
- && (ident.name == "className" || ident.name == "style")
- {
- let mut res = None;
-
- if let JSXAttributeValue::ExpressionContainer(container) = &value {
- res = container
- .expression
- .as_expression()
- .map(|expression| expression.clone_in(ast_builder.allocator()));
- } else if let JSXAttributeValue::StringLiteral(literal) = &value {
- res = Some(Expression::new_string_literal(
- SPAN,
- literal.value,
- None,
- ast_builder,
- ));
- }
- let name = ident.name.as_str();
- if name == "className" {
- class_name_prop = res;
- } else if name == "style" {
- style_prop = res;
- }
+ JSXAttributeItem::Attribute(attr)
+ if matches!(&attr.name, Identifier(ident)
+ if ident.name == "className" || ident.name == "style")
+ && attr.value.is_some() =>
+ {
+ let value = match &attr.value {
+ Some(JSXAttributeValue::ExpressionContainer(container)) => container
+ .expression
+ .as_expression()
+ .map(|expression| expression.clone_in(ast_builder.allocator())),
+ Some(JSXAttributeValue::StringLiteral(literal)) => Some(
+ Expression::new_string_literal(SPAN, literal.value, None, ast_builder),
+ ),
+ _ => None,
+ };
+ let Some(value) = value else {
+ continue;
+ };
+ if matches!(&attr.name, Identifier(ident) if ident.name == "className") {
+ class_names.push(Written::Prop(value));
} else {
- props.insert(idx, JSXAttributeItem::Attribute(attr));
+ style_values.push(Written::Prop(value));
}
}
JSXAttributeItem::SpreadAttribute(spread) => {
- spread_props.push(spread.argument.clone_in(ast_builder.allocator()));
- props.insert(idx, JSXAttributeItem::SpreadAttribute(spread));
+ class_names.push(Written::Spread(
+ spread.argument.clone_in(ast_builder.allocator()),
+ ));
+ style_values.push(Written::Spread(
+ spread.argument.clone_in(ast_builder.allocator()),
+ ));
+ props.push(JSXAttributeItem::SpreadAttribute(spread));
}
+ prop @ JSXAttributeItem::Attribute(_) => props.push(prop),
}
}
+ let class_name_prop = last_written(ast_builder, &class_names, "className");
+ let style_prop = last_written(ast_builder, &style_values, "style");
let (class_name_expr, tailwind_styles) = resolve_class_name_expression(
ast_builder,
&class_name_prop,
styles,
style_order,
- &spread_props,
filename,
conditional_branch,
);
@@ -275,14 +260,8 @@ pub fn modify_props<'a>(
ast_builder,
));
}
- if let Some(ex) = get_style_expression(
- ast_builder,
- &style_prop,
- styles,
- &style_vars,
- &spread_props,
- filename,
- ) {
+ if let Some(ex) = get_style_expression(ast_builder, &style_prop, styles, &style_vars, filename)
+ {
props.push(JSXAttributeItem::new_attribute(
SPAN,
JSXAttributeName::new_identifier(SPAN, "style", ast_builder),
@@ -310,7 +289,6 @@ pub fn get_class_name_expression<'a>(
class_name_prop: &Option>,
styles: &mut [ExtractStyleProp<'a>],
style_order: Option,
- spread_props: &[Expression<'a>],
filename: Option<&str>,
) -> (Option>, Vec) {
let mut tailwind = TailwindClassName {
@@ -330,32 +308,13 @@ pub fn get_class_name_expression<'a>(
.map(|class_name| convert_class_name(ast_builder, class_name));
// Merge class names: [className] + [devup-ui component styles]
- let mut class_expressions = Vec::with_capacity(2 + spread_props.len());
+ let mut class_expressions = Vec::with_capacity(2);
if let Some(class_name) = class_name_to_use {
class_expressions.push(class_name);
}
if let Some(class_name) = gen_class_names(ast_builder, styles, style_order, filename) {
class_expressions.push(class_name);
}
- if class_name_prop.is_none() {
- class_expressions.extend(
- spread_props
- .iter()
- .filter(|ex| may_hold(ex, "className"))
- .map(|ex| {
- convert_class_name(
- ast_builder,
- &Expression::StaticMemberExpression(StaticMemberExpression::boxed(
- SPAN,
- ex.clone_in(ast_builder.allocator()),
- IdentifierName::new(SPAN, "className", ast_builder),
- true,
- ast_builder,
- )),
- )
- }),
- );
- }
let expression = merge_string_expressions(ast_builder, &class_expressions);
(expression, tailwind.into_styles())
@@ -762,6 +721,107 @@ fn template_raw(cooked: &str) -> String {
raw
}
+/// `className` or `style` written as a prop, or spread with other props
+pub(crate) enum Written<'a> {
+ Prop(Expression<'a>),
+ Spread(Expression<'a>),
+}
+
+/// The value `key` ends up with as the props are written in order: a later
+/// prop or spread holding `key` replaces an earlier one. Whether a spread holds
+/// it only the runtime tells, unless it is an object literal.
+fn last_written<'a>(
+ ast_builder: &AstBuilder<'a>,
+ written: &[Written<'a>],
+ key: &'static str,
+) -> Option> {
+ let mut value: Option> = None;
+ for written in written {
+ let spread = match written {
+ Written::Prop(prop) => {
+ value = Some(prop.clone_in(ast_builder.allocator()));
+ continue;
+ }
+ Written::Spread(spread) if may_hold(spread, key) => spread,
+ Written::Spread(_) => continue,
+ };
+ let member = |optional| {
+ Expression::StaticMemberExpression(StaticMemberExpression::boxed(
+ SPAN,
+ spread.clone_in(ast_builder.allocator()),
+ IdentifierName::new(SPAN, key, ast_builder),
+ optional,
+ ast_builder,
+ ))
+ };
+ if let Some(written) = written_value(spread, key) {
+ value = Some(written.clone_in(ast_builder.allocator()));
+ continue;
+ }
+ value = Some(match value {
+ Some(earlier) if !names(spread, key) => {
+ let holds = Expression::new_binary_expression(
+ SPAN,
+ Expression::new_string_literal(SPAN, key, None, ast_builder),
+ oxc_syntax::operator::BinaryOperator::In,
+ crate::utils::wrap_direct_call(
+ ast_builder,
+ &Expression::new_identifier(SPAN, "Object", ast_builder),
+ &[spread.clone_in(ast_builder.allocator())],
+ ),
+ ast_builder,
+ );
+ Expression::new_conditional_expression(
+ SPAN,
+ holds,
+ member(false),
+ earlier,
+ ast_builder,
+ )
+ }
+ _ => member(!names(spread, key)),
+ });
+ }
+ value
+}
+
+/// Whether the object literal `spread` names `key`, so it always holds it
+fn names(spread: &Expression<'_>, key: &str) -> bool {
+ let Expression::ObjectExpression(object) = crate::utils::unwrap_syntax_only(spread) else {
+ return false;
+ };
+ object.properties.iter().any(|property| {
+ matches!(property, ObjectPropertyKind::ObjectProperty(property)
+ if !property.computed
+ && get_str_by_property_key(&property.key).as_deref() == Some(key))
+ })
+}
+
+/// What the object literal `spread` sets `key` to, when it writes it by name
+/// after anything that may also hold it
+fn written_value<'b, 'a>(spread: &'b Expression<'a>, key: &str) -> Option<&'b Expression<'a>> {
+ let Expression::ObjectExpression(object) = crate::utils::unwrap_syntax_only(spread) else {
+ return None;
+ };
+ object
+ .properties
+ .iter()
+ .rev()
+ .find_map(|property| match property {
+ ObjectPropertyKind::ObjectProperty(property)
+ if !property.computed
+ && get_str_by_property_key(&property.key).as_deref() != Some(key) =>
+ {
+ None
+ }
+ ObjectPropertyKind::ObjectProperty(property) if !property.computed => {
+ Some(Some(&property.value))
+ }
+ _ => Some(None),
+ })
+ .flatten()
+}
+
/// Whether the object `spread` gives may hold `key`: an object literal holds
/// only the keys it writes
fn may_hold(spread: &Expression<'_>, key: &str) -> bool {
@@ -781,10 +841,9 @@ pub fn get_style_expression<'a>(
style_prop: &Option>,
styles: &[ExtractStyleProp<'a>],
style_vars: &Option>,
- spread_props: &[Expression<'a>],
filename: Option<&str>,
) -> Option> {
- let mut style_expressions = Vec::with_capacity(3 + spread_props.len());
+ let mut style_expressions = Vec::with_capacity(3);
if let Some(style) = gen_styles(ast_builder, styles, filename) {
style_expressions.push(style);
}
@@ -797,20 +856,6 @@ pub fn get_style_expression<'a>(
if let Some(style_prop) = style_prop.clone_in(ast_builder.allocator()) {
style_expressions.push(style_prop);
}
- if style_prop.is_none() {
- style_expressions.extend(spread_props.iter().filter(|ex| may_hold(ex, "style")).map(
- |ex| {
- Expression::StaticMemberExpression(StaticMemberExpression::boxed(
- SPAN,
- ex.clone_in(ast_builder.allocator()),
- IdentifierName::new(SPAN, "style", ast_builder),
- true,
- ast_builder,
- ))
- },
- ));
- }
-
merge_object_expressions(ast_builder, &style_expressions)
}
diff --git a/libs/extractor/src/prop_valid.rs b/libs/extractor/src/prop_valid.rs
new file mode 100644
index 000000000..82031372f
--- /dev/null
+++ b/libs/extractor/src/prop_valid.rs
@@ -0,0 +1,437 @@
+//! The props React passes to an element as attributes, as
+//! `@emotion/is-prop-valid` 1.4 lists them: a styled component keeps passing
+//! these to the tag it renders even when its styles read them
+
+use phf::phf_set;
+
+static VALID_PROPS: phf::Set<&str> = phf_set! {
+ "children",
+ "dangerouslySetInnerHTML",
+ "key",
+ "ref",
+ "autoFocus",
+ "defaultValue",
+ "defaultChecked",
+ "innerHTML",
+ "suppressContentEditableWarning",
+ "suppressHydrationWarning",
+ "valueLink",
+ "abbr",
+ "accept",
+ "acceptCharset",
+ "accessKey",
+ "action",
+ "allow",
+ "allowUserMedia",
+ "allowPaymentRequest",
+ "allowFullScreen",
+ "allowTransparency",
+ "alt",
+ "async",
+ "autoComplete",
+ "autoPlay",
+ "capture",
+ "cellPadding",
+ "cellSpacing",
+ "challenge",
+ "charSet",
+ "checked",
+ "cite",
+ "classID",
+ "className",
+ "cols",
+ "colSpan",
+ "content",
+ "contentEditable",
+ "contextMenu",
+ "controls",
+ "controlsList",
+ "coords",
+ "crossOrigin",
+ "data",
+ "dateTime",
+ "decoding",
+ "default",
+ "defer",
+ "dir",
+ "disabled",
+ "disablePictureInPicture",
+ "disableRemotePlayback",
+ "download",
+ "draggable",
+ "encType",
+ "enterKeyHint",
+ "fetchpriority",
+ "fetchPriority",
+ "form",
+ "formAction",
+ "formEncType",
+ "formMethod",
+ "formNoValidate",
+ "formTarget",
+ "frameBorder",
+ "headers",
+ "height",
+ "hidden",
+ "high",
+ "href",
+ "hrefLang",
+ "htmlFor",
+ "httpEquiv",
+ "id",
+ "inputMode",
+ "integrity",
+ "is",
+ "keyParams",
+ "keyType",
+ "kind",
+ "label",
+ "lang",
+ "list",
+ "loading",
+ "loop",
+ "low",
+ "marginHeight",
+ "marginWidth",
+ "max",
+ "maxLength",
+ "media",
+ "mediaGroup",
+ "method",
+ "min",
+ "minLength",
+ "multiple",
+ "muted",
+ "name",
+ "nonce",
+ "noValidate",
+ "open",
+ "optimum",
+ "pattern",
+ "placeholder",
+ "playsInline",
+ "popover",
+ "popoverTarget",
+ "popoverTargetAction",
+ "poster",
+ "preload",
+ "profile",
+ "radioGroup",
+ "readOnly",
+ "referrerPolicy",
+ "rel",
+ "required",
+ "reversed",
+ "role",
+ "rows",
+ "rowSpan",
+ "sandbox",
+ "scope",
+ "scoped",
+ "scrolling",
+ "seamless",
+ "selected",
+ "shape",
+ "size",
+ "sizes",
+ "slot",
+ "span",
+ "spellCheck",
+ "src",
+ "srcDoc",
+ "srcLang",
+ "srcSet",
+ "start",
+ "step",
+ "style",
+ "summary",
+ "tabIndex",
+ "target",
+ "title",
+ "translate",
+ "type",
+ "useMap",
+ "value",
+ "width",
+ "wmode",
+ "wrap",
+ "about",
+ "datatype",
+ "inlist",
+ "prefix",
+ "property",
+ "resource",
+ "typeof",
+ "vocab",
+ "autoCapitalize",
+ "autoCorrect",
+ "autoSave",
+ "color",
+ "incremental",
+ "fallback",
+ "inert",
+ "itemProp",
+ "itemScope",
+ "itemType",
+ "itemID",
+ "itemRef",
+ "on",
+ "option",
+ "results",
+ "security",
+ "unselectable",
+ "accentHeight",
+ "accumulate",
+ "additive",
+ "alignmentBaseline",
+ "allowReorder",
+ "alphabetic",
+ "amplitude",
+ "arabicForm",
+ "ascent",
+ "attributeName",
+ "attributeType",
+ "autoReverse",
+ "azimuth",
+ "baseFrequency",
+ "baselineShift",
+ "baseProfile",
+ "bbox",
+ "begin",
+ "bias",
+ "by",
+ "calcMode",
+ "capHeight",
+ "clip",
+ "clipPathUnits",
+ "clipPath",
+ "clipRule",
+ "colorInterpolation",
+ "colorInterpolationFilters",
+ "colorProfile",
+ "colorRendering",
+ "contentScriptType",
+ "contentStyleType",
+ "cursor",
+ "cx",
+ "cy",
+ "d",
+ "decelerate",
+ "descent",
+ "diffuseConstant",
+ "direction",
+ "display",
+ "divisor",
+ "dominantBaseline",
+ "dur",
+ "dx",
+ "dy",
+ "edgeMode",
+ "elevation",
+ "enableBackground",
+ "end",
+ "exponent",
+ "externalResourcesRequired",
+ "fill",
+ "fillOpacity",
+ "fillRule",
+ "filter",
+ "filterRes",
+ "filterUnits",
+ "floodColor",
+ "floodOpacity",
+ "focusable",
+ "fontFamily",
+ "fontSize",
+ "fontSizeAdjust",
+ "fontStretch",
+ "fontStyle",
+ "fontVariant",
+ "fontWeight",
+ "format",
+ "from",
+ "fr",
+ "fx",
+ "fy",
+ "g1",
+ "g2",
+ "glyphName",
+ "glyphOrientationHorizontal",
+ "glyphOrientationVertical",
+ "glyphRef",
+ "gradientTransform",
+ "gradientUnits",
+ "hanging",
+ "horizAdvX",
+ "horizOriginX",
+ "ideographic",
+ "imageRendering",
+ "in",
+ "in2",
+ "intercept",
+ "k",
+ "k1",
+ "k2",
+ "k3",
+ "k4",
+ "kernelMatrix",
+ "kernelUnitLength",
+ "kerning",
+ "keyPoints",
+ "keySplines",
+ "keyTimes",
+ "lengthAdjust",
+ "letterSpacing",
+ "lightingColor",
+ "limitingConeAngle",
+ "local",
+ "markerEnd",
+ "markerMid",
+ "markerStart",
+ "markerHeight",
+ "markerUnits",
+ "markerWidth",
+ "mask",
+ "maskContentUnits",
+ "maskUnits",
+ "mathematical",
+ "mode",
+ "numOctaves",
+ "offset",
+ "opacity",
+ "operator",
+ "order",
+ "orient",
+ "orientation",
+ "origin",
+ "overflow",
+ "overlinePosition",
+ "overlineThickness",
+ "panose1",
+ "paintOrder",
+ "pathLength",
+ "patternContentUnits",
+ "patternTransform",
+ "patternUnits",
+ "pointerEvents",
+ "points",
+ "pointsAtX",
+ "pointsAtY",
+ "pointsAtZ",
+ "preserveAlpha",
+ "preserveAspectRatio",
+ "primitiveUnits",
+ "r",
+ "radius",
+ "refX",
+ "refY",
+ "renderingIntent",
+ "repeatCount",
+ "repeatDur",
+ "requiredExtensions",
+ "requiredFeatures",
+ "restart",
+ "result",
+ "rotate",
+ "rx",
+ "ry",
+ "scale",
+ "seed",
+ "shapeRendering",
+ "slope",
+ "spacing",
+ "specularConstant",
+ "specularExponent",
+ "speed",
+ "spreadMethod",
+ "startOffset",
+ "stdDeviation",
+ "stemh",
+ "stemv",
+ "stitchTiles",
+ "stopColor",
+ "stopOpacity",
+ "strikethroughPosition",
+ "strikethroughThickness",
+ "string",
+ "stroke",
+ "strokeDasharray",
+ "strokeDashoffset",
+ "strokeLinecap",
+ "strokeLinejoin",
+ "strokeMiterlimit",
+ "strokeOpacity",
+ "strokeWidth",
+ "surfaceScale",
+ "systemLanguage",
+ "tableValues",
+ "targetX",
+ "targetY",
+ "textAnchor",
+ "textDecoration",
+ "textRendering",
+ "textLength",
+ "to",
+ "transform",
+ "u1",
+ "u2",
+ "underlinePosition",
+ "underlineThickness",
+ "unicode",
+ "unicodeBidi",
+ "unicodeRange",
+ "unitsPerEm",
+ "vAlphabetic",
+ "vHanging",
+ "vIdeographic",
+ "vMathematical",
+ "values",
+ "vectorEffect",
+ "version",
+ "vertAdvY",
+ "vertOriginX",
+ "vertOriginY",
+ "viewBox",
+ "viewTarget",
+ "visibility",
+ "widths",
+ "wordSpacing",
+ "writingMode",
+ "x",
+ "xHeight",
+ "x1",
+ "x2",
+ "xChannelSelector",
+ "xlinkActuate",
+ "xlinkArcrole",
+ "xlinkHref",
+ "xlinkRole",
+ "xlinkShow",
+ "xlinkTitle",
+ "xlinkType",
+ "xmlBase",
+ "xmlns",
+ "xmlnsXlink",
+ "xmlLang",
+ "xmlSpace",
+ "y",
+ "y1",
+ "y2",
+ "yChannelSelector",
+ "z",
+ "zoomAndPan",
+ "for",
+ "class",
+ "autofocus",
+};
+
+/// Whether React passes `name` to an element as an attribute
+pub fn is_prop_valid(name: &str) -> bool {
+ VALID_PROPS.contains(name)
+ || name.starts_with("data-")
+ || name.starts_with("aria-")
+ || name.starts_with("x-")
+ || name
+ .strip_prefix("on")
+ .is_some_and(|event| event.starts_with(|c: char| c.is_ascii_uppercase()))
+}
diff --git a/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap b/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap
index 45662cd31..70a702afc 100644
--- a/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__compiled_bindings_never_dangle.snap
@@ -5,7 +5,7 @@ expression: outputs
[
(
"import { css, styled, Box } from '@devup-ui/react';\nconst myCss = css;\nconst again = myCss;\nconst S = styled;\nconst B = Box;\nexport const a = again({ color: 'red' });\nexport const s = S.div({ color: 'blue' });\nexport const b = ;",
- "export const a = \"a-a\";\nexport const s = ({ style, className, ...rest }) => ;\nexport const b = ;",
+ "export const a = \"a-a\";\nexport const s = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-b\", className].filter(Boolean).join(\" \")} style={style} />;\nexport const b = ;",
),
(
"import { css } from '@devup-ui/react';\nexport function f(css) { return css; }\nexport const a = css({ color: 'red' });",
diff --git a/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap
index 5325a6ce2..6c5c65ddc 100644
--- a/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap
@@ -64,6 +64,16 @@ ToBTreeSet {
layer: None,
},
),
+ Static(
+ ExtractStaticStyle {
+ property: "padding",
+ value: "4px",
+ level: 0,
+ selector: None,
+ style_order: None,
+ layer: None,
+ },
+ ),
},
- code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as tokens from \"./styles\";\nimport { card, rules } from \"./styles\";\nimport { emotionClass } from \"./emotion\";\nconst make = (n: number) => ({ m: n });\nconst local = make(2);\nconst computed = make(3);\nconst name = String(\"named\");\nconst shared = {\n\trules: make(4),\n\tfixed: { p: 5 }\n};\nexport const a = \"a-d\";\nexport const b = `a-e ${card}`;\nexport const c = `named x ${`y`}`;\nexport const d = ({ style, className, ...rest }) => ;\nexport const e = ({ style, className, ...rest }) => ;\nexport const f = (__devupProps) => (({ style, className, ...rest }) => )({\n\t...__devupProps,\n\t...{ role: \"note\" }\n});\nexport const g = (on) => `a-d a-a ${on ? \"\" : card} ${on ? typeof on === \"string\" ? on : \"\" : \"\"}`;\nexport const h = \"a-b\";\nexport const i = \"a-d\";\nexport const k = `k ${tokens.card}`;\nexport const l = `a-c ${emotionClass}`;\nexport const n = ;\n",
+ code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as tokens from \"./styles\";\nimport { card, rules } from \"./styles\";\nimport { emotionClass } from \"./emotion\";\nconst make = (n: number) => ({ m: n });\nconst local = make(2);\nconst computed = make(3);\nconst name = String(\"named\");\nconst shared = {\n\trules: make(4),\n\tfixed: { p: 5 }\n};\nexport const a = \"a-e\";\nexport const b = \"a-a a-f\";\nexport const c = `named x ${`y`}`;\nexport const d = ({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-e\", className].filter(Boolean).join(\" \")} style={style} />;\nexport const e = ({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-f\", className].filter(Boolean).join(\" \")} style={style} />;\nexport const f = (__devupProps) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-g\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n});\nexport const g = (on) => `a-e a-b ${on ? typeof on === \"string\" ? on : \"\" : \"\"}`;\nexport const h = \"a-c\";\nexport const i = \"a-e\";\nexport const k = `k ${tokens.card}`;\nexport const l = `a-d ${emotionClass}`;\nexport const n = ;\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap
index 12974320c..a6019f8f9 100644
--- a/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap
@@ -35,5 +35,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const a = \"a b\";\nexport const b = \"a extra c\";\nexport const c = \"a\";\nexport const d = cond ? \"a\" : \"\";\n",
+ code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const a = \"a b\";\nexport const b = \"extra a c\";\nexport const c = \"a\";\nexport const d = cond ? \"a\" : \"\";\n",
}
diff --git a/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap b/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap
index 0288b22d1..e59dd6ea2 100644
--- a/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap
+++ b/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap
@@ -99,5 +99,5 @@ ToBTreeSet {
},
),
},
- code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY } from \"./tokens\";\nconst make = (n: number) => ({ m: n });\nconst KEY = [\"co\", \"lor\"].join(\"\");\nfunction darken(amount: number, color: string) {\n\treturn color === PRIMARY ? \"#112233\" : color;\n}\nconst card = make(2);\nconst hover = make(3);\nconst parts = [make(4), { p: 1 }];\nexport const a =