From 0faa84ee73c4d9b72cf25f61369dc6e049e22284 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 03:41:12 +0900 Subject: [PATCH] fix(plugin-utils): compile the css prop where tsconfig builds JSX with Emotion Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...ngepack_log_emotion_jsx_import_source.json | 14 ++ libs/extractor/src/css_prop.rs | 31 ++++ libs/extractor/src/import_alias_visit.rs | 77 +++++++-- libs/extractor/src/lib.rs | 44 +++++ ...ts__project_building_jsx_with_emotion.snap | 75 +++++++++ ...e_the_project_builds_jsx_with_emotion.snap | 29 ++++ packages/bun-plugin/src/plugin.ts | 3 +- .../next-plugin/src/__tests__/plugin.test.ts | 6 + packages/next-plugin/src/plugin.ts | 6 +- .../src/__tests__/create-compat-types.test.ts | 14 ++ .../src/__tests__/jsx-import-source.test.ts | 119 +++++++++++++ .../__tests__/merge-import-aliases.test.ts | 22 +++ packages/plugin-utils/src/index.ts | 1 + .../plugin-utils/src/jsx-import-source.ts | 156 ++++++++++++++++++ packages/plugin-utils/src/types.ts | 19 ++- packages/react/package.json | 3 + .../react/src/compat/emotion-jsx-runtime.d.ts | 7 + packages/rsbuild-plugin/src/plugin.ts | 6 +- packages/vite-plugin/src/plugin.ts | 6 +- packages/webpack-plugin/src/plugin.ts | 6 +- 20 files changed, 628 insertions(+), 16 deletions(-) create mode 100644 .changepacks/changepack_log_emotion_jsx_import_source.json create mode 100644 libs/extractor/src/snapshots/extractor__import_alias_visit__tests__project_building_jsx_with_emotion.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_where_the_project_builds_jsx_with_emotion.snap create mode 100644 packages/plugin-utils/src/__tests__/jsx-import-source.test.ts create mode 100644 packages/plugin-utils/src/jsx-import-source.ts create mode 100644 packages/react/src/compat/emotion-jsx-runtime.d.ts 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 0ea799d6d..408fdfeba 100644 --- a/libs/extractor/src/css_prop.rs +++ b/libs/extractor/src/css_prop.rs @@ -23,6 +23,26 @@ use crate::utils::{binding_root, get_string_by_literal_expression, unwrap_syntax /// 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 { @@ -605,6 +625,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 666fe6b15..f5cf40351 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, emotion_pragma, is_emotion, is_jsx_function, react_runtime, returned, + CssProp, EMOTION_REACT, REACT_JSX_PRAGMA, builds_jsx_with_emotion, emotion_pragma, 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, @@ -349,9 +350,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); @@ -369,14 +372,17 @@ pub fn transform_import_aliases_with_edits<'a>( // Collect import transformations let mut transformations: Vec<(usize, usize, String)> = Vec::new(); let mut numbers = LibraryNumbers::default(); - let mut uses_emotion = false; + let mut uses_emotion = emotion_jsx; + let mut jsx_pragma = false; let compat = format!("{package}/compat"); // A pragma building JSX with Emotion builds it with React once the `css` // props are compiled for comment in &program.comments { let span = comment.content_span(); - if let Some(at) = emotion_pragma(&code[span.start as usize..span.end as usize]) { + let text = &code[span.start as usize..span.end as usize]; + jsx_pragma |= text.contains("@jsx"); + if let Some(at) = emotion_pragma(text) { uses_emotion = true; if emotion_aliased { let start = span.start as usize + at; @@ -384,6 +390,11 @@ pub fn transform_import_aliases_with_edits<'a>( } } } + // So does a project building every file's JSX with Emotion, through a + // pragma, which a file's own one overrides + if emotion_jsx && is_jsx_file(filename) && !jsx_pragma { + transformations.push((0, 0, REACT_JSX_PRAGMA.to_string())); + } for stmt in &program.body { if let Statement::ImportDeclaration(import_decl) = stmt { @@ -413,9 +424,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) { let span = import_decl.span; let new_import = generate_transformed_import(import_decl, alias, package, redirect_every_name); @@ -470,7 +479,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() { @@ -1212,4 +1222,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/lib.rs b/libs/extractor/src/lib.rs index d101e4afa..9415e1c89 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -21376,6 +21376,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..32e6388c3 --- /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: "import \"@devup-ui/react/devup-ui.css\";\n/** @jsxImportSource react */\nconst Custom = ({ className }) =>
;\nexport const App = () => ;\n", +} diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index 52591c9ee..81edd30ae 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -9,6 +9,7 @@ import { type CustomShorthands, loadDevupConfig, mergeImportAliases, + readJsxImportSource, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -29,7 +30,7 @@ const devupFile = 'devup.json' const distDir = 'df' const cssDir = resolve(distDir, cssDirName) const singleCss = true -const importAliases = mergeImportAliases() +const importAliases = mergeImportAliases(undefined, readJsxImportSource()) export interface DevupUIBunPluginOptions { shorthands?: CustomShorthands diff --git a/packages/next-plugin/src/__tests__/plugin.test.ts b/packages/next-plugin/src/__tests__/plugin.test.ts index 8bc599b7b..0f4ddce2d 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 originalFetch: typeof global.fetch @@ -142,6 +143,10 @@ beforeEach(() => { coordinatorModule, 'startCoordinator', ).mockReturnValue({ close: mock() as () => void }) + readJsxImportSourceSpy = spyOn( + importGraphModule, + 'readJsxImportSource', + ).mockReturnValue(undefined) setWasmForTesting(wasm) setWebpackPluginForTesting(webpackPluginModule) @@ -158,6 +163,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 7905cc7d0..e69c4d21f 100644 --- a/packages/next-plugin/src/plugin.ts +++ b/packages/next-plugin/src/plugin.ts @@ -20,6 +20,7 @@ import { loadDevupConfigSync, mergeImportAliases, planAtomHoist, + readJsxImportSource, type StaticImportGraph, } from '@devup-ui/plugin-utils' import { type NextConfig } from 'next' @@ -144,7 +145,10 @@ export function DevupUI( importAliases: userImportAliases, } = options - 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') 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 dd3fff83d..eb9c9f019 100644 --- a/packages/plugin-utils/src/__tests__/create-compat-types.test.ts +++ b/packages/plugin-utils/src/__tests__/create-compat-types.test.ts @@ -45,6 +45,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 286292711..ea4cec89c 100644 --- a/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts +++ b/packages/plugin-utils/src/__tests__/merge-import-aliases.test.ts @@ -79,6 +79,28 @@ 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', + '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/index.ts b/packages/plugin-utils/src/index.ts index b66b07abb..bdca8063f 100644 --- a/packages/plugin-utils/src/index.ts +++ b/packages/plugin-utils/src/index.ts @@ -16,6 +16,7 @@ export { type ResolvedModule, type StaticImportGraph, } from './import-graph' +export { readJsxImportSource } from './jsx-import-source' export { deepMerge, loadDevupConfig, loadDevupConfigSync } from './load-config' export { createNodeModulesExcludeRegex, 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/types.ts b/packages/plugin-utils/src/types.ts index 1a1365c2d..5b2ed69e1 100644 --- a/packages/plugin-utils/src/types.ts +++ b/packages/plugin-utils/src/types.ts @@ -117,10 +117,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 @@ -131,8 +139,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) @@ -146,6 +162,7 @@ export function mergeImportAliases( */ const COMPAT_TYPE_ENTRIES: Record = { '@emotion/react': 'emotion', + [EMOTION_JSX_RUNTIME]: 'emotion-jsx-runtime', '@emotion/styled': 'emotion', '@vanilla-extract/css': 'vanilla-extract', 'styled-components': 'styled-components', diff --git a/packages/react/package.json b/packages/react/package.json index 4c6a47414..a1f587b92 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 990a4cd2a..1a796c057 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -15,6 +15,7 @@ import { loadDevupConfig, mergeImportAliases, planAtomHoist, + readJsxImportSource, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -118,7 +119,10 @@ export const DevupUI = ({ importAliases: userImportAliases, }: Partial = {}): RsbuildPlugin => { registerShorthands(shorthands ?? {}) - const importAliases = mergeImportAliases(userImportAliases) + const importAliases = mergeImportAliases( + userImportAliases, + readJsxImportSource(), + ) return { name: 'devup-ui-rsbuild-plugin', diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 9d0b77613..2ea19c45e 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -16,6 +16,7 @@ import { loadDevupConfig, mergeImportAliases, planAtomHoist, + readJsxImportSource, } from '@devup-ui/plugin-utils' import { codeExtract, @@ -274,7 +275,10 @@ export function DevupUI({ if (prefix) { setPrefix(prefix) } - const importAliases = mergeImportAliases(userImportAliases) + const importAliases = mergeImportAliases( + userImportAliases, + readJsxImportSource(), + ) const cssMap = new Map() let serverBundleToForward: Record | undefined let isServe = false diff --git a/packages/webpack-plugin/src/plugin.ts b/packages/webpack-plugin/src/plugin.ts index 54d0713a9..7b0cb1f45 100644 --- a/packages/webpack-plugin/src/plugin.ts +++ b/packages/webpack-plugin/src/plugin.ts @@ -16,6 +16,7 @@ import { loadDevupConfigSync, mergeImportAliases, planAtomHoist, + readJsxImportSource, type WasmImportAliases, } from '@devup-ui/plugin-utils' import { @@ -95,7 +96,10 @@ export class DevupUIWebpackPlugin { importAliases: userImportAliases, }: Partial = {}) { registerShorthands(shorthands ?? {}) - this.importAliases = mergeImportAliases(userImportAliases) + this.importAliases = mergeImportAliases( + userImportAliases, + readJsxImportSource(), + ) this.options = { package: libPackage,