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
8 changes: 8 additions & 0 deletions .changepacks/changepack_log_keep_jsx_pragmas.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch",
"packages/bun-plugin/package.json": "Patch"
},
"note": "Keep leading JSX compiler pragmas through extraction, styled lowering, namespace rewrites and generated stylesheets with correct source maps. Enabled Emotion aliases keep the React JSX import-source compatibility bridge; other sources and opted-out Emotion are preserved. No new build errors.",
"date": "2026-10-17T00:00:00.000Z"
}
9 changes: 9 additions & 0 deletions .changepacks/changepack_log_next_webpack_css_delivery.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"changes": {
"packages/next-plugin/package.json": "Patch",
"packages/webpack-plugin/package.json": "Patch",
"packages/plugin-utils/package.json": "Patch"
},
"note": "Reconstruct the complete deterministic Next webpack stylesheet after each server, edge and client compiler reset, including accepted external package dependency closures, so delivered CSS matches rendered classes in per-file and single-CSS modes. Keep production CSS request paths stable across build IDs so persistent webpack cache cannot bypass the CSS loader. Genuine production prewarm collection, read and extraction failures now fail the build instead of silently dropping styles; failed initialization releases its build session without leaking styles into unrelated builds.",
"date": "2026-10-16T00:00:00.000Z"
}
3 changes: 2 additions & 1 deletion libs/extractor/src/emotion_namespace/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ impl Rewritten<'_> {
/// Generated from the rewritten module, so the map points at `source`.
pub(crate) fn output(&self) -> ExtractOutput {
let allocator = Allocator::default();
let program = Parser::new(
let mut program = Parser::new(
&allocator,
self.code,
crate::parsing::source_type(self.filename).unwrap_or_default(),
)
.parse()
.program;
crate::jsx_pragmas::HeaderPragmas::capture(&program).reanchor(&mut program);
let result = Codegen::new()
.with_options(CodegenOptions {
source_map_path: Some(PathBuf::from(self.filename)),
Expand Down
3 changes: 3 additions & 0 deletions libs/extractor/src/import_alias_visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@ pub fn transform_import_aliases_with_edits<'a>(

// Collect import transformations
let mut transformations: Vec<(usize, usize, String)> = Vec::new();
if import_aliases.contains_key("@emotion/react") {
transformations.extend(crate::jsx_import_source::emotion_replacements(&program));
}
let mut numbers = LibraryNumbers::default();

for stmt in &program.body {
Expand Down
101 changes: 101 additions & 0 deletions libs/extractor/src/jsx_import_source.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
use oxc_ast::ast::Program;
use oxc_span::GetSpan;

/// Keep the enabled Emotion alias on React after the bundler lowers JSX.
pub(crate) fn emotion_replacements(program: &Program<'_>) -> Vec<(usize, usize, String)> {
let header_end = program
.body
.first()
.map_or(program.span.end, |body| body.span().start);
let mut replacements = Vec::new();
for comment in &program.comments {
if comment.span.end > header_end {
continue;
}
let span = comment.content_span();
let original = span.source_text(program.source_text);
let text = original.trim_start_matches('*');
for (at, _) in text.match_indices("@jsxImportSource") {
if at > 0 && !text[..at].ends_with(char::is_whitespace) {
continue;
}
let rest = &text[at + "@jsxImportSource".len()..];
let value = rest.trim_start();
if value.len() == rest.len() {
continue;
}
if let Some(after) = value.strip_prefix("@emotion/react")
&& after.chars().next().is_none_or(char::is_whitespace)
{
let start = span.start as usize + original.len() - text.len()
+ at
+ "@jsxImportSource".len()
+ rest.len()
- value.len();
replacements.push((start, start + "@emotion/react".len(), "react".into()));
}
}
}
replacements
}

#[cfg(test)]
mod tests {
use crate::{ImportAlias, import_alias_visit::transform_import_aliases_with_edits};
use rstest::rstest;
use std::collections::HashMap;

#[rstest]
#[case(
"/** @jsxImportSource @emotion/react */",
"/** @jsxImportSource react */"
)]
#[case("/*@jsxImportSource\t@emotion/react*/", "/*@jsxImportSource\treact*/")]
#[case("// @jsxImportSource @emotion/react", "// @jsxImportSource react")]
#[case(
"/* @jsxImportSource @emotion/reactive */",
"/* @jsxImportSource @emotion/reactive */"
)]
#[case(
"/* not@jsxImportSource @emotion/react */",
"/* not@jsxImportSource @emotion/react */"
)]
#[case(
"/* @jsxImportSource@emotion/react */",
"/* @jsxImportSource@emotion/react */"
)]
#[case(
"/* @jsxImportSource @emotion/react more */",
"/* @jsxImportSource react more */"
)]
#[case(
"'use client'; // @jsxImportSource @emotion/react",
"'use client'; // @jsxImportSource react"
)]
#[case(
"/* text*@jsxImportSource @emotion/react */",
"/* text*@jsxImportSource @emotion/react */"
)]
fn emotion_source_rewrite_uses_exact_comment_tokens(
#[case] header: &str,
#[case] expected: &str,
) {
let source = format!(
"{header}\nexport const anchor=1;\n/** @jsxImportSource @emotion/react */\nexport const later=2;"
);

let (rewritten, _) = transform_import_aliases_with_edits(
&source,
"pragma.tsx",
"@devup-ui/react",
&HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]),
);

