Skip to content
Merged
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_unresolved_global_inputs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Reject unresolved imports and font-face values, misplaced font-template expressions and unusable functional-selector arguments with located build errors, preserving static values and genuine optional absence without a styling runtime.",
"date": "2026-10-19T00:00:00.000Z"
}
1 change: 1 addition & 0 deletions libs/css/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub mod optimize_value;
mod property_names;
pub mod quoted;
pub mod rm_css_comment;
pub mod selector_params;
mod selector_separator;
pub mod shorthand;
pub mod slot_reservation;
Expand Down
37 changes: 37 additions & 0 deletions libs/css/src/selector_params.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
use crate::style_selector::StyleSelector;

/// The bare functional pseudo targeted by `add_selector_params`, without allocating.
#[must_use]
pub fn parameter_name(selector: &StyleSelector) -> Option<&str> {
let value = match selector {
StyleSelector::Selector(value)
| StyleSelector::Global(value, _)
| StyleSelector::At {
selector: Some(value),
..
} => value,
StyleSelector::At { selector: None, .. } => return None,
};
let end = crate::params_position(value)?;
value[..end].rsplit(':').next()
}

#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;

#[rstest]
#[case("&:nth-child", Some("nth-child"))]
#[case(":is([role=group],[data-group]):nth-child &:hover", Some("nth-child"))]
#[case("&:nth-child(2):hover", None)]
#[case("&:hover", None)]
#[case("&::slotted", Some("slotted"))]
fn finds_the_same_bare_target_as_the_formatter(
#[case] selector: &str,
#[case] expected: Option<&str>,
) {
let selector = StyleSelector::Selector(selector.to_string());
assert_eq!(parameter_name(&selector), expected);
}
}
106 changes: 105 additions & 1 deletion libs/extractor/src/barrel_pipeline_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ fn resolver(specifier: &str, _: &str) -> Option<ResolvedModule> {
match specifier {
"./ui" => Some(ResolvedModule {
path: "/src/ui.ts".to_string(),
code: "export { css, Box } from '@devup-ui/react';".to_string(),
code: "export { css, Box, globalCss } from '@devup-ui/react';".to_string(),
}),
_ => None,
}
Expand Down Expand Up @@ -159,3 +159,107 @@ fn source_map_and_units_survive_barrel_namespace_alias_and_boa_layers()
assert_eq!((token.get_src_line(), token.get_src_col()), (5, 13));
Ok(())
}

#[rstest]
#[case("import { globalCss } from './ui';", "globalCss")]
#[case("import * as UI from './ui';", "UI.globalCss")]
#[serial]
fn global_imports_evaluate_pure_calls_after_barrel_rewriting(
#[case] import: &str,
#[case] api: &str,
#[values(false, true)] single_css: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Given: a structural URL needs evaluation after the project import is rewritten.
reset_class_map();
reset_file_map();
let source = format!(
"{import}\n{api}({{imports:[{{url:(() => 'pure.css')()}}]}});\nexport const after = 1;"
);
// When: the real barrel and value-evaluation pipeline extracts the global.
let output = extract_with_modules(
"/src/app.tsx",
&source,
ExtractOption {
single_css,
..ExtractOption::default()
},
true,
&resolver,
)?;
// Then: the exact import, dependency and following authored coordinate survive.
let urls: Vec<_> = output
.styles
.iter()
.map(|style| match style {
crate::ExtractStyleValue::Import(import) => import.url.as_str(),
other => panic!("expected import, got {other:?}"),
})
.collect();
assert_eq!(urls, vec!["\"pure.css\""]);
assert_eq!(output.dependencies, vec!["/src/ui.ts"]);
let map =
oxc_sourcemap::SourceMap::from_json_string(output.map.as_deref().ok_or("missing map")?)?;
assert_eq!(map.get_source_content(0), Some(source.as_str()));
let (line, generated) = output
.code
.lines()
.enumerate()
.find(|(_, line)| line.contains("after"))
.ok_or("missing declaration")?;
let column = position(generated, "after");
let token = map
.get_tokens()
.find(|token| {
usize::try_from(token.get_dst_line()) == Ok(line)
&& usize::try_from(token.get_dst_col()) == Ok(column)
})
.ok_or("missing declaration token")?;
assert_eq!((token.get_src_line(), token.get_src_col()), (2, 13));
Ok(())
}

