diff --git a/.changepacks/changepack_log_emotion_jsx_import_source.json b/.changepacks/changepack_log_emotion_jsx_import_source.json new file mode 100644 index 000000000..ceb232995 --- /dev/null +++ b/.changepacks/changepack_log_emotion_jsx_import_source.json @@ -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" +} diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs index d8cead515..757c93bd0 100644 --- a/libs/extractor/src/css_prop.rs +++ b/libs/extractor/src/css_prop.rs @@ -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) -> 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 { @@ -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( diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 12edf0526..b19517e2c 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -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, @@ -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); @@ -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 { @@ -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; @@ -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() { @@ -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)> = [ + ("test.tsx", "export const a = ;"), + ( + "test.jsx", + "import styled from '@emotion/styled';\nexport const a =
;", + ), + ( + "test.tsx", + "/** @jsxImportSource @emotion/react */\nexport const a =
;", + ), + ("test.tsx", "/** @jsx h */\nexport const a =
;"), + ("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 =
;", + "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(_))); + } } diff --git a/libs/extractor/src/jsx_import_source.rs b/libs/extractor/src/jsx_import_source.rs index 6b14f8600..f90c69e27 100644 --- a/libs/extractor/src/jsx_import_source.rs +++ b/libs/extractor/src/jsx_import_source.rs @@ -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=;"); + 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=
;"; + 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=
;" + ); + 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=
;"); + 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 */", diff --git a/libs/extractor/src/jsx_import_source_terminator_tests.rs b/libs/extractor/src/jsx_import_source_terminator_tests.rs new file mode 100644 index 000000000..7b3b61653 --- /dev/null +++ b/libs/extractor/src/jsx_import_source_terminator_tests.rs @@ -0,0 +1,88 @@ +use crate::{ExtractOption, ImportAlias, import_alias_visit::transform_import_aliases_with_edits}; +use rstest::rstest; +use std::collections::HashMap; + +#[rstest] +#[case::cr("\r")] +#[case::crlf("\r\n")] +#[case::lf("\n")] +#[case::line_separator("\u{2028}")] +#[case::paragraph_separator("\u{2029}")] +fn project_jsx_preserves_adjacent_hashbang_terminator_and_offsets( + #[case] terminator: &str, +) -> Result<(), String> { + // Given: a different later LF must not determine the insertion point. + let source = format!("#!/usr/bin/env node{terminator}export const view=
;\n"); + let aliases = HashMap::from([ + ("@emotion/react".into(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".into(), + ImportAlias::NamedToNamed, + ), + ]); + + // When: project-only JSX is redirected before bundler lowering. + let rewritten = + transform_import_aliases_with_edits(&source, "project.jsx", "@devup-ui/react", &aliases); + + // Then: insertion consumes only the adjacent terminator; authored bytes map back exactly. + assert_eq!( + rewritten.code, + format!( + "#!/usr/bin/env node{terminator}/** @jsxImportSource react */\nexport const view=
;\n" + ) + ); + let emitted = rewritten + .code + .find("export") + .ok_or("missing emitted export")?; + let authored = source.find("export").ok_or("missing authored export")?; + for offset in 0.."export const view=
;\n".len() { + assert_eq!( + crate::import_alias_visit::source_offset(&rewritten.edits, emitted + offset), + authored + offset + ); + } + Ok(()) +} + +#[rstest] +#[case::cr("\r")] +#[case::crlf("\r\n")] +#[case::lf("\n")] +#[case::line_separator("\u{2028}")] +#[case::paragraph_separator("\u{2029}")] +#[serial_test::serial] +fn project_jsx_keeps_public_pragma_in_header_after_mixed_terminators( + #[case] terminator: &str, +) -> Result<(), String> { + // Given: importless project JSX with a hashbang and a later LF. + let source = format!("#!/usr/bin/env node{terminator}export const view=
;\n"); + let option = ExtractOption { + import_aliases: HashMap::from([ + ("@emotion/react".into(), ImportAlias::NamedToNamed), + ( + "@emotion/react/jsx-runtime".into(), + ImportAlias::NamedToNamed, + ), + ]), + ..ExtractOption::default() + }; + + // When: the public extractor parses and regenerates the source. + let output = + crate::extract("project.jsx", &source, option).map_err(|error| error.to_string())?; + + // Then: a compiler-visible header instruction precedes the first body statement. + let pragma = output + .code + .find("/** @jsxImportSource react */") + .ok_or("missing project pragma")?; + let body = output + .code + .find("export const view") + .ok_or("missing export")?; + assert!(pragma < body, "{}", output.code); + assert!(output.code.starts_with("#!/usr/bin/env node")); + Ok(()) +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 8f6106253..c23e4649d 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -342,6 +342,8 @@ fn extract_with_source_map( ) -> Result> { if code.contains(&option.package) || code.contains(STYLEX_PACKAGE) + || (css_prop::builds_jsx_with_emotion(&option.import_aliases) + && css_prop::is_jsx_file(filename)) || option .import_aliases .keys() @@ -23520,6 +23522,50 @@ export const App = () =>
;\nexport const App = () => ;", + option.clone(), + ) + .unwrap(); + assert_debug_snapshot!(ToBTreeSet::from(output)); + + reset_class_map(); + reset_file_map(); + let plain = extract( + "test.tsx", + "export const App = () =>
;", + option.clone(), + ) + .unwrap(); + assert_eq!( + plain.code, + "/** @jsxImportSource react */\nexport const App = () =>
;\n" + ); + + reset_class_map(); + reset_file_map(); + let error = extract( + "test.tsx", + "import { css } from '@emotion/react';\nexport const App = () =>
;", + option, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!(error.starts_with("test.tsx:2:36: "), "{error}"); + } + #[test] #[serial] fn test_emotion_css_prop_reports_what_it_cannot_compile() { diff --git a/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__project_building_jsx_with_emotion.snap b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__project_building_jsx_with_emotion.snap new file mode 100644 index 000000000..f6128ba1a --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__import_alias_visit__tests__project_building_jsx_with_emotion.snap @@ -0,0 +1,75 @@ +--- +source: libs/extractor/src/import_alias_visit.rs +expression: outputs +--- +[ + ( + "/** @jsxImportSource react */\nexport const a = ;", + Everywhere, + [ + ( + 0, + 0, + 30, + ), + ( + 41, + 42, + 5, + ), + ], + ), + ( + "/** @jsxImportSource react */\nimport { styled } from '@devup-ui/react';\nexport const a =
;", + Off, + [ + ( + 0, + 0, + 30, + ), + ( + 0, + 37, + 41, + ), + ], + ), + ( + "/** @jsxImportSource react */\nexport const a =
;", + Off, + [ + ( + 21, + 35, + 5, + ), + ], + ), + ( + "/** @jsx h */\nexport const a =
;", + Off, + [], + ), + ( + "export const a = 1;", + Off, + [], + ), + ( + "import { jsx as _jsx } from \"react/jsx-runtime\";\nexport const a = _jsx(Custom, { css: { top: \"1px\" } });", + Everywhere, + [ + ( + 28, + 56, + 19, + ), + ( + 102, + 103, + 5, + ), + ], + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_where_the_project_builds_jsx_with_emotion.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_where_the_project_builds_jsx_with_emotion.snap new file mode 100644 index 000000000..2d0c73a81 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_where_the_project_builds_jsx_with_emotion.snap @@ -0,0 +1,29 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(output)" +--- +ToBTreeSet { + styles: { + Static( + ExtractStaticStyle { + property: "color", + value: "red", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + Static( + ExtractStaticStyle { + property: "padding", + value: "2px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), + }, + code: "/** @jsxImportSource react */\nimport \"@devup-ui/react/devup-ui.css\";\nconst Custom = ({ className }) =>
;\nexport const App = () => ;\n", +} diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index af359f0e4..a742e2452 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -10,8 +10,10 @@ import { createSharedSlotReservation, createThemeInterfaceArgs, type CustomShorthands, + isProjectJsx, loadDevupConfig, mergeImportAliases, + readJsxImportSource, seedFileNumbers, tailwindCssFiles, withTailwindCss, @@ -39,7 +41,7 @@ const devupFile = 'devup.json' const distDir = 'df' const cssDir = resolve(distDir, cssDirName) const singleCss = true -const importAliases = mergeImportAliases() +const importAliases = mergeImportAliases(undefined, readJsxImportSource()) // The packages whose imports the extractor compiles: Devup UI, the packages it // takes the place of, and StyleX const compiledPackages = [ @@ -134,7 +136,10 @@ async function loadSourceFile(filePath: string, bundling: boolean) { : 'js' const contents = await Bun.file(filePath).text() - if (importsCompiledPackage(contents, loader)) { + if ( + isProjectJsx(filePath, importAliases) || + importsCompiledPackage(contents, loader) + ) { const code = codeExtract( filePath, contents, @@ -212,6 +217,7 @@ function DevupUI(options: DevupUIBunPluginOptions = {}) { collectNumberedFiles({ roots: [resolve('src')], needles: compiledPackages, + importAliases, toId: (path) => path, }), ) diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index b088582cd..dd439be50 100644 --- a/packages/next-plugin/src/__tests__/plugin.test.ts +++ b/packages/next-plugin/src/__tests__/plugin.test.ts @@ -89,6 +89,7 @@ let codeExtractSpy: ReturnType let codeExtractWithoutSourceMapSpy: ReturnType let devupUIWebpackPluginSpy: ReturnType let startCoordinatorSpy: ReturnType +let readJsxImportSourceSpy: ReturnType let originalEnv: NodeJS.ProcessEnv let originalEnvValues: NodeJS.ProcessEnv @@ -143,6 +144,10 @@ beforeEach(() => { coordinatorModule, 'startCoordinator', ).mockReturnValue({ close: mock() as () => void }) + readJsxImportSourceSpy = spyOn( + importGraphModule, + 'readJsxImportSource', + ).mockReturnValue(undefined) setWasmForTesting(wasm) setWebpackPluginForTesting(webpackPluginModule) @@ -164,6 +169,7 @@ afterEach(() => { process.env = originalEnv global.fetch = originalFetch process.debugPort = originalDebugPort + readJsxImportSourceSpy.mockRestore() existsSyncSpy.mockRestore() mkdirSyncSpy.mockRestore() readFileSyncSpy.mockRestore() diff --git a/packages/next-plugin/src/plugin.ts b/packages/next-plugin/src/plugin.ts index b466605a4..435c73c2a 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -20,6 +20,7 @@ import { extractedNeedles, loadDevupConfigSync, mergeImportAliases, + readJsxImportSource, seedFileNumbers, type StaticImportGraph, withTailwindCss, @@ -159,7 +160,10 @@ export function DevupUI( atomHoist !== undefined && Number.isFinite(atomHoist) && atomHoist > 0 - const importAliases = mergeImportAliases(userImportAliases) + const importAliases = mergeImportAliases( + userImportAliases, + readJsxImportSource(), + ) const watch = process.env.NODE_ENV === 'development' const sourceMap = watch || config.productionBrowserSourceMaps === true const sheetFile = join(distDir, 'sheet.json') @@ -352,6 +356,7 @@ export function DevupUI( include, cwd, needles: extractedNeedles(libPackage, importAliases), + importAliases, toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), ) diff --git a/packages/plugin-utils/src/__tests__/create-compat-types.test.ts b/packages/plugin-utils/src/__tests__/create-compat-types.test.ts index 0255bc589..e796b6ada 100644 --- a/packages/plugin-utils/src/__tests__/create-compat-types.test.ts +++ b/packages/plugin-utils/src/__tests__/create-compat-types.test.ts @@ -47,6 +47,20 @@ describe('createCompatTypes', () => { ) }) + it("types Emotion's JSX runtime where the project builds JSX with it", () => { + expect( + createCompatTypes(mergeImportAliases(undefined, '@emotion/react')), + ).toBe( + [ + '/// ', + '/// ', + '/// ', + '/// ', + '/// ', + ].join('\n') + '\n', + ) + }) + it('still emits stylex when nothing is aliased', () => { expect(createCompatTypes({})).toBe( '/// \n', diff --git a/packages/plugin-utils/src/__tests__/jsx-import-source.test.ts b/packages/plugin-utils/src/__tests__/jsx-import-source.test.ts new file mode 100644 index 000000000..8b9c9d6d8 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/jsx-import-source.test.ts @@ -0,0 +1,119 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { readJsxImportSource } from '../jsx-import-source' + +describe('readJsxImportSource', () => { + let cwd: string + + function write(path: string, content: string) { + mkdirSync(dirname(join(cwd, path)), { recursive: true }) + writeFileSync(join(cwd, path), content) + } + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'devup-ui-jsx-import-source-')) + }) + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) + }) + + it('reads nothing without a config', () => { + expect(readJsxImportSource(cwd)).toBeUndefined() + }) + + it('reads the default directory when none is given', () => { + expect(readJsxImportSource()).toBe(readJsxImportSource(process.cwd())) + }) + + it('reads tsconfig, with the comments and trailing commas it allows', () => { + write( + 'tsconfig.json', + `{ + // the JSX runtime + "compilerOptions": { + /* Emotion's */ "jsxImportSource": "@emotion/react", + "paths": { "a\\"b": ["./src/*"], }, + }, +}`, + ) + write( + 'jsconfig.json', + '{ "compilerOptions": { "jsxImportSource": "preact" } }', + ) + expect(readJsxImportSource(cwd)).toBe('@emotion/react') + }) + + it('reads jsconfig when there is no tsconfig', () => { + write( + 'jsconfig.json', + '{ "compilerOptions": { "jsxImportSource": "preact" } }', + ) + expect(readJsxImportSource(cwd)).toBe('preact') + }) + + it('follows extends, a later entry overriding an earlier one', () => { + write( + 'tsconfig.json', + '{ "extends": ["./base/first", "@acme/config", "./base/second.json"], "compilerOptions": { "jsxImportSource": 1 } }', + ) + write( + 'base/first.json', + '{ "compilerOptions": { "jsxImportSource": "first" } }', + ) + write('base/second.json', '{ "compilerOptions": {} }') + write( + 'node_modules/@acme/config/tsconfig.json', + '{ "compilerOptions": { "jsxImportSource": "@emotion/react" } }', + ) + expect(readJsxImportSource(cwd)).toBe('@emotion/react') + }) + + it("follows a package's main config", () => { + write('tsconfig.json', '{ "extends": "@acme/main" }') + write( + 'node_modules/@acme/main/package.json', + '{ "name": "@acme/main", "main": "base.json" }', + ) + write( + 'node_modules/@acme/main/base.json', + '{ "compilerOptions": { "jsxImportSource": "@emotion/react" } }', + ) + expect(readJsxImportSource(cwd)).toBe('@emotion/react') + }) + + it('follows project references to a file or a directory', () => { + write( + 'tsconfig.json', + '{ "files": [], "extends": ["@acme/missing", "./missing"], "references": [1, { "path": 2 }, { "path": "./gone" }, { "path": "./tsconfig.node.json" }, { "path": "./app" }] }', + ) + write('tsconfig.node.json', '{ "compilerOptions": {} }') + write( + 'app/tsconfig.json', + '{ "compilerOptions": { "jsxImportSource": "@emotion/react" } }', + ) + expect(readJsxImportSource(cwd)).toBe('@emotion/react') + }) + + it('stops at configs extending each other', () => { + write('tsconfig.json', '{ "extends": "./other.json" }') + write('other.json', '{ "extends": "./tsconfig.json" }') + expect(readJsxImportSource(cwd)).toBeUndefined() + }) + + it('reads nothing from a config it cannot parse', () => { + write('tsconfig.json', '{ "compilerOptions": /* unterminated') + expect(readJsxImportSource(cwd)).toBeUndefined() + write('tsconfig.json', '{ "extends": "./base" } // last line') + write('base', '{ "compilerOptions": { "jsxImportSource": "base" } }') + expect(readJsxImportSource(cwd)).toBe('base') + write('tsconfig.json', '{ "compilerOptions": [1, 2,] }') + expect(readJsxImportSource(cwd)).toBeUndefined() + write('tsconfig.json', '{ "compilerOptions": {} },') + expect(readJsxImportSource(cwd)).toBeUndefined() + }) +}) diff --git a/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts b/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts index 34dca997b..d7d6cf7e2 100644 --- a/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts +++ b/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts @@ -82,6 +82,29 @@ describe('mergeImportAliases', () => { // null = named exports 1:1 mapping expect(result['named-export-lib']).toBeNull() }) + + it("aliases Emotion's JSX runtime where the project builds JSX with it", () => { + expect(mergeImportAliases(undefined, '@emotion/react')).toEqual({ + '@emotion/react': null, + '@emotion/react/jsx-runtime': null, + '@emotion/styled': 'styled', + '@emotion/css': null, + 'styled-components': 'styled', + '@vanilla-extract/css': null, + }) + expect( + mergeImportAliases({ '@emotion/react': false }, '@emotion/react'), + ).not.toHaveProperty('@emotion/react/jsx-runtime') + expect( + mergeImportAliases( + { '@emotion/react/jsx-runtime': false }, + '@emotion/react', + ), + ).not.toHaveProperty('@emotion/react/jsx-runtime') + expect(mergeImportAliases(undefined, 'preact')).not.toHaveProperty( + '@emotion/react/jsx-runtime', + ) + }) }) describe('DEFAULT_IMPORT_ALIASES', () => { diff --git a/packages/plugin-utils/src/__tests__/project-jsx.test.ts b/packages/plugin-utils/src/__tests__/project-jsx.test.ts new file mode 100644 index 000000000..5ce8fe44e --- /dev/null +++ b/packages/plugin-utils/src/__tests__/project-jsx.test.ts @@ -0,0 +1,143 @@ +import { relative } from 'node:path' + +import { expect, it } from 'bun:test' + +import { collectNumberedFiles, extractedNeedles } from '../numbering' +import { collectReservationInputs } from '../reservation-discovery' +import { createSharedSlotReservation } from '../shared-slots' +import type { WasmImportAliases } from '../types' +import { fixture, flags, recorder, STYLE } from './reservation-fixture' + +const jsx = "export const view=" +const enabled = { + '@emotion/react': null, + '@emotion/react/jsx-runtime': null, +} satisfies WasmImportAliases + +for (const [name, importAliases, expected] of [ + ['off', {}, false], + ['react only', { '@emotion/react': null }, false], + ['runtime only', { '@emotion/react/jsx-runtime': null }, false], + ['both keys', enabled, true], +] satisfies readonly (readonly [string, WasmImportAliases, boolean])[]) { + it(`selects project JSX for numbering and reservation when ${name}`, () => { + // Given: package-backed source remains eligible independently of project JSX. + using fs = fixture() + fs.write('src/A.tsx', jsx) + fs.write('src/B.jsx', jsx) + fs.write('src/legacy.ts', STYLE) + fs.write('src/plain.ts', 'export const css=1') + fs.write('src/plain.js', 'export const css=1') + fs.write('src/ignored.test.tsx', jsx) + fs.write('src/__tests__/ignored.test.jsx', jsx) + fs.write('src/node_modules/ignored.jsx', jsx) + const options = { + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases, + needles: extractedNeedles('@devup-ui/react', importAliases), + toId: (path: string) => relative(fs.root, path).replaceAll('\\', '/'), + } + // When + const numbered = collectNumberedFiles({ + ...options, + roots: [fs.root + '/src'], + }) + const reserved = collectReservationInputs(options) + // Then + const files = expected + ? ['src/A.tsx', 'src/B.jsx', 'src/legacy.ts'] + : ['src/legacy.ts'] + expect(numbered).toEqual(files) + expect(reserved.inputs.map(({ filename }) => filename)).toEqual(files) + }) +} + +it('reserves importless entries and children with the same rows under reversed roots', () => { + // Given: neither the entry outside source roots nor its child mentions a package. + using fs = fixture() + fs.write( + 'entry/main.tsx', + "import '../children/view'; export const main=
", + ) + fs.write('children/view.jsx', jsx) + fs.write('src/Z.tsx', jsx) + fs.write('other/A.jsx', jsx) + fs.write('node_modules/@acme/ui/dist/Card.jsx', jsx) + fs.write('node_modules/@acme/ui/node_modules/ignored.jsx', jsx) + fs.write('node_modules/not-included/ignored.jsx', jsx) + const options = { + roots: ['src', 'other'], + entries: [{ request: './entry/main.tsx', context: fs.root }], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: enabled, + include: ['@acme/ui'], + toId: (path: string) => + `raw\\${relative(fs.root, path).replaceAll('/', '\\')}`, + } + const first = recorder() + const second = recorder() + // When: real shared managers discover and deliver the batch to the engine boundary. + createSharedSlotReservation(first.engine).reserve(options, flags) + createSharedSlotReservation(second.engine).reserve( + { ...options, roots: ['other', 'src'] }, + flags, + ) + // Then + expect(first.packets).toEqual(second.packets) + expect(first.packets[0]?.rows.map(([filename]) => filename)).toEqual([ + 'raw\\children\\view.jsx', + 'raw\\entry\\main.tsx', + 'raw\\node_modules\\@acme\\ui\\dist\\Card.jsx', + 'raw\\other\\A.jsx', + 'raw\\src\\Z.tsx', + ]) +}) + +it('numbers project JSX by engine filename without widening physical-source exclusions', () => { + // Given: a virtual engine JSX ID and a physical JSX file compiled under a non-JSX ID. + using fs = fixture() + fs.write('src/generated.ts', 'export const view=1') + fs.write('src/authored.tsx', jsx) + fs.write('src/ignored.spec.ts', jsx) + const options = { + roots: [fs.root + '/src'], + importAliases: enabled, + needles: extractedNeedles('@devup-ui/react', enabled), + toId: (path: string) => + path.endsWith('generated.ts') + ? 'owned\\generated.tsx' + : 'owned/authored.js', + } + // When + const files = collectNumberedFiles(options) + // Then + expect(files).toEqual(['owned\\generated.tsx']) +}) + +it('retains prepared-input ownership and its importless JSX dependencies', () => { + // Given: owned MDX compiled to a JSX engine filename, with a project-only child. + using fs = fixture() + fs.write('src/page.mdx', '# raw is not JavaScript') + fs.write('child/view.jsx', jsx) + const code = "import '../child/view'; export const page=
" + // When + const inventory = collectReservationInputs({ + roots: ['src'], + cwd: fs.root, + package: '@devup-ui/react', + importAliases: enabled, + prepared: [ + { physicalPath: 'src/page.mdx', filename: 'owned/page.tsx', code }, + ], + toId: (path) => relative(fs.root, path).replaceAll('\\', '/'), + }) + // Then + expect(inventory.inputs.map(({ filename }) => filename)).toEqual([ + 'child/view.jsx', + 'owned/page.tsx', + ]) + expect(inventory.inputs.map((input) => input.code)).toEqual([jsx, code]) +}) diff --git a/packages/plugin-utils/src/index.ts b/packages/plugin-utils/src/index.ts index b376c77b8..7c488a90d 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -20,12 +20,14 @@ export { type ResolvedModule, type StaticImportGraph, } from './import-graph' +export { readJsxImportSource } from './jsx-import-source' export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' export { collectNumberedFiles, type CollectNumberedFilesOptions, extractedNeedles, type FileNumbering, + isProjectJsx, seedFileNumbers, } from './numbering' export { diff --git a/packages/plugin-utils/src/jsx-import-source.ts b/packages/plugin-utils/src/jsx-import-source.ts new file mode 100644 index 000000000..0531c083e --- /dev/null +++ b/packages/plugin-utils/src/jsx-import-source.ts @@ -0,0 +1,156 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, isAbsolute, join, resolve } from 'node:path' + +interface TsConfig { + extends?: unknown + compilerOptions?: { jsxImportSource?: unknown } + references?: unknown +} + +/** + * Where the comment starting at `index` of `text` ends, or `index` when none + * starts there + */ +function skipComment(text: string, index: number): number { + if (text.startsWith('//', index)) { + const end = text.indexOf('\n', index) + return end < 0 ? text.length : end + } + if (text.startsWith('/*', index)) { + const end = text.indexOf('*/', index + 2) + return end < 0 ? text.length : end + 2 + } + return index +} + +/** + * The character after `index` of `text` that is neither space nor comment + */ +function nextToken(text: string, index: number): string | undefined { + let at = index + while (at < text.length) { + const skipped = skipComment(text, at) + if (skipped !== at) at = skipped + else if (/\s/.test(text[at])) at++ + else return text[at] + } + return undefined +} + +/** + * JSON with the comments and trailing commas tsconfig allows, as JSON + */ +function stripJsonc(text: string): string { + let json = '' + let index = 0 + while (index < text.length) { + const char = text[index] + const skipped = skipComment(text, index) + if (skipped !== index) { + index = skipped + } else if (char === '"') { + const start = index + index++ + while (index < text.length && text[index] !== '"') { + index += text[index] === '\\' ? 2 : 1 + } + index++ + json += text.slice(start, index) + } else { + const trailing = + char === ',' && /[}\]]/.test(nextToken(text, index + 1) ?? '') + if (!trailing) json += char + index++ + } + } + return json +} + +function readConfig(path: string): TsConfig | undefined { + try { + return JSON.parse(stripJsonc(readFileSync(path, 'utf-8'))) as TsConfig + } catch { + return undefined + } +} + +/** + * The file an `extends` entry of the config at `from` names: a path, with + * `.json` optional, or a package's config + */ +function resolveExtended(entry: string, from: string): string | undefined { + if (entry.startsWith('.') || isAbsolute(entry)) { + const path = resolve(dirname(from), entry) + return [path, `${path}.json`].find((candidate) => existsSync(candidate)) + } + const require = createRequire(from) + for (const candidate of [entry, `${entry}/tsconfig.json`]) { + try { + return require.resolve(candidate) + } catch { + // Not a module path; the package may keep its config elsewhere + } + } + return undefined +} + +/** + * The config a project reference names: a config file, or a directory + * holding `tsconfig.json` + */ +function resolveReference( + reference: unknown, + from: string, +): string | undefined { + const path = + typeof reference === 'object' && + reference !== null && + 'path' in reference && + typeof reference.path === 'string' + ? resolve(dirname(from), reference.path) + : undefined + if (!path || !existsSync(path)) return undefined + return statSync(path).isDirectory() ? join(path, 'tsconfig.json') : path +} + +function jsxImportSourceOf( + path: string, + seen: Set, +): string | undefined { + if (seen.has(path)) return undefined + seen.add(path) + const config = readConfig(path) + const own = config?.compilerOptions?.jsxImportSource + if (typeof own === 'string') return own + const extended = [config?.extends] + .flat() + .filter((entry): entry is string => typeof entry === 'string') + // A later entry of `extends` overrides an earlier one + for (const entry of extended.reverse()) { + const parent = resolveExtended(entry, path) + const source = parent && jsxImportSourceOf(parent, seen) + if (source) return source + } + const references = Array.isArray(config?.references) ? config.references : [] + for (const reference of references) { + const referenced = resolveReference(reference, path) + const source = referenced && jsxImportSourceOf(referenced, seen) + if (source) return source + } + return undefined +} + +/** + * The module the project builds JSX with, as its `tsconfig.json` (or, without + * one, `jsconfig.json`) in `cwd` sets `compilerOptions.jsxImportSource`, + * following `extends` and project references + */ +export function readJsxImportSource( + cwd: string = process.cwd(), +): string | undefined { + const config = ['tsconfig.json', 'jsconfig.json'] + .map((name) => join(cwd, name)) + .find((path) => existsSync(path)) + return config && jsxImportSourceOf(config, new Set()) +} diff --git a/packages/plugin-utils/src/numbering.ts b/packages/plugin-utils/src/numbering.ts index f65d89ec9..b209165c6 100644 --- a/packages/plugin-utils/src/numbering.ts +++ b/packages/plugin-utils/src/numbering.ts @@ -14,6 +14,8 @@ export interface CollectNumberedFilesOptions { * build never extracts do not take numbers (and lengthen class prefixes) */ needles?: string[] + /** Alias keys enabling importless project JSX, matched against the engine ID. */ + readonly importAliases?: object /** The name the plugin extracts a file under, given its absolute path */ toId?: (path: string) => string } @@ -24,6 +26,15 @@ function usesAny(file: string, needles: string[]): boolean { return needles.some((needle) => text.includes(needle)) } +/** Match the extractor's project-wide Emotion JSX policy, including null alias values. */ +export function isProjectJsx(filename: string, importAliases: object): boolean { + return ( + (filename.endsWith('.tsx') || filename.endsWith('.jsx')) && + Object.hasOwn(importAliases, '@emotion/react') && + Object.hasOwn(importAliases, '@emotion/react/jsx-runtime') + ) +} + /** Where an included package lives, found the way the bundler finds it. */ export function findPackageDir(cwd: string, name: string): string | undefined { let dir = resolve(cwd) @@ -48,6 +59,7 @@ export function collectNumberedFiles({ include = [], cwd = process.cwd(), needles, + importAliases = {}, toId = (path) => path.replaceAll('\\', '/'), }: CollectNumberedFilesOptions): string[] { const directories = [ @@ -56,15 +68,19 @@ export function collectNumberedFiles({ .map((name) => findPackageDir(cwd, name)) .filter((dir): dir is string => dir !== undefined), ] - return [ - ...new Set( - directories.flatMap((dir) => - listSourceFiles(dir) - .filter((file) => !needles || usesAny(file, needles)) - .map(toId), - ), - ), - ].sort() + const files = new Set() + for (const directory of directories) { + for (const file of listSourceFiles(directory)) { + const filename = toId(file) + if ( + !needles || + isProjectJsx(filename, importAliases) || + usesAny(file, needles) + ) + files.add(filename) + } + } + return [...files].sort() } export interface FileNumbering { diff --git a/packages/plugin-utils/src/reservation-discovery.ts b/packages/plugin-utils/src/reservation-discovery.ts index 1140d1de4..a45205aab 100644 --- a/packages/plugin-utils/src/reservation-discovery.ts +++ b/packages/plugin-utils/src/reservation-discovery.ts @@ -7,7 +7,7 @@ import { isSourceFile, listSourceFiles, } from './import-graph' -import { extractedNeedles, findPackageDir } from './numbering' +import { extractedNeedles, findPackageDir, isProjectJsx } from './numbering' import { isReservationReadFailure, reservationSource, @@ -142,13 +142,18 @@ export function collectReservationInputs( sources.get(key) ?? reservationSource(() => readFileSync(physicalPath, 'utf-8')) if (code === undefined) continue + const filename = overlay?.filename ?? toId(physicalPath) sources.set(key, code) records.set(key, { physicalPath, - filename: overlay?.filename ?? toId(physicalPath), + filename, code, }) - if (needles.some((needle) => code.includes(needle))) seeds.add(key) + if ( + isProjectJsx(filename, options.importAliases) || + needles.some((needle) => code.includes(needle)) + ) + seeds.add(key) const targets = new Set() const resolutions = new Map() imports.set(key, resolutions) diff --git a/packages/plugin-utils/src/types.ts b/packages/plugin-utils/src/types.ts index 4bcc526d2..2a441a433 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -130,10 +130,18 @@ export const DEFAULT_IMPORT_ALIASES: ImportAliases = { */ export type WasmImportAliases = Record +/** Emotion's JSX runtime, aliased when the project builds JSX with it */ +const EMOTION_JSX_RUNTIME = '@emotion/react/jsx-runtime' + /** * Merge user import aliases with defaults and convert to WASM format * + * A project building JSX with Emotion (`jsxImportSource` of `@emotion/react`) + * also aliases `@emotion/react/jsx-runtime`: its `css` props then compile in + * every file, and its JSX is built with React's runtime. + * * @param userAliases - User-provided aliases (optional) + * @param jsxImportSource - The module the project builds JSX with (optional) * @returns WASM-compatible import aliases * * @example @@ -144,8 +152,16 @@ export type WasmImportAliases = Record */ export function mergeImportAliases( userAliases?: ImportAliases, + jsxImportSource?: string, ): WasmImportAliases { - const merged = { ...DEFAULT_IMPORT_ALIASES, ...userAliases } + const merged: ImportAliases = { ...DEFAULT_IMPORT_ALIASES, ...userAliases } + if ( + jsxImportSource === '@emotion/react' && + merged['@emotion/react'] !== false && + !(EMOTION_JSX_RUNTIME in merged) + ) { + merged[EMOTION_JSX_RUNTIME] = true + } return Object.fromEntries( Object.entries(merged) .filter((entry): entry is [string, string | true] => entry[1] !== false) @@ -159,6 +175,7 @@ export function mergeImportAliases( */ const COMPAT_TYPE_ENTRIES: Record = { '@emotion/react': 'emotion', + [EMOTION_JSX_RUNTIME]: 'emotion-jsx-runtime', '@emotion/styled': 'emotion', '@emotion/css': 'emotion', '@vanilla-extract/css': 'vanilla-extract', diff --git a/packages/react/package.json b/packages/react/package.json index fa0d51662..8c0499322 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -52,6 +52,9 @@ "./compat/css-prop": { "types": "./dist/compat/css-prop.d.ts" }, + "./compat/emotion-jsx-runtime": { + "types": "./dist/compat/emotion-jsx-runtime.d.ts" + }, "./compat/stylex": { "types": "./dist/compat/stylex.d.ts" }, diff --git a/packages/react/src/compat/emotion-jsx-runtime.d.ts b/packages/react/src/compat/emotion-jsx-runtime.d.ts new file mode 100644 index 000000000..a06ee7c34 --- /dev/null +++ b/packages/react/src/compat/emotion-jsx-runtime.d.ts @@ -0,0 +1,7 @@ +declare module '@emotion/react/jsx-runtime' { + export * from 'react/jsx-runtime' +} + +declare module '@emotion/react/jsx-dev-runtime' { + export * from 'react/jsx-dev-runtime' +} diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index ca57a479d..8bf710f2e 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -18,6 +18,7 @@ import { loadDevupConfig, mergeImportAliases, planAtomHoist, + readJsxImportSource, tailwindCssFiles, withTailwindCss, } from '@devup-ui/plugin-utils' @@ -141,7 +142,10 @@ export const DevupUI = ({ importAliases: userImportAliases, }: Partial = {}): RsbuildPlugin => { registerShorthands(shorthands ?? {}) - const importAliases = mergeImportAliases(userImportAliases) + const importAliases = mergeImportAliases( + userImportAliases, + readJsxImportSource(), + ) const stateWriter = createStateWriter((path, content, encoding) => encoding ? writeFile(path, content, encoding) : writeFile(path, content), ) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 51baddff9..ec5fc3081 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -494,6 +494,13 @@ describe('devupUIVitePlugin', () => { roots: [resolve('/p', 'src'), resolve('/p', 'app')], include: ['@acme/ui'], cwd: '/p', + importAliases: { + '@emotion/react': null, + '@emotion/styled': 'styled', + '@emotion/css': null, + 'styled-components': 'styled', + '@vanilla-extract/css': null, + }, needles: expect.arrayContaining([ '@devup-ui/react', '@stylexjs/stylex', @@ -1527,7 +1534,9 @@ describe('devupUIVitePlugin', () => { it('should print error when watch change error', async () => { writeFileSpy.mockResolvedValueOnce(undefined) getThemeInterfaceSpy.mockReturnValue('interface code') - existsSyncSpy.mockReturnValueOnce(true).mockReturnValueOnce(false) + existsSyncSpy.mockImplementation( + (path: fs.PathLike) => path === 'devup.json', + ) mkdirSpy.mockImplementation(() => { throw new Error('error') }) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index e56939930..77528c7d4 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -16,6 +16,7 @@ import { type ImportAliases, loadDevupConfig, mergeImportAliases, + readJsxImportSource, seedFileNumbers, tailwindCssFiles, withTailwindCss, @@ -316,7 +317,10 @@ export function DevupUI({ registerShorthands(shorthands ?? {}) setDebug(debug) setPrefix(prefix ?? null) - const importAliases = mergeImportAliases(userImportAliases) + const importAliases = mergeImportAliases( + userImportAliases, + readJsxImportSource(), + ) const cssMap = new Map() let resolvedConfig: ResolvedConfig | undefined // Set by the client `generateBundle`, run by the late hook of the sibling @@ -388,6 +392,7 @@ export function DevupUI({ include, cwd: projectRoot, needles: extractedNeedles(libPackage, importAliases), + importAliases, }), ) } catch { diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 175620b0f..2961faed6 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -20,6 +20,7 @@ import { loadDevupConfigSync, mergeImportAliases, planAtomHoist, + readJsxImportSource, seedFileNumbers, tailwindCssFiles, type WasmImportAliases, @@ -106,7 +107,10 @@ export class DevupUIWebpackPlugin { importAliases: userImportAliases, }: Partial = {}) { registerShorthands(shorthands ?? {}) - this.importAliases = mergeImportAliases(userImportAliases) + this.importAliases = mergeImportAliases( + userImportAliases, + readJsxImportSource(), + ) this.options = { package: libPackage, @@ -347,6 +351,7 @@ export class DevupUIWebpackPlugin { include: this.options.include, cwd, needles: extractedNeedles(this.options.package, this.importAliases), + importAliases: this.importAliases, toId: (path) => relative(cwd, path).replaceAll('\\', '/'), }), )