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
0edb211
fix(extractor): compile or reject function-local style objects, keep …
owjs3901 Oct 3, 2026
734fd16
test(extractor): cover parenthesized function-local style objects
owjs3901 Oct 3, 2026
743e59a
chore(extractor): merge imported styled definitions
owjs3901 Oct 3, 2026
c0a88f2
fix(extractor): preserve numeric units on logical rule operands
owjs3901 Oct 4, 2026
aaae7ac
fix(extractor): close build-time composition and styled capture pipel…
owjs3901 Oct 4, 2026
a3ea3a2
chore(changepack): record composition and styled extraction patch
owjs3901 Oct 4, 2026
ab459f8
test(extractor): cover composition and styled branches reported by Linux
owjs3901 Oct 4, 2026
d846365
merge: update PR #745 with PR #740
owjs3901 Oct 8, 2026
e4a1b5c
merge: update PR #754 with PR #745
owjs3901 Oct 8, 2026
bacd135
merge: update PR #754 with PR #736
owjs3901 Oct 8, 2026
ffc8ff6
merge: update PR #745 with PR #740
owjs3901 Oct 17, 2026
6ba102f
fix(extractor): preserve callback scope and local style guards
owjs3901 Oct 18, 2026
587a11c
merge: bring main with the merged composition parents into local styl…
owjs3901 Oct 20, 2026
a2cbcea
fix(extractor): cover callback preparation and remove redundant analysis
owjs3901 Oct 20, 2026
58de843
fix(extractor): guard generic self references and trim preparation
owjs3901 Oct 21, 2026
c3fe464
merge: update PR #745 with settled main
sisyphus-dev-ai Oct 23, 2026
2d51af4
merge: update PR #754 with PR #745
owjs3901 Oct 24, 2026
d6879f2
merge: update PR #754 with settled PR #745
owjs3901 Oct 24, 2026
f12842a
test(extractor): use neutral composition regression names
owjs3901 Oct 24, 2026
eab7109
test(extractor): close Linux composition coverage gaps
owjs3901 Oct 24, 2026
51295a3
ci: allow 180 seconds per coverage test binary
owjs3901 Oct 24, 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_composition_rest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Compose namespace-imported styled components, inline tagged CSS and exact CSS spreads with later styles winning, and compile conditional props rule objects and read-only function-local style shapes without a styling runtime. Local function captures keep lexical hygiene, so user names like rest, style or className never collide with generated code. Styled callbacks bound to read-only const arrow or function declarations compile like inline callbacks while keeping the original exports and live bindings. Emotion numbers on the left operand of OR and nullish whole-rule choices become px like the right operand. Preserve Emotion numeric units and captured values, read exact imported computations, and retain named CSS layer metadata through composition. Unknowable shapes and spreads keep located build errors, including an unknown whole-rule shape held by a local or shadowing binding named undefined; namespace css props report the same non-inlineable overlap error as named imports, and anonymous layers, invalid layer names, and component layer-order statements report located errors directing users to named layers and globalCss.",
"date": "2026-10-01T00:00:00.000Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_local_style_objects.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "A style object declared with const inside a function and passed only to styling APIs now compiles when its entries are written out, including Emotion, styled-components and vanilla-extract aliases. Successful member, primitive and constant-condition folding accounts for erased semantic references, so mutation or extra-use paths keep the existing located refusal while safe style-only member reads compile. css() and styled() compose known conditional rule arrays with the controller read once. Whole-rule props callbacks preserve their original invocation scope, destructuring, defaults, rest and authored argument evaluation order, including covered reads and empty/static-only results; fixed dynamic values become CSS variables without a runtime rule-object interpreter. Nonzero numbers remain px lengths except for unitless properties, and proven theme paths remain static CSS variables. Implicit actual-props theme reads through own arguments or a first rest formal retain located errors with correct catch, loop, switch and var binding ownership. Named whole-rule callbacks that refer to their own binding, including metadata reads, now give a located error naming the callback and explaining that recursion is not compiled; a same-named type parameter cannot hide that value self-reference, while type-only names and actual value shadows still compile. Repeated preparation checks, traversal and error buffering are consolidated without changing output or existing diagnostics. Opaque calls in Emotion ClassNames cx remain located errors until their result is proven unable to carry Devup style classes; only that allowance is deferred. Other located errors remain: attrs chained after styles, callbacks without one immediate explicit rule object, and local style objects also read elsewhere or holding unprovable style structure. No styling runtime or helper is added.",
"date": "2026-10-01T00:00:00.000Z"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Preserve extractor callback, theme-read and rule-array behavior while removing redundant preparation and unreachable analysis paths. Add Linux-coverable regression cases without coverage exclusions. No new build errors.",
"date": "2026-10-21T00:00:00.000Z"
}
91 changes: 91 additions & 0 deletions libs/extractor/src/bound_callback_alias_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
use std::collections::HashMap;

