Skip to content
Open
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
14 changes: 14 additions & 0 deletions .changepacks/changepack_log_emotion_jsx_import_source.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch",
"packages/plugin-utils/package.json": "Patch",
"packages/react/package.json": "Patch",
"packages/vite-plugin/package.json": "Patch",
"packages/next-plugin/package.json": "Patch",
"packages/webpack-plugin/package.json": "Patch",
"packages/rsbuild-plugin/package.json": "Patch",
"packages/bun-plugin/package.json": "Patch"
},
"note": "A project whose tsconfig.json (or jsconfig.json) builds JSX with Emotion (compilerOptions.jsxImportSource of @emotion/react, through extends and project references too) now compiles the css prop on every element of every file, and its .tsx and .jsx files are built with React's JSX runtime through a /** @jsxImportSource react */ pragma, which a file's own JSX pragma overrides, so the bundler no longer imports Emotion's runtime. The plugins read the setting into a new @emotion/react/jsx-runtime alias (importAliases can turn it off with false, and it is left out when @emotion/react is not aliased); @devup-ui/react/compat/emotion-jsx-runtime declares Emotion's JSX runtimes as React's so the project type-checks without Emotion installed. plugin-utils exports readJsxImportSource, and mergeImportAliases takes the JSX import source",
"date": "2026-10-01T00:00:00.000Z"
}
31 changes: 31 additions & 0 deletions libs/extractor/src/css_prop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ use crate::utils::{
/// The package whose alias makes elements take the `css` prop
pub(crate) const EMOTION_REACT: &str = "@emotion/react";

/// Emotion's JSX runtime, whose alias tells that the project builds JSX with
/// it, as a `jsxImportSource` of `@emotion/react` in tsconfig does
pub(crate) const EMOTION_JSX_RUNTIME: &str = "@emotion/react/jsx-runtime";

/// The pragma building a file's JSX with React, as its `css` props compile
pub(crate) const REACT_JSX_PRAGMA: &str = "/** @jsxImportSource react */\n";

/// Whether the project builds JSX with Emotion's runtime, which then builds it
/// with React's as every `css` prop compiles
pub(crate) fn builds_jsx_with_emotion(import_aliases: &HashMap<String, ImportAlias>) -> bool {
import_aliases.contains_key(EMOTION_REACT) && import_aliases.contains_key(EMOTION_JSX_RUNTIME)
}

/// Whether `filename` is written in JSX, which the project's JSX settings build
pub(crate) fn is_jsx_file(filename: &str) -> bool {
filename
.rsplit_once('.')
.is_some_and(|(_, extension)| matches!(extension, "tsx" | "jsx"))
}

/// Which elements of a file take Emotion's `css` prop
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CssProp {
Expand Down Expand Up @@ -610,6 +630,17 @@ mod tests {
);
assert_eq!(react_runtime("@emotion/react"), None);

assert!(is_jsx_file("src/App.tsx"));
assert!(is_jsx_file("App.jsx"));
assert!(!is_jsx_file("App.ts"));
assert!(!is_jsx_file("Makefile"));

let mut aliases =
HashMap::from([(EMOTION_JSX_RUNTIME.to_string(), ImportAlias::NamedToNamed)]);
assert!(!builds_jsx_with_emotion(&aliases));
aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed);
assert!(builds_jsx_with_emotion(&aliases));

let compat = "@devup-ui/react/compat";
assert!(is_jsx_function("react/jsx-runtime", "jsxs", compat));
assert!(is_jsx_function(
Expand Down
75 changes: 65 additions & 10 deletions libs/extractor/src/import_alias_visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

use crate::ImportAlias;
use crate::css_prop::{
CssProp, EMOTION_REACT, is_emotion, is_jsx_function, react_runtime, returned,
CssProp, EMOTION_REACT, builds_jsx_with_emotion, is_emotion, is_jsx_file, is_jsx_function,
react_runtime, returned,
};
use crate::utils::{
get_str_by_property_key, is_vanilla_extract_file, js_number_literal, keeps_bare_number,
Expand Down Expand Up @@ -456,9 +457,11 @@ pub fn transform_import_aliases_with_edits<'a>(
css_prop,
};
let emotion_aliased = import_aliases.contains_key(EMOTION_REACT);
// Quick check: if no aliases match and no element takes a `css` prop,
// return original code
if CssProp::of(import_aliases, code, false) == CssProp::Off
let emotion_jsx = builds_jsx_with_emotion(import_aliases);
// Quick check: if no aliases match, no element takes a `css` prop and the
// project builds no JSX with Emotion, return original code
if !(emotion_jsx && is_jsx_file(filename))
&& CssProp::of(import_aliases, code, false) == CssProp::Off
&& (import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias)))
{
return unchanged(CssProp::Off);
Expand All @@ -475,12 +478,18 @@ 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") {
if emotion_aliased {
transformations.extend(crate::jsx_import_source::emotion_replacements(&program));
}
let mut numbers = LibraryNumbers::default();
let mut uses_emotion = !transformations.is_empty();
let mut uses_emotion = emotion_jsx || !transformations.is_empty();
let compat = format!("{package}/compat");
if emotion_jsx
&& is_jsx_file(filename)
&& let Some(replacement) = crate::jsx_import_source::project_replacement(&program)
{
transformations.push(replacement);
}

for stmt in &program.body {
if let Statement::ImportDeclaration(import_decl) = stmt {
Expand Down Expand Up @@ -510,9 +519,7 @@ pub fn transform_import_aliases_with_edits<'a>(
span.end as usize,
format!("\"{runtime}\""),
));
}

if let Some(alias) = import_aliases.get(source_value)
} else if let Some(alias) = import_aliases.get(source_value)
&& !import_decl.import_kind.is_type()
{
let span = import_decl.span;
Expand Down Expand Up @@ -572,7 +579,8 @@ pub fn transform_import_aliases_with_edits<'a>(
numbers.visit_program(&program);
transformations.append(&mut numbers.replacements);
}
transformations.sort_unstable_by_key(|(start, ..)| *start);
// An insertion comes before a replacement starting where it does
transformations.sort_unstable_by_key(|(start, end, _)| (*start, *end));