assert_eq!(
rewritten,
format!(
"{expected}\nexport const anchor=1;\n/** @jsxImportSource @emotion/react */\nexport const later=2;"
)
);
}
}
103 changes: 103 additions & 0 deletions libs/extractor/src/jsx_pragmas.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! Keep file-level JSX compiler instructions attached to emitted program nodes.
use oxc_allocator::Allocator;
use oxc_ast::{
Comment, CommentContent, CommentPosition,
ast::{Program, Statement},
};
use oxc_parser::Parser;
use oxc_span::{GetSpan, SourceType, Span};

/// The original header ends at the first body statement, not the first directive.
pub(crate) struct HeaderPragmas {
end: u32,
}

impl HeaderPragmas {
pub(crate) fn capture(program: &Program<'_>) -> Self {
Self {
end: program
.body
.first()
.map_or(program.span.end, |node| node.span().start),
}
}

fn contains(&self, comment: &Comment, source: &str) -> bool {
comment.span.end <= self.end
&& comment
.content_span()
.source_text(source)
.trim_start_matches('*')
.split_whitespace()
.any(|token| {
matches!(
token,
"@jsx" | "@jsxFrag" | "@jsxRuntime" | "@jsxImportSource"
)
})
}

/// Only synthetic import spans may change; authored node spans remain map inputs.
pub(crate) fn reanchor(&self, program: &mut Program<'_>) {
let Some(first) = program
.comments
.iter()
.find(|comment| self.contains(comment, program.source_text))
else {
return;
};
// Anchor zero prints before the hashbang. A comment-content offset cannot
// collide with a real statement, and is safe for a generated import.
if let Some(Statement::ImportDeclaration(import)) = program.body.first_mut()
&& import.span.start == 0
{
import.span = Span::empty(first.content_span().start);
}
let body_anchor = program
.body
.first()
.map_or(program.span.end, |node| node.span().start);
for comment in &mut program.comments {
if self.contains(comment, program.source_text) {
comment.attached_to = program
.directives
.iter()
.find(|directive| comment.span.end <= directive.span.start)
.map_or(body_anchor, |directive| directive.span.start);
comment.position = CommentPosition::Leading;
comment.set_followed_by_newline(true);
// The authored node retains its annotation flag; header comments
// must print normally rather than through Oxc's annotation store.
if comment.is_pure() || comment.is_no_side_effects() {
comment.content = CommentContent::None;
}
}
}
}
}

