Skip to content

Commit 28ee00a

Browse files
authored
feat(transform): fold static JSX past opaque spreads and styled() chains (#3699)
* fix(transform): rewrite JSX elements whose every spread is style-only A spread the extractor already folded into the element's styles still counted as a runtime spread, so an element like `<styled.div {...baseStyle} css={x} />` bailed even though nothing dynamic was left. Recognize the resolved identifier and member spreads and let the element rewrite. `StyleSpread::Open` now carries the span of the spread that produced it, so a later pass can tell which source spread is opaque rather than only that one is. * feat(transform): fold the static half of an element with an opaque spread `<styled.button {...props} type="button" css={x} />` bailed entirely because of the spread, so every style source on it was resolved per render. The factory and the spread now stay — style-prop splitting and DOM filtering still happen for whatever the spread turns out to hold — while every style Panda can see collapses into one className. Requires a bare identifier spread, exactly one opaque spread, no style source ahead of it, and no explicit className, so the precedence the factory gives them is reproducible. The class read is optional-chained because a nullish spread is a legal no-op in JSX. * feat(transform): fold a same-file styled() chain to its host element `const Button = styled('button', { base: … })` renders through forwardRef, which costs a component level per instance even when the class never changes. When the chain is base-only and rooted in an intrinsic tag, every `<Button>` folds to `<button className="…">` and the definition is left for the bundler to drop. A tag folds only when it resolves to the symbol the chain declared, so a local binding that shadows the module-level one is untouched — the same check the imported-tag branch beside it already makes. Variants, an options argument, a non-local base, `let`/`var`, or a declaration inside a function all keep the runtime chain.
1 parent 736358d commit 28ee00a

11 files changed

Lines changed: 1115 additions & 29 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@pandacss/compiler': patch
3+
---
4+
5+
Precompute the static styles of a `styled.*` element that also spreads unknown props. The factory and the spread stay
6+
so runtime style props still work; everything Panda can see at build time collapses into one `className`.

.changeset/styled-chain-fold.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@pandacss/compiler': patch
3+
---
4+
5+
Fold same-file `styled()` chains to their underlying element when the class string is constant, so
6+
`<Button>` no longer pays for a `forwardRef` component level at runtime. Chains with variants, an
7+
options argument, or a non-local base keep the existing runtime behaviour.

crates/pandacss_extractor/src/jsx.rs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
55
use crate::{
66
CssSyntaxKind, Diagnostic, ExpressionFacts, ExtractorConfig, ImportSpecifierKind, JsxKind,
7-
Literal, MatchCategory, MatchedImport, Matchers, Span, StyleTree, VisitorContext,
7+
Literal, MatchCategory, MatchedImport, Matchers, Span, StyleObject, StyleTree, VisitorContext,
88
css_template::css_template_to_style_tree,
99
jsx_react_runtime,
1010
matcher::member_display,
@@ -13,6 +13,7 @@ use crate::{
1313
},
1414
span_from_oxc,
1515
style_tree::{jsx_attributes_to_style_tree, project_literal},
16+
styled_bindings::{StyledBinding, StyledBindings, collect_styled_bindings},
1617
};
1718
use oxc_allocator::Allocator;
1819
use oxc_ast::ast::{
@@ -196,6 +197,7 @@ pub(crate) fn collect_jsx(
196197
out: &mut out,
197198
react_runtime,
198199
style_source_refs: None,
200+
styled_bindings: collect_styled_bindings(program, ctx),
199201
retain_transform_facts,
200202
};
201203
extractor.visit_program(program);
@@ -214,6 +216,7 @@ pub(crate) fn collect_jsx_verbose(
214216
out: &mut out,
215217
react_runtime,
216218
style_source_refs: Some(&mut style_source_refs),
219+
styled_bindings: collect_styled_bindings(program, ctx),
217220
retain_transform_facts: false,
218221
};
219222
extractor.visit_program(program);
@@ -287,6 +290,7 @@ pub(crate) struct Extractor<'walk, 'ctx, 'cb> {
287290
out: &'walk mut Vec<ExtractedJsx>,
288291
react_runtime: jsx_react_runtime::ReactRuntimeImports,
289292
style_source_refs: Option<&'walk mut Vec<StyleSourceRef>>,
293+
styled_bindings: StyledBindings,
290294
pub(crate) retain_transform_facts: bool,
291295
}
292296

@@ -298,6 +302,9 @@ pub(crate) struct ResolvedTag<'a> {
298302
/// `true` when resolved via a matched Panda import; `false` for the
299303
/// name-only `should_match_tag` fallback.
300304
pub(crate) panda_owned: bool,
305+
/// Set when the tag is a local `styled()` chain the fold may collapse to
306+
/// its host element.
307+
pub(crate) styled: Option<&'a StyledBinding>,
301308
}
302309