// Apply transformations in reverse order to preserve positions
if transformations.is_empty() {
Expand Down Expand Up @@ -1519,4 +1527,51 @@ const x = 1;",
assert_eq!(without_css_prop.css_prop, CssProp::Off);
assert!(matches!(without_css_prop.code, Cow::Borrowed(_)));
}

#[test]
fn test_project_building_jsx_with_emotion() {
let mut aliases = emotion_alias();
aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed);
aliases.insert(
crate::css_prop::EMOTION_JSX_RUNTIME.to_string(),
ImportAlias::NamedToNamed,
);
let outputs: Vec<(String, CssProp, Vec<Edit>)> = [
("test.tsx", "export const a = <Custom css={{ padding: 2 }} />;"),
(
"test.jsx",
"import styled from '@emotion/styled';\nexport const a = <div />;",
),
(
"test.tsx",
"/** @jsxImportSource @emotion/react */\nexport const a = <div />;",
),
("test.tsx", "/** @jsx h */\nexport const a = <div />;"),
("test.ts", "export const a = 1;"),
(
"test.ts",
"import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nexport const a = _jsx(Custom, { css: { top: 1 } });",
),
]
.iter()
.map(|(filename, code)| {
let aliased =
transform_import_aliases_with_edits(code, filename, "@devup-ui/react", &aliases);
(aliased.code.into_owned(), aliased.css_prop, aliased.edits)
})
.collect();
insta::assert_debug_snapshot!(outputs);

let runtime_alone = transform_import_aliases_with_edits(
"export const a = <div />;",
"test.tsx",
"@devup-ui/react",
&HashMap::from([(
crate::css_prop::EMOTION_JSX_RUNTIME.to_string(),
ImportAlias::NamedToNamed,
)]),
);
assert_eq!(runtime_alone.css_prop, CssProp::Off);
assert!(matches!(runtime_alone.code, Cow::Borrowed(_)));
}
}
190 changes: 190 additions & 0 deletions libs/extractor/src/jsx_import_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,202 @@ pub(crate) fn emotion_replacements(program: &Program<'_>) -> Vec<(usize, usize,
replacements
}

/// Redirect project-wide Emotion JSX unless an exact header instruction overrides it.
pub(crate) fn project_replacement(program: &Program<'_>) -> Option<(usize, usize, String)> {
let header_end = program
.body
.first()
.map_or(program.span.end, |body| body.span().start);
for comment in &program.comments {
if comment.span.end > header_end {
continue;
}
let mut tokens = comment
.content_span()
.source_text(program.source_text)
.trim_start_matches('*')
.split_whitespace()
.peekable();
while let Some(token) = tokens.next() {
if matches!(
token,
"@jsx" | "@jsxFrag" | "@jsxRuntime" | "@jsxImportSource"
) && tokens.peek().is_some_and(|value| *value != "*")
{
return None;
}
}
}
let pragma = crate::css_prop::REACT_JSX_PRAGMA;
let (start, text) = match &program.hashbang {
Some(hashbang) => {
let end = hashbang.span.end as usize;
let rest = &program.source_text[end..];
if rest.starts_with("\r\n") {
(end + 2, pragma.to_string())
} else {
match rest.chars().next() {
Some(terminator @ ('\r' | '\n' | '\u{2028}' | '\u{2029}')) => {
(end + terminator.len_utf8(), pragma.to_string())
}
Some(_) | None => (end, format!("\n{pragma}")),
}
}
}
None => (0, pragma.to_string()),
};
Some((start, start, text))
}

#[cfg(test)]
#[path = "jsx_import_source_terminator_tests.rs"]
mod terminator_tests;

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