use crate::{ExtractOption, ExtractStyleValue, ImportAlias, extract};
use css::{class_map::reset_class_map, file_map::reset_file_map};
use rstest::rstest;
use serial_test::serial;

#[path = "bound_callback_export_tests.rs"]
mod exports;

#[path = "bound_callback_numeric_tests.rs"]
mod numeric;

#[rstest]
#[case("p => ({'@layer': {base: {color:p.color}}})", true)]
#[case("function(p) { return {'@layer': {base: {color:p.color}}}; }", true)]
#[case("p => ({'@layer': {inherit: {color:p.color}}})", false)]
#[case(
"function(p) { return {'@layer': {inherit: {color:p.color}}}; }",
false
)]
#[serial]
fn compiles_callback_layer_qa_sources(
#[case] callback: &str,
#[case] valid: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Given
reset_class_map();
reset_file_map();
let source = format!(
"import styled from '@emotion/styled';\nconst rules = {callback}; export const Choice = styled.div(rules);"
);
let option = ExtractOption {
import_aliases: HashMap::from([(
"@emotion/styled".into(),
ImportAlias::DefaultToNamed("styled".into()),
)]),
..ExtractOption::default()
};
// When
let result = extract("callback-layer.tsx", &source, option);
// Then
if valid {
let output = result?;
assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Dynamic(style) if style.layer.as_deref() == Some("base"))));
assert!(output.code.contains("\"--"), "{}", output.code);
} else {
let Err(error) = result else {
panic!("expected invalid layer error");
};
let error = error.to_string();
let line = source
.lines()
.nth(1)
.ok_or("missing callback source line")?;
let column = line.find("inherit").ok_or("missing inherit key")? + 1;
assert!(
error.starts_with(&format!("callback-layer.tsx:2:{column}:")),
"{error}"
);
assert!(error.contains("@layer inherit"), "{error}");
}
Ok(())
}

#[rstest]
#[case("import * as ui from '@devup-ui/react';", "ui.styled.div")]
#[case("import {styled as make} from '@devup-ui/react';", "make.div")]
#[serial]
fn resolves_semantic_style_imports(
#[case] import: &str,
#[case] callee: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Given
reset_class_map();
reset_file_map();
let source = format!(
"{import} const rules = p => ({{color:p.color}}); export const C = {callee}(rules);"
);
// When
let output = extract("callback.tsx", &source, ExtractOption::default())?;
// Then
assert!(
output
.styles
.iter()
.any(|style| matches!(style, ExtractStyleValue::Dynamic(_))),
"{output:?}"
);
Ok(())
}
51 changes: 51 additions & 0 deletions libs/extractor/src/bound_callback_captures.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use oxc_ast::{ast::Expression, ast::IdentifierReference};
use oxc_ast_visit::Visit;
use oxc_semantic::Scoping;
use oxc_span::{GetSpan, Span};
use oxc_syntax::symbol::SymbolId;
use rustc_hash::FxHashSet;