303310
impl Extractor<'_, '_, '_> {
@@ -329,10 +336,30 @@ impl Extractor<'_, '_, '_> {
329336
alias: Cow::Borrowed(&matched.alias),
330337
emit_empty: true,
331338
panda_owned: true,
339+
styled: None,
332340
});
333341
}
334342

335343
let tag_name = id.name.as_str();
344+
// A local `styled()` chain stands in for `<styled.{tag}>`, so the
345+
// fold reaches it with the machinery host factories already use.
346+
// Only when the tag resolves to the symbol we recorded — a local
347+
// binding may shadow the module-level chain.
348+
if let Some(binding) = self.styled_bindings.get(tag_name)
349+
&& binding.symbol_id.is_some()
350+
&& let Some(resolver) = self.ctx.resolver
351+
&& resolver.symbol_for_identifier(id) == binding.symbol_id
352+
{
353+
return Some(ResolvedTag {
354+
category: MatchCategory::Jsx,
355+
name: Cow::Owned(format!("styled.{}", binding.intrinsic)),
356+
alias: Cow::Borrowed(tag_name),
357+
emit_empty: true,
358+
panda_owned: true,
359+
styled: Some(binding),
360+
});
361+
}
362+
336363
let is_configured_component = self.ctx.config.jsx.is_component_tag(tag_name);
337364
if !is_configured_component
338365
&& !self
@@ -349,6 +376,7 @@ impl Extractor<'_, '_, '_> {
349376
alias: Cow::Borrowed(tag_name),
350377
emit_empty: is_configured_component,
351378
panda_owned: false,
379+
styled: None,
352380
})
353381
}
354382
JSXElementName::MemberExpression(member) => {
@@ -386,6 +414,7 @@ impl Extractor<'_, '_, '_> {
386414
alias: Cow::Borrowed(root),
387415
emit_empty: is_configured_component,
388416
panda_owned: false,
417+
styled: None,
389418
});
390419
}
391420
return None;
@@ -410,6 +439,7 @@ impl Extractor<'_, '_, '_> {
410439
alias: Cow::Borrowed(&matched.alias),
411440
emit_empty: true,
412441
panda_owned: true,
442+
styled: None,
413443
});
414444
}
415445

@@ -423,6 +453,7 @@ impl Extractor<'_, '_, '_> {
423453
alias: Cow::Borrowed(&matched.alias),
424454
emit_empty: true,
425455
panda_owned: true,
456+
styled: None,
426457
})
427458
}
428459
ImportSpecifierKind::Namespace => {
@@ -441,6 +472,7 @@ impl Extractor<'_, '_, '_> {
441472
alias: Cow::Borrowed(&matched.alias),
442473
emit_empty: true,
443474
panda_owned: true,
475+
styled: None,
444476
})
445477
}
446478
ImportSpecifierKind::Default => None,
@@ -484,6 +516,7 @@ impl Extractor<'_, '_, '_> {
484516
alias: Cow::Borrowed(&matched.alias),
485517
emit_empty: true,
486518
panda_owned: true,
519+
styled: None,
487520
});
488521
}
489522