#[rstest]
#[case::plain("", true)]
#[case::embedded("/* not@jsxImportSource custom */\n", true)]
#[case::joined("/* @jsxImportSourcecustom */\n", true)]
#[case::suffix("/* @jsxFactory h */\n", true)]
#[case::empty("/* @jsxImportSource */\n", true)]
#[case::doc_empty("/** @jsxImportSource\n * documentation */\n", true)]
#[case::post_body("export const anchor=1;\n/* @jsxImportSource custom */\n", true)]
#[case::source("/** @jsxImportSource custom */\n", false)]
#[case::factory("/**@jsx h*/\n", false)]
#[case::fragment("/** @jsxFrag Fragment */\n", false)]
#[case::runtime("/** @jsxRuntime classic */\n", false)]
#[case::directive("'use client';\n/* @jsxImportSource custom */\n", false)]
fn project_jsx_uses_only_exact_header_overrides(#[case] header: &str, #[case] inserted: bool) {
// Given: project-wide Emotion JSX plus a real or lookalike file override.
let source = format!("{header}export const view=<Custom css={{{{padding:2}}}} />;");
let aliases = HashMap::from([
("@emotion/react".into(), ImportAlias::NamedToNamed),
(
"@emotion/react/jsx-runtime".into(),
ImportAlias::NamedToNamed,
),
]);

// When: aliases compile JSX's css prop before the bundler lowers JSX.
let rewritten = transform_import_aliases_with_edits(
&source,
"project.tsx",
"@devup-ui/react",
&aliases,
);

// Then: only an actual header instruction suppresses the React default.
assert_eq!(
rewritten
.code
.starts_with(crate::css_prop::REACT_JSX_PRAGMA),
inserted
);
assert_eq!(rewritten.css_prop, crate::css_prop::CssProp::Everywhere);
assert!(
rewritten.code.contains("padding:\"2px\""),
"{}",
rewritten.code
);
}

#[test]
fn project_jsx_inserts_after_hashbang_and_maps_authored_offsets() -> Result<(), String> {
// Given: an executable JSX source with a directive and project-only Emotion.
let source = "#!/usr/bin/env node\n'use client';\nexport const view=<div />;";
let aliases = HashMap::from([
("@emotion/react".into(), ImportAlias::NamedToNamed),
(
"@emotion/react/jsx-runtime".into(),
ImportAlias::NamedToNamed,
),
]);

// When: the project default is redirected to React.
let rewritten =
transform_import_aliases_with_edits(source, "project.jsx", "@devup-ui/react", &aliases);

// Then: the hashbang stays first and every authored byte keeps its origin.
assert_eq!(
rewritten.code,
"#!/usr/bin/env node\n/** @jsxImportSource react */\n'use client';\nexport const view=<div />;"
);
let authored = source.find("export").ok_or("missing authored export")?;
let emitted = rewritten
.code
.find("export")
.ok_or("missing emitted export")?;
assert_eq!(
crate::import_alias_visit::source_offset(&rewritten.edits, emitted),
authored
);
Ok(())
}

#[test]
fn project_jsx_terminates_a_hashbang_at_end_of_file() {
// Given: a project JSX file containing only an unterminated hashbang line.
let source = "#!/usr/bin/env node";
let aliases = HashMap::from([
("@emotion/react".into(), ImportAlias::NamedToNamed),
(
"@emotion/react/jsx-runtime".into(),
ImportAlias::NamedToNamed,
),
]);

// When: alias preprocessing redirects the project default.
let rewritten =
transform_import_aliases_with_edits(source, "project.jsx", "@devup-ui/react", &aliases);

// Then: the pragma starts on its own line, outside the hashbang.
assert_eq!(
rewritten.code,
"#!/usr/bin/env node\n/** @jsxImportSource react */\n"
);
}

#[rstest]
#[case::plain("")]
#[case::hashbang("#!/usr/bin/env node\n")]
#[case::directive("'use client';\n")]
#[serial_test::serial]
fn project_jsx_preserves_original_parser_locations(#[case] header: &str) -> Result<(), String> {
// Given: project-only JSX with a recoverable invalid attribute.
let source = format!("{header}export const view=<div css={{}} />;");
let option = crate::ExtractOption {
import_aliases: HashMap::from([
("@emotion/react".into(), ImportAlias::NamedToNamed),
(
"@emotion/react/jsx-runtime".into(),
ImportAlias::NamedToNamed,
),
]),
..crate::ExtractOption::default()
};

// When: the public extractor inserts the project pragma before parsing.
let error = crate::extract("project.tsx", &source, option)
.err()
.ok_or("expected strict parsing error")?
.to_string();

// Then: strict parsing rejects it at its authored line, not the inserted line.
let line = header.lines().count() + 1;
assert_eq!(
error,
format!(
"project.tsx:{line}:28: JSX attributes must only be assigned a non-empty 'expression'."
)
);
Ok(())
}

#[rstest]
#[case(
"/** @jsxImportSource @emotion/react */",
Expand Down
Loading
Loading