Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9e29139
fix(extractor): let a later css() part replace an earlier one's decla…
owjs3901 Oct 1, 2026
337ddeb
test(extractor): cover known style composition branches
owjs3901 Oct 1, 2026
024a24c
chore: satisfy clippy assert_is_empty lint on Rust 1.99
owjs3901 Oct 1, 2026
71585a9
test(extractor): cover false ?? composition branch
owjs3901 Oct 1, 2026
52f4518
refactor(extractor): split coalesce check so composition lines are me…
owjs3901 Oct 1, 2026
1cedf9f
fix(extractor): compose styled extensions, merge attrs and let later …
owjs3901 Oct 1, 2026
01adab9
test(extractor): restore runtime-value composition test and cover sty…
owjs3901 Oct 1, 2026
63d954a
fix(extractor): merge StyleX props and attrs key by key with null uns…
owjs3901 Oct 1, 2026
b6ceb5f
test(extractor): cover empty StyleX conditions and arrays with runtim…
owjs3901 Oct 1, 2026
af1177d
merge: update PR #706 with main ESLint checks
owjs3901 Oct 8, 2026
944e723
merge: update PR #706 with 9a953607
owjs3901 Oct 8, 2026
5a70d2a
merge: update PR #706 with db631c6c
owjs3901 Oct 8, 2026
30247ba
merge: update PR #706 with main
owjs3901 Oct 8, 2026
c7ea90a
merge: update PR #707 with main
owjs3901 Oct 8, 2026
64dbc2f
merge: update PR #708 with PR #707
owjs3901 Oct 8, 2026
e23acb6
merge: update PR #707 with main and preserve inherited captures
owjs3901 Oct 14, 2026
fa11c07
merge: update PR #707 with latest main and exact test hotfixes
owjs3901 Oct 14, 2026
3ba0ecf
merge: update PR #708 with PR #707 and retain StyleX composition
owjs3901 Oct 14, 2026
bcd240e
merge: refresh PR #707 with main and cover inherited render paths
owjs3901 Oct 14, 2026
53011cf
fix(extractor): lift capture visitor out of generic method
owjs3901 Oct 14, 2026
009c848
Merge remote-tracking branch 'origin/fix/compose-styled' into HEAD
owjs3901 Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_compose_known_styles.json
Original file line number Diff line number Diff line change
@@ -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"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_compose_styled.json
Original file line number Diff line number Diff line change
@@ -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"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_compose_stylex.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "stylex.props() and stylex.attrs() merge namespaces this file creates key by key as StyleX does: a later namespace's key replaces an earlier one's, so props(red, blue) is blue and props(blue, red) is red, and a key set to null removes the earlier value; conditions (cond && styles.x, cond ? a : b) choose per key. Arguments the build cannot read key by key (namespaces from other modules, props, dynamic namespaces, include()) keep the previous joined behavior",
"date": "2026-10-01T00:00:00.000Z"
}
360 changes: 360 additions & 0 deletions libs/extractor/src/composition.rs
Original file line number Diff line number Diff line change
@@ -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<StyleSelector>,
level: u8,
layer: Option<String>,
}

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<Choice<'a>>,
alternate: Box<Choice<'a>>,
},
}

/// What a part does to a key
enum Overlay<'a> {
/// Leaves what came before
Keep,
Atom(ExtractStyleValue),
Conditional {
test: Expression<'a>,
consequent: Box<Overlay<'a>>,
alternate: Box<Overlay<'a>>,
},
}

/// 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<ExtractStyleProp<'a>>,
}

impl<'a> Composition<'a> {
/// A part the build knows completely
pub fn apply(&mut self, ast_builder: &AstBuilder<'a>, props: Vec<ExtractStyleProp<'a>>) {
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<ExtractStyleProp<'a>>,
alternate: Vec<ExtractStyleProp<'a>>,
) {
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<CascadeKey> = 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<ExtractStyleProp<'a>> {
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<Vec<ExtractStyleValue>> {
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<ExtractStyleValue>),
/// A rule object
Rules(Expression<'a>),
}

/// A side of a condition among the parts of `css(...)`
pub enum KnownSide<'a> {
Styles(Vec<KnownStyles<'a>>),
/// 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<KnownStyles<'a>>),
Conditional {
test: Expression<'a>,
consequent: Vec<KnownStyles<'a>>,
alternate: Vec<KnownStyles<'a>>,
},
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<ExtractStyleProp<'_>> {
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),
})
}
}
}
4 changes: 4 additions & 0 deletions libs/extractor/src/extract_style/extract_dynamic_style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
self.style_order
}
Expand Down
Loading
Loading