@@ -504,6 +537,7 @@ impl Extractor<'_, '_, '_> {
504537
alias: Cow::Borrowed(tag_name),
505538
emit_empty: is_configured_component,
506539
panda_owned: false,
540+
styled: None,
507541
})
508542
}
509543
Expression::StringLiteral(s) => {
@@ -517,6 +551,7 @@ impl Extractor<'_, '_, '_> {
517551
alias: Cow::Borrowed(tag_name),
518552
emit_empty: true,
519553
panda_owned: false,
554+
styled: None,
520555
})
521556
}
522557
Expression::StaticMemberExpression(member) => {
@@ -547,12 +582,18 @@ impl<'a> Visit<'a> for Extractor<'_, '_, '_> {
547582
let emit_empty = resolved.emit_empty;
548583
let panda_owned = resolved.panda_owned;
549584

585+
let styled_base = resolved.styled.map(|binding| binding.base.clone());
586+
let styled_intrinsic = resolved.styled.map(|binding| binding.intrinsic.clone());
550587
let style = jsx_attributes_to_style_tree(
551588
&element.attributes,
552589
self.ctx.resolver,
553590
&self.ctx.config.jsx,
554591
&tag_name,
555592
);
593+
let style = match (styled_base, style) {
594+
(Some(base), style) => prepend_styled_base(base, style),
595+
(None, style) => style,
596+
};
556597
let data = style
557598
.as_ref()
558599
.and_then(project_literal)
@@ -606,7 +647,8 @@ impl<'a> Visit<'a> for Extractor<'_, '_, '_> {
606647
source: if retain {
607648
JsxSourceFacts {
608649
kind: JsxSourceKind::Element,
609-
factory_intrinsic: factory_intrinsic_from_jsx_name(&element.name),
650+
factory_intrinsic: styled_intrinsic
651+
.or_else(|| factory_intrinsic_from_jsx_name(&element.name)),
610652
..Default::default()
611653
}
612654
} else {
@@ -688,6 +730,26 @@ fn factory_intrinsic_from_jsx_name(name: &JSXElementName<'_>) -> Option<String>
688730
}
689731
}
690732

733+
/// Puts a `styled()` chain's `base` underneath the element's own props, which
734+
/// is the precedence the runtime factory gives them. `None` when the element's
735+
/// styles are not a plain object, since only then is the ordering well defined.
736+
fn prepend_styled_base(
737+
base: Vec<(String, StyleTree)>,
738+
style: Option<StyleTree>,
739+
) -> Option<StyleTree> {
740+
let own = match style {
741+
None => StyleObject::default(),
742+
Some(StyleTree::Object(object)) => object,
743+
Some(_) => return None,
744+
};
745+
let mut entries = base;
746+
entries.extend(own.entries);
747+
Some(StyleTree::Object(StyleObject {
748+
entries,
749+
spreads: own.spreads,
750+
}))
751+
}
752+
691753
pub(crate) fn factory_intrinsic_from_expression(expression: &Expression<'_>) -> Option<String> {
692754
match expression.get_inner_expression() {
693755
Expression::StaticMemberExpression(member) => Some(member.property.name.to_string()),

crates/pandacss_extractor/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ mod scope;
2929
mod source;
3030
mod source_refs;
3131
mod style_tree;
32+
mod styled_bindings;
3233
mod svelte_adapter;
3334
mod template_styles;
3435
mod transform_facts;

crates/pandacss_extractor/src/style_tree.rs

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ pub enum StyleSpread {
7878
/// Keys overwritten by a later static entry.
7979
overridden: Vec<String>,
8080
},
81-
/// Opaque object member or spread. Transform bails; encode skips it.
82-
Open,
81+
/// Opaque object member or spread. Encode skips it; transform needs the
82+
/// span to tell which source spread it came from.
83+
Open { span: Span },
8384
/// Dynamic `...(a || b)` or `...(a ?? b)` with a known fallback. Transform
8485
/// bails; encode merges the fallback.
8586
OpenWithFallback { fallback: StyleTree },
@@ -89,7 +90,7 @@ impl StyleSpread {
8990
/// True for rewrite-critical open spreads.
9091
#[must_use]
9192
pub const fn is_open(&self) -> bool {
92-
matches!(self, Self::Open | Self::OpenWithFallback { .. })
93+
matches!(self, Self::Open { .. } | Self::OpenWithFallback { .. })
9394
}
9495
}
9596

@@ -181,7 +182,7 @@ fn project_object(obj: &StyleObject) -> Option<Literal> {
181182
}
182183
}
183184
}
184-
StyleSpread::Open => {}
185+
StyleSpread::Open { .. } => {}
185186
}
186187
}
187188

