Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changepacks/changepack_log_transient_props.json
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@
const G = styled(motion.div)`
color: red;
`
const H = styled(forwardRef(Field))({ bg: 'red' })

Check warning on line 30 in apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx

View workflow job for this annotation

GitHub Actions / publish

In React 19, 'forwardRef' is no longer necessary. Pass 'ref' as a prop instead
```

Any base renders: a tag, a component, a member such as `motion.div`, or any other value, read once where the component is declared. `null`, numbers, booleans and `undefined` never render, so they are build errors, as is a `styled` left uncompiled (`styled('div')` never given styles, `styled.div` exported bare): its import is removed, and that code would throw at runtime.

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`.

Expand All @@ -44,7 +44,7 @@

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
Expand All @@ -66,6 +66,23 @@
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 <Title {...props} />
}

// `$tone` sets the color; the <h1> gets `id` but neither `$tone` nor `$track`
const heading = <Heading $tone="teal" $track="hero" id="intro" />
```

## 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:
Expand Down Expand Up @@ -128,7 +145,7 @@
| `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

Expand Down
7 changes: 5 additions & 2 deletions libs/extractor/src/extractor/extract_style_from_styled.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1572,7 +1572,7 @@ fn create_styled_component<'a>(
ast_builder,
identifier(ast_builder, RENDERED),
(class_name, style, identifier(ast_builder, "forwardedAs")),
without_props(ast_builder, withheld),
super::styled_transient::filter(ast_builder, without_props(ast_builder, withheld)),
)
} else {
Expression::new_jsx_element(
Expand All @@ -1585,7 +1585,10 @@ fn create_styled_component<'a>(
vec![
JSXAttributeItem::new_spread_attribute(
SPAN,
without_props(ast_builder, withheld),
super::styled_transient::filter(
ast_builder,
without_props(ast_builder, withheld),
),
ast_builder,
),
JSXAttributeItem::new_attribute(
Expand Down
1 change: 1 addition & 0 deletions libs/extractor/src/extractor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ mod styled_record;
mod styled_record_evaluation;
#[cfg(test)]
mod styled_record_tests;
mod styled_transient;

/**
* type
Expand Down
50 changes: 50 additions & 0 deletions libs/extractor/src/extractor/styled_record_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,56 @@ fn captures_and_filter_survive_when_prepared_base_retargets_then_extends(
);
}

#[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.render(input,input.ref);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(
Expand Down
100 changes: 100 additions & 0 deletions libs/extractor/src/extractor/styled_transient.rs
Original file line number Diff line number Diff line change
@@ -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::<oxc_allocator::Box<oxc_ast::ast::TSTypeAnnotation<'a>>>,
None::<oxc_allocator::Box<Expression<'a>>>,
false,
None,
false,
false,
builder,
);
let params = FormalParameters::boxed(
SPAN,
FormalParameterKind::ArrowFormalParameters,
Vec::from_array_in([parameter], builder),
None::<oxc_allocator::Box<oxc_ast::ast::FormalParameterRest<'a>>>,
builder,
);
let predicate = Expression::new_arrow_function_expression(
SPAN,
false,
None::<oxc_allocator::Box<oxc_ast::ast::TSTypeParameterDeclaration<'a>>>,
params,
None::<oxc_allocator::Box<oxc_ast::ast::TSTypeAnnotation<'a>>>,
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::<oxc_allocator::Box<oxc_ast::ast::TSTypeParameterInstantiation<'a>>>,
Vec::from_array_in([argument.into()], builder),
false,
builder,
)
}
Loading
Loading