pub(super) fn collect(
expression: &Expression<'_>,
scoping: &Scoping,
) -> Option<FxHashSet<SymbolId>> {
struct Captures<'s> {
scoping: &'s Scoping,
span: Span,
symbols: FxHashSet<SymbolId>,
safe: bool,
}
impl<'a> Visit<'a> for Captures<'_> {
fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) {
match identifier
.reference_id
.get()
.and_then(|id| self.scoping.get_reference(id).symbol_id())
{
Some(symbol)
if !self
.span
.contains_inclusive(self.scoping.symbol_span(symbol)) =>
{
self.symbols.insert(symbol);
}
None if super::local_capture_reads::generated_name(&identifier.name)
|| self.scoping.symbol_ids().any(|symbol| {
self.scoping.symbol_name(symbol) == identifier.name.as_str()
}) =>
{
self.safe = false;
}
_ => {}
}
}
}
let mut collector = Captures {
scoping,
span: expression.span(),
symbols: FxHashSet::default(),
safe: true,
};
collector.visit_expression(expression);
collector.safe.then_some(collector.symbols)
}
206 changes: 206 additions & 0 deletions libs/extractor/src/bound_callback_export_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
use super::*;
use oxc_allocator::{Allocator, CloneIn, TakeIn};
use oxc_ast::ast::{Declaration, Expression, JSXAttributeValue, Statement};
use oxc_ast_visit::{VisitMut, walk_mut};
use oxc_span::SourceType;

fn declared_names(declaration: &Declaration<'_>) -> Vec<String> {
match declaration {
Declaration::VariableDeclaration(variable) => variable
.declarations
.iter()
.flat_map(|declarator| declarator.id.get_binding_identifiers())
.map(|binding| binding.name.to_string())
.collect(),
declaration => declaration
.id()
.iter()
.map(|id| id.name.to_string())
.collect(),
}
}

#[rstest]
#[case("export const color = 'red';", "color", "color")]
#[case(
"export function getColor() { return 'red'; }",
"getColor()",
"getColor"
)]
#[case("export let color = 'red';", "color", "color")]
#[case("export var color = 'red';", "color", "color")]
#[case("export const color = 'red', other = 'blue';", "color", "color,other")]
#[case("const color = 'red'; export {color};", "color", "color")]
#[case(
"const color = 'red'; export {color as publicColor};",
"color",
"publicColor"
)]
#[case(
"export default function getColor() { return 'red'; }",
"getColor()",
"default"
)]
#[case("export const color = 'red';", "p.color", "color")]
#[serial]
fn public_names_survive_when_callback_captures_exports(
#[case] declaration: &str,
#[case] value: &str,
#[case] names: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Given
reset_class_map();
reset_file_map();
let source = format!(
"import {{styled}} from '@devup-ui/react'; {declaration} const rules = p => ({{color:{value}}}); export const Choice = styled.div(rules);"
);
// When
let output = extract("export-capture.tsx", &source, ExtractOption::default())?;
// Then
let allocator = Allocator::default();
let parsed = oxc_parser::Parser::new(&allocator, &output.code, SourceType::tsx()).parse();
assert_eq!(parsed.diagnostics.len(), 0);
let mut exports = Vec::new();
for statement in &parsed.program.body {
match statement {
Statement::ExportDeclaration(export) => {
exports.extend(declared_names(&export.declaration));
}
Statement::ExportNamedDeclaration(export) => exports.extend(
export
.specifiers
.iter()
.map(|specifier| specifier.exported.name().to_string()),
),
Statement::ExportDefaultDeclaration(_) => exports.push("default".into()),
_ => {}
}
}
exports.sort();
let mut expected: Vec<_> = names.split(',').chain(["Choice"]).collect();
expected.sort_unstable();
assert_eq!(exports, expected, "{}", output.code);
Ok(())
}