@@ -196,7 +197,7 @@ fn project_object(obj: &StyleObject) -> Option<Literal> {
196197
.iter()
197198
.all(|(_, v)| project_literal(v).is_none())
198199
&& obj.spreads.iter().all(|s| match s {
199-
StyleSpread::Open => true,
200+
StyleSpread::Open { .. } => true,
200201
StyleSpread::Ternary {
201202
consequent,
202203
alternate,
@@ -415,11 +416,15 @@ fn object_to_style_tree(
415416
match prop {
416417
ObjectPropertyKind::ObjectProperty(prop) => {
417418
if prop.method || prop.kind != PropertyKind::Init {
418-
spreads.push(StyleSpread::Open);
419+
spreads.push(StyleSpread::Open {
420+
span: span_from_oxc(prop.span),
421+
});
419422
continue;
420423
}
421424
let Some(key) = property_key_to_string(&prop.key, prop.computed, resolver) else {
422-
spreads.push(StyleSpread::Open);
425+
spreads.push(StyleSpread::Open {
426+
span: span_from_oxc(prop.span),
427+
});
423428
continue;
424429
};
425430
mark_spreads_overridden(&mut spreads, &key);
@@ -446,6 +451,8 @@ fn push_style_spread(
446451
argument: &Expression<'_>,
447452
resolver: Option<&Resolver<'_, '_>>,
448453
) {
454+
// Before `get_inner_expression`, so it lines up with `ExpressionFacts::span`.
455+
let open_span = span_from_oxc(argument.span());
449456
let argument = argument.get_inner_expression();
450457
match argument {
451458
Expression::ConditionalExpression(c) => {
@@ -509,7 +516,7 @@ fn push_style_spread(
509516
Some(fallback) => {
510517
spreads.push(StyleSpread::OpenWithFallback { fallback });
511518
}
512-
None => spreads.push(StyleSpread::Open),
519+
None => spreads.push(StyleSpread::Open { span: open_span }),
513520
}
514521
}
515522
}
@@ -522,7 +529,7 @@ fn push_style_spread(
522529
}
523530
spreads.extend(inner.spreads);
524531
}
525-
Some(StyleTree::Open) | None => spreads.push(StyleSpread::Open),
532+
Some(StyleTree::Open) | None => spreads.push(StyleSpread::Open { span: open_span }),
526533
Some(StyleTree::OpenWithFallback(inner)) => {
527534
spreads.push(StyleSpread::OpenWithFallback { fallback: *inner });
528535
}
@@ -559,7 +566,7 @@ fn mark_spreads_overridden(spreads: &mut [StyleSpread], key: &str) {
559566
StyleSpread::Ternary { overridden, .. } | StyleSpread::And { overridden, .. } => {
560567
overridden
561568
}
562-
StyleSpread::Open | StyleSpread::OpenWithFallback { .. } => continue,
569+
StyleSpread::Open { .. } | StyleSpread::OpenWithFallback { .. } => continue,
563570
};
564571
if !overridden.iter().any(|existing| existing == key) {
565572
overridden.push(key.to_owned());
@@ -694,7 +701,7 @@ fn filter_spread_props(spread: &mut StyleSpread, jsx: &crate::JsxExtractionConfi
694701
StyleSpread::And { value, .. } | StyleSpread::OpenWithFallback { fallback: value } => {
695702
filter_object_props(value, jsx, tag_name);
696703
}
697-
StyleSpread::Open => {}
704+
StyleSpread::Open { .. } => {}
698705
}
699706
}
700707

@@ -743,7 +750,7 @@ fn filter_react_runtime_props_in_spread(spread: &mut StyleSpread) {
743750
StyleSpread::And { value, .. } | StyleSpread::OpenWithFallback { fallback: value } => {
744751
filter_react_runtime_props_in_tree(value);
745752
}
746-
StyleSpread::Open => {}
753+
StyleSpread::Open { .. } => {}
747754
}
748755
}
749756

0 commit comments

Comments
 (0)