/// A collected stylesheet has new source; copy its authored instructions into
/// that intermediate source, before Oxc generates both the output and its map.
pub(crate) fn stylesheet_source(source: &str, generated: String, filename: &str) -> String {
if generated.is_empty() || !source.contains("@jsx") {
return generated;
}
let allocator = Allocator::default();
let program = Parser::new(
&allocator,
source,
SourceType::from_path(filename).unwrap_or_default(),
)
.parse()
.program;
let header = HeaderPragmas::capture(&program);
let mut output = String::new();
for comment in &program.comments {
if header.contains(comment, source) {
output.push_str(comment.span.source_text(source));
output.push('\n');
}
}
output.push_str(&generated);
output
}
50 changes: 40 additions & 10 deletions libs/extractor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@ mod gen_class_name;
mod gen_style;
mod import_alias_visit;
mod imported_constants;
mod jsx_import_source;
mod jsx_pragmas;
mod module_loader;
mod mutations;
mod parsing;
#[cfg(test)]
mod pragma_annotation_tests;
#[cfg(test)]
mod pragma_path_tests;
#[cfg(test)]
mod pragma_tests;
mod prop_modify_utils;
mod prop_valid;
#[cfg(test)]
Expand Down Expand Up @@ -374,8 +382,11 @@ fn extract_source(
|| transformed_code.contains(STYLEX_PACKAGE);

if !has_relevant_import {
let pragma_rewritten = alias_edits
.iter()
.any(|(start, end, _)| &namespace_code[*start..*end] == "@emotion/react");
// skip if not using package
if namespace_edits.is_empty() {
if namespace_edits.is_empty() && !pragma_rewritten {
return Ok(ExtractOutput {
styles: FxHashSet::default(),
code: namespace_code.into_owned(),
Expand All @@ -385,12 +396,24 @@ fn extract_source(
});
}
let (source, earlier_edits) = evaluated.unwrap_or((code, &[]));
let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(namespace_edits.as_slice())
.chain(earlier_edits.iter().copied())
.collect();
let edits: Vec<&[import_alias_visit::Edit]> = [
if pragma_rewritten {
alias_edits.as_slice()
} else {
&[]
},
namespace_edits.as_slice(),
]
.into_iter()
.chain(earlier_edits.iter().copied())
.collect();
let mut output = emotion_namespace::Rewritten {
filename,
code: &namespace_code,
code: if pragma_rewritten {
&transformed_code
} else {
&namespace_code
},
source,
edits: &edits,
}
Expand Down Expand Up @@ -426,23 +449,28 @@ fn extract_source(
&referenced,
)?
};
let code = vanilla_extract::collected_styles_to_code_with_keyframes(
let generated = vanilla_extract::collected_styles_to_code_with_keyframes(
&collected,
&option.package,
&keyframes_names,
);
Some(if code.is_empty() {
code
let generated = if generated.is_empty() {
generated
} else {
imports
.kept_imports
.iter()
.map(|specifier| {
format!("import {};\n", vanilla_extract::json_string(specifier))
})
.chain([code])
.chain([generated])
.collect()
})
};
Some(jsx_pragmas::stylesheet_source(
&transformed_code,
generated,
filename,
))
}
// A stylesheet another one imports must give its own values, and an
// import cycle read too early fails as it does in ES modules, so both
Expand Down Expand Up @@ -494,6 +522,7 @@ fn extract_source(
let allocator = Allocator::default();

let mut program = parsing::parse(&allocator, filename, code_to_parse)?;
let header_pragmas = jsx_pragmas::HeaderPragmas::capture(&program);
let inlined = if processed_code.is_none() {
imported_constants::inline_constants(
&oxc_ast::builder::AstBuilder::new(&allocator),
Expand Down Expand Up @@ -598,6 +627,7 @@ fn extract_source(
} else {
CodegenOptions::default()
};
header_pragmas.reanchor(&mut program);
let result = Codegen::new().with_options(codegen_options).build(&program);
// A stylesheet's output is generated, so its map stays on that code
let map = result.map.map(|map| {
Expand Down
Loading
Loading