/// Observe the generated element's style values using the same JSX-to-Boa
/// adapter as the capture hygiene tests, with getters for the parsed exports.
fn consumer_values(code: &str) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
struct RenderStyle<'a>(&'a Allocator);
impl<'a> VisitMut<'a> for RenderStyle<'a> {
fn visit_expression(&mut self, expression: &mut Expression<'a>) {
if let Expression::JSXElement(element) = expression {
let value = element
.opening_element
.attributes
.iter()
.find_map(|attribute| {
let attribute = attribute.as_attribute()?;
attribute
.name
.as_identifier()
.filter(|name| name.name == "style")?;
match attribute.value.as_ref()? {
JSXAttributeValue::ExpressionContainer(container) => {
container.expression.as_expression()
}
JSXAttributeValue::StringLiteral(_)
| JSXAttributeValue::Element(_)
| JSXAttributeValue::Fragment(_) => None,
}
});
assert!(value.is_some(), "expected generated style object");
if let Some(value) = value {
*expression = value.clone_in(self.0);
}
return;
}
walk_mut::walk_expression(self, expression);
}
}
let allocator = Allocator::default();
let mut parsed = oxc_parser::Parser::new(&allocator, code, SourceType::tsx()).parse();
assert_eq!(parsed.diagnostics.len(), 0);
RenderStyle(&allocator).visit_program(&mut parsed.program);
let mut getters = Vec::new();
let mut body = oxc_allocator::Vec::new_in(&&allocator);
for statement in parsed.program.body {
match statement {
Statement::ImportDeclaration(_) => {}
Statement::ExportDeclaration(mut export) => {
getters.extend(
declared_names(&export.declaration)
.iter()
.map(|name| format!("get {name}() {{ return {name}; }}")),
);
body.push(Statement::from(export.declaration.take_in(&&allocator)));
}
Statement::ExportNamedDeclaration(export) => {
getters.extend(export.specifiers.iter().map(|specifier| {
format!(
"get {}() {{ return {}; }}",
specifier.exported.name(),
specifier.local.name()
)
}));
}
statement => body.push(statement),
}
}
parsed.program.body = body;
let compiled = oxc_codegen::Codegen::new().build(&parsed.program).code;
let script = format!(
"const __devupForwardRef = fn => props => fn(props, undefined); {compiled}\nconst consumer = {{{}}}; const before = Object.values(consumer.Choice({{}})); consumer.update(); JSON.stringify([consumer.color, before, Object.values(consumer.Choice({{}}))]);",
getters.join(",")
);
let json = boa_engine::Context::default()
.eval(boa_engine::Source::from_bytes(&script))
.map_err(|error| format!("consumer evaluation failed: {error}\n{script}"))?
.as_string()
.ok_or("expected JSON")?
.to_std_string_escaped();
Ok(serde_json::from_str(&json)?)
}

#[rstest]
#[case("<div />")]
#[case("<div style />")]
#[case("<div style='red' />")]
#[case("<div style={/* missing expression */} />")]
#[case("<div {...props} />")]
fn consumer_oracle_when_generated_style_is_missing_rejects(#[case] jsx: &str) {
// Given
let code = format!("const Choice = () => {jsx};");
// When
let result = std::panic::catch_unwind(|| consumer_values(&code));
// Then
assert!(result.is_err(), "missing style must fail before evaluation");
}

#[rstest]
#[case("export let color = 'red';", "color")]
#[case("let color = 'red'; export {color};", "color")]
#[case(
"export let color = 'red'; function getColor() { return color; }",
"getColor()"
)]
#[serial]
fn consumer_and_rendered_values_stay_live_when_user_scopes_shadow_capture(
#[case] declaration: &str,
#[case] value: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Given
reset_class_map();
reset_file_map();
let source = format!(
"import {{styled}} from '@devup-ui/react'; {declaration} export function update() {{ color = 'blue'; }} const rules = p => ({{color:{value}}}); function make(color) {{ return styled.div(rules); }} export const Choice = make('green');"
);
// When
let output = extract("export-live.tsx", &source, ExtractOption::default())?;
// Then
assert_eq!(
consumer_values(&output.code)?,
serde_json::json!(["blue", ["red"], ["blue"]])
);
Ok(())
}
Loading
Loading