#[rstest]
#[case(("font-family:${() => \"Face\"};", "() => \"Face\"", "() => \"Face\""))]
#[case(("font-family:${() => \" at build time: \"};", "() => \" at build time: \"", "() => \" at build time: \""))]
#[case(("font-family:${class {}};", "class {}", "(class {})"))]
#[case(("font-family:${function() {}};", "function() {}", "(function() {})"))]
#[case(("${p => p.theme.fonts.body}:Face;", "p => p.theme.fonts.body", "(p) => p.theme.fonts.body"))]
#[case(("${p => p.theme.fonts.body};font-family:Face;", "p => p.theme.fonts.body", "(p) => p.theme.fonts.body"))]
#[serial]
fn font_interpolations_keep_authored_refusals_after_barrel_rewriting(
#[case] fixture: (&str, &str, &str),
#[values(false, true)] namespace: bool,
#[values(false, true)] single_css: bool,
) {
// Given: a rewritten global contains a function or misplaced theme interpolation.
reset_class_map();
reset_file_map();
let (text, marker, rejected) = fixture;
let (import, api) = if namespace {
("import * as UI from './ui';", "UI.globalCss")
} else {
("import { globalCss } from './ui';", "globalCss")
};
let statement = format!("{api}({{fontFaces:[`{text}src:url(face.woff2);`]}});");
let column = position(&statement, marker) + 1;
let source = format!("{import}\n{statement}");
// When: extraction sees the invalid structural leaf through the project barrel.
let error = failure(extract_with_modules(
"/src/app.tsx",
&source,
ExtractOption {
single_css,
..ExtractOption::default()
},
false,
&resolver,
));
// Then: neither evaluation nor rewrite mapping can discard or relocate the refusal.
assert_eq!(
error,
format!(
"/src/app.tsx:2:{column}: `globalCss()` cannot use `{rejected}` at build time: {}",
crate::extractor::global_inputs::FONT_TEMPLATE_INPUT
)
);
}
45 changes: 45 additions & 0 deletions libs/extractor/src/dead_properties/calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ use oxc_ast_visit::{
use oxc_span::GetSpan;
use rustc_hash::FxHashMap;

#[path = "params.rs"]
mod params;

pub(super) fn collect(
stylesheet: crate::vanilla_extract::Stylesheet<'_>,
package: &str,
Expand Down Expand Up @@ -149,12 +152,45 @@ impl Calls<'_> {
});
crate::locate(self.filename, self.source, offset)
}

fn params(&mut self, expression: &Expression<'_>) {
for (offset, leaf) in params::errors(expression, self.scoping) {
self.errors.push((
offset,
crate::utils::build_time_error(
"css",
&leaf,
crate::extractor::selector_params::SELECTOR_PARAMS,
),
));
}
}
}

impl<'a> Visit<'a> for Calls<'_> {
fn visit_tagged_template_expression(&mut self, tagged: &TaggedTemplateExpression<'a>) {
let tag = crate::utils::unwrap_syntax_only(&tagged.tag);
if self.api(tag).as_deref() == Some("css") {
let mut before = String::new();
for (index, quasi) in tagged.quasi.quasis.iter().enumerate() {
before.push_str(&quasi.value.raw);
if let Some(expression) = tagged.quasi.expressions.get(index) {
match crate::css_utils::interpolation_place(
&before,
&tagged.quasi.quasis[index + 1..],
) {
crate::css_utils::Place::Statement => self.params(expression),
crate::css_utils::Place::Value => {
before.push_str("__EXPR__");
continue;
}
crate::css_utils::Place::Other => {}
}
if let Some(text) = crate::utils::get_string_by_literal_expression(expression) {
before.push_str(&text);
}
}
}
// Keep the template site and its eval/await/yield expressions outside the forwarding closure.
let receiver = match tag {
Expression::StaticMemberExpression(member) => {
Expand Down Expand Up @@ -188,6 +224,15 @@ impl<'a> Visit<'a> for Calls<'_> {
};
if let Some((argument, kind)) = shape {
self.ranges.push((call.span.start, call.span.end));
if api == "css" {
for argument in &call.arguments {
if let Some(expression) = argument.as_expression() {
self.params(expression);
} else if let oxc_ast::ast::Argument::SpreadElement(spread) = argument {
self.params(&spread.argument);
}
}
}
if !(api == "styleVariants" && call.arguments.len() > 1)
&& let Some(expression) = call
.arguments
Expand Down
Loading
Loading