From 0faa84ee73c4d9b72cf25f61369dc6e049e22284 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 03:41:12 +0900 Subject: [PATCH 1/5] 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, From 1a6a31eef64af031609420227f6db06dc5101c53 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 05:25:30 +0900 Subject: [PATCH 2/5] fix(extractor): compile Emotion's ClassNames at build time Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_emotion_class_names.json | 8 + libs/extractor/src/css_prop.rs | 173 +++++++- libs/extractor/src/import_alias_visit.rs | 42 +- libs/extractor/src/imported_constants.rs | 48 ++- libs/extractor/src/lib.rs | 71 ++++ libs/extractor/src/mutations.rs | 20 +- ...ts__emotion_class_names_in_every_form.snap | 13 + ..._names_reports_what_it_cannot_compile.snap | 32 ++ ...n_global_component_and_import_surface.snap | 2 +- libs/extractor/src/utils.rs | 10 + libs/extractor/src/visit.rs | 386 +++++++++++++++++- .../react/src/compat/__tests__/index.test.ts | 9 + packages/react/src/compat/emotion.d.ts | 2 + packages/react/src/compat/index.ts | 29 ++ 14 files changed, 811 insertions(+), 34 deletions(-) create mode 100644 .changepacks/changepack_log_emotion_class_names.json create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_class_names_in_every_form.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap diff --git a/.changepacks/changepack_log_emotion_class_names.json b/.changepacks/changepack_log_emotion_class_names.json new file mode 100644 index 000000000..9317e2ecf --- /dev/null +++ b/.changepacks/changepack_log_emotion_class_names.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Emotion's now compiles at build time with no runtime: the element becomes what its child function ({ css, cx, theme }) => ... renders, each css and cx call becomes the classes it composes (later styles replace earlier ones; css reads strings as CSS text and its tagged templates split around mixins; cx keeps unknown classes, reads { name: condition } objects as conditional classes and composes nested css and cx calls), and theme.a.b in their values becomes var(--a-b). Numbers in css rules are px lengths, as Emotion reads them, and top-level constants are inlined. New build errors, each at file:line:col: a without exactly one child function taking { css, cx, theme } that gives what it renders at once (attributes, other children, rest or default parameters, async or generator functions, several statements); reading the theme other than as theme.a.b in a css value; using css or cx other than by calling them; a cx object with a spread, getter or method; parts css and cx cannot compose (runtime calls, spreads, a condition choosing between styles and classes). @devup-ui/react/compat exports ClassNames with the ClassNamesContent and ClassNamesArg types, and the @emotion/react declarations include them", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs index 408fdfeba..96cb8c59e 100644 --- a/libs/extractor/src/css_prop.rs +++ b/libs/extractor/src/css_prop.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; use oxc_ast::ast::{ Argument, ArrowFunctionBody, BinaryOperator, BindingPattern, CallExpression, Expression, - FormalParameters, FunctionBody, ImportDeclarationSpecifier, JSXAttributeItem, JSXElementName, - ObjectPropertyKind, Program, Statement, Str, TemplateElement, TemplateElementValue, - TemplateLiteral, + FormalParameters, FunctionBody, ImportDeclarationSpecifier, JSXAttributeItem, JSXChild, + JSXElement, JSXElementName, ObjectPropertyKind, Program, Statement, Str, TemplateElement, + TemplateElementValue, TemplateLiteral, }; use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{VisitMut, walk_mut}; @@ -104,6 +104,8 @@ pub(crate) struct CssTakers<'s> { pub css_prop: CssProp, /// Local names of the functions building elements from a type and props jsx: FxHashSet<&'s str>, + /// Local names of Emotion's `ClassNames` + class_names: FxHashSet<&'s str>, } impl<'s> CssTakers<'s> { @@ -111,18 +113,43 @@ impl<'s> CssTakers<'s> { /// `compat` is the entry absorbing Emotion's own `jsx` pub(crate) fn new(program: &Program<'s>, css_prop: CssProp, compat: &str) -> Self { let mut jsx = FxHashSet::default(); + let mut class_names = FxHashSet::default(); for statement in &program.body { if let Statement::ImportDeclaration(import) = statement { + let source = import.source.value.as_str(); for specifier in import.specifiers.iter().flatten() { - if let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier - && is_jsx_function(&import.source.value, &specifier.imported.name(), compat) - { + let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier else { + continue; + }; + let imported = specifier.imported.name(); + if is_jsx_function(source, &imported, compat) { jsx.insert(specifier.local.name.as_str()); + } else if imported == "ClassNames" + && (source == compat || source == EMOTION_REACT) + { + class_names.insert(specifier.local.name.as_str()); } } } } - Self { css_prop, jsx } + Self { + css_prop, + jsx, + class_names, + } + } + + /// The names a `` child function `element` gives takes `css` + /// and `cx` by; empty for any other element + pub(crate) fn class_names_calls(&self, element: &JSXElement<'_>) -> Vec { + match &element.opening_element.name { + JSXElementName::IdentifierReference(name) + if self.class_names.contains(name.name.as_str()) => + { + class_names_calls(element) + } + _ => Vec::new(), + } } /// Whether `attribute` of the element `name` is a `css` prop it takes; @@ -288,6 +315,138 @@ pub(crate) fn returned<'b, 'a>(body: &'b FunctionBody<'a>) -> Option<&'b Express } } +fn returned_mut<'b, 'a>(body: &'b mut FunctionBody<'a>) -> Option<&'b mut Expression<'a>> { + match body.statements.as_mut_slice() { + [Statement::ReturnStatement(statement)] => statement.argument.as_mut(), + _ => None, + } +} + +/// The parameters of `function` and what it gives at once, when it is a +/// plain function giving one value +pub(crate) fn render_function<'b, 'a>( + function: &'b mut Expression<'a>, +) -> Option<(&'b FormalParameters<'a>, &'b mut Expression<'a>)> { + match function { + Expression::ArrowFunctionExpression(arrow) if !arrow.r#async => { + let arrow = &mut **arrow; + let body = match &mut arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned_mut(body)?, + body => body.as_expression_mut()?, + }; + Some((&arrow.params, body)) + } + Expression::FunctionExpression(function) if !function.r#async && !function.generator => { + let function = &mut **function; + Some(( + &function.params, + returned_mut(function.body.as_deref_mut()?)?, + )) + } + _ => None, + } +} + +/// The names Emotion's `` child function takes `css`, `cx` and +/// the theme by +#[derive(Default, Clone, Copy)] +pub(crate) struct ClassNamesParams<'a> { + pub css: Option<&'a str>, + pub cx: Option<&'a str>, + pub theme: Option<&'a str>, +} + +/// What `params` take of `{ css, cx, theme }`; `None` when they take it in a +/// way the build cannot follow +pub(crate) fn class_names_params<'a>( + params: &FormalParameters<'a>, +) -> Option> { + let mut names = ClassNamesParams::default(); + if params.rest.is_some() { + return None; + } + match params.items.as_slice() { + [] => {} + [param] if param.initializer.is_none() => { + let BindingPattern::ObjectPattern(object) = ¶m.pattern else { + return None; + }; + if object.rest.is_some() { + return None; + } + for property in &object.properties { + let (BindingPattern::BindingIdentifier(local), Some(key), false) = ( + &property.value, + property.key.static_name(), + property.computed, + ) else { + return None; + }; + let slot = match key.as_ref() { + "css" => &mut names.css, + "cx" => &mut names.cx, + "theme" => &mut names.theme, + _ => return None, + }; + *slot = Some(local.name.as_str()); + } + } + _ => return None, + } + Some(names) +} + +/// What the child function of the `` element `element` takes +pub(crate) fn class_names_child<'a>(element: &JSXElement<'a>) -> Option> { + let mut children = element + .children + .iter() + .filter(|child| !matches!(child, JSXChild::Text(text) if text.value.trim().is_empty())); + let (Some(JSXChild::ExpressionContainer(container)), None) = (children.next(), children.next()) + else { + return None; + }; + let params = match container.expression.as_expression()? { + Expression::ArrowFunctionExpression(arrow) => &arrow.params, + Expression::FunctionExpression(function) => &function.params, + _ => return None, + }; + class_names_params(params) +} + +/// The names the child function of the `` element `element` +/// takes `css` and `cx` by, which read styles +pub(crate) fn class_names_calls(element: &JSXElement<'_>) -> Vec { + class_names_child(element) + .map(|names| { + [names.css, names.cx] + .into_iter() + .flatten() + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +/// Write the reads of the theme bound to `theme` in `expression`, a CSS value +/// when `value`, as the CSS variables the `ThemeProvider` sets; `Err` holds +/// the first read the build cannot write so +pub(crate) fn read_theme<'a>( + ast: &AstBuilder<'a>, + expression: &mut Expression<'a>, + theme: &'a str, + value: bool, +) -> Result<(), Expression<'a>> { + let roots = [(theme, None)]; + let mut reads = ThemeReads { + ast, + roots: &roots, + unread: None, + }; + reads.visit(expression, value); + reads.unread.map_or(Ok(()), Err) +} + /// The names a function of the theme reads it by, each with the key of the /// theme it holds when destructured; `None` for parameters the build cannot /// follow diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index f5cf40351..db6e569c8 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -9,8 +9,8 @@ use crate::ImportAlias; use crate::css_prop::{ - CssProp, EMOTION_REACT, REACT_JSX_PRAGMA, builds_jsx_with_emotion, emotion_pragma, is_emotion, - is_jsx_file, is_jsx_function, react_runtime, returned, + CssProp, EMOTION_REACT, REACT_JSX_PRAGMA, builds_jsx_with_emotion, class_names_child, + 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, @@ -18,12 +18,12 @@ use crate::utils::{ use oxc_allocator::Allocator; use oxc_ast::ast::{ Argument, ArrowFunctionBody, CallExpression, Expression, ImportDeclarationSpecifier, - JSXAttributeItem, JSXAttributeValue, JSXElementName, JSXOpeningElement, LogicalOperator, - ModuleExportName, ObjectPropertyKind, Statement, + JSXAttributeItem, JSXAttributeValue, JSXElement, JSXElementName, JSXOpeningElement, + LogicalOperator, ModuleExportName, ObjectPropertyKind, Statement, }; use oxc_ast_visit::{ Visit, - walk::{walk_call_expression, walk_jsx_opening_element}, + walk::{walk_call_expression, walk_jsx_element, walk_jsx_opening_element}, }; use oxc_parser::Parser; use oxc_span::{GetSpan, SourceType}; @@ -54,6 +54,9 @@ struct LibraryNumbers<'n> { devup: Vec<&'n str>, /// Local names of the functions building elements from a type and props jsx: Vec<&'n str>, + /// Local names of Emotion's `ClassNames`, whose child function takes a + /// `css` taking rules + class_names: Vec<&'n str>, replacements: Vec<(usize, usize, String)>, } @@ -196,7 +199,25 @@ impl LibraryNumbers<'_> { } } -impl<'a> Visit<'a> for LibraryNumbers<'_> { +impl<'a> Visit<'a> for LibraryNumbers<'a> { + fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { + let css = match &element.opening_element.name { + JSXElementName::IdentifierReference(name) + if self.class_names.contains(&name.name.as_str()) => + { + class_names_child(element).and_then(|names| names.css) + } + _ => None, + }; + if let Some(css) = css { + self.calls.push((css, RulesAt::EveryArgument)); + } + walk_jsx_element(self, element); + if css.is_some() { + self.calls.pop(); + } + } + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { let rules_at = match &call.callee { Expression::Identifier(callee) => self @@ -290,6 +311,7 @@ fn devup_equivalent(source: &str, imported: &str) -> Option (_, "styled") => Some(DevupTarget::Main("styled")), // The `css` props it builds compile away, leaving React's own element ("@emotion/react", "jsx" | "createElement") => Some(DevupTarget::Compat("jsx")), + ("@emotion/react", "ClassNames") => Some(DevupTarget::Compat("ClassNames")), (_, "createGlobalStyle") => Some(DevupTarget::Compat("createGlobalStyle")), (_, "Global") => Some(DevupTarget::Compat("Global")), (_, "ThemeProvider") => Some(DevupTarget::Compat("ThemeProvider")), @@ -446,6 +468,9 @@ pub fn transform_import_aliases_with_edits<'a>( "css" | "keyframes", ) => numbers.calls.push((local, RulesAt::EveryArgument)), ("@emotion/react", "Global") => numbers.components.push(local), + ("@emotion/react", "ClassNames") => { + numbers.class_names.push(local); + } _ => {} } } @@ -473,7 +498,10 @@ pub fn transform_import_aliases_with_edits<'a>( } } numbers.css_prop = CssProp::of(import_aliases, code, uses_emotion); - if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) + if !(numbers.calls.is_empty() + && numbers.styled.is_empty() + && numbers.components.is_empty() + && numbers.class_names.is_empty()) || numbers.css_prop != CssProp::Off { numbers.visit_program(&program); diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 2094e2fea..03cb5c9b5 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -357,6 +357,7 @@ pub(crate) fn inline_constants<'a>( css_props: &css_props, names: FxHashSet::default(), depth: 0, + class_names: Vec::new(), }; read.visit_program(program); if read.names.is_empty() { @@ -509,6 +510,7 @@ pub(crate) fn inline_constants<'a>( objects: false, styles: false, px: false, + class_names: Vec::new(), } .visit_program(program); } @@ -643,6 +645,9 @@ struct StyleReads<'s> { css_props: &'s CssTakers<'s>, names: FxHashSet, depth: usize, + /// The names the `` child functions around take `css` and + /// `cx` by + class_names: Vec, } /// Whether `expression` is a style API: a root the package gives, or a member @@ -676,9 +681,18 @@ impl<'a> Visit<'a> for StyleReads<'_> { } } + fn visit_jsx_element(&mut self, element: &oxc_ast::ast::JSXElement<'a>) { + let calls = self.css_props.class_names_calls(element); + let taken = calls.len(); + self.class_names.extend(calls); + oxc_ast_visit::walk::walk_jsx_element(self, element); + self.class_names.truncate(self.class_names.len() - taken); + } + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&call.callee); - let style = self.is_style_root(&call.callee); + let style = + self.is_style_root(&call.callee) || calls_one_of(&self.class_names, &call.callee); let css = self .css_props .property(call, |root| self.style_roots.contains(root)); @@ -703,7 +717,7 @@ impl<'a> Visit<'a> for StyleReads<'_> { tagged: &oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&tagged.tag); - let style = self.is_style_root(&tagged.tag); + let style = self.is_style_root(&tagged.tag) || calls_one_of(&self.class_names, &tagged.tag); self.reading(style, |reads| reads.visit_template_literal(&tagged.quasi)); } @@ -728,6 +742,11 @@ impl<'a> Visit<'a> for StyleReads<'_> { } } +/// Whether `callee` is one of `names` +fn calls_one_of(names: &[String], callee: &Expression<'_>) -> bool { + matches!(callee, Expression::Identifier(callee) if names.iter().any(|name| name == callee.name.as_str())) +} + /// The name `` or `` starts with pub(crate) fn jsx_root<'n>(name: &'n JSXElementName<'_>) -> Option<&'n str> { match name { @@ -1793,6 +1812,9 @@ struct Inline<'s, 'a> { styles: bool, /// Inside a `css` prop, whose numbers Emotion reads as `px` lengths px: bool, + /// The names the `` child functions around take `css` and + /// `cx` by + class_names: Vec, } impl<'a> Inline<'_, 'a> { @@ -2070,6 +2092,12 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { tagged: &mut oxc_ast::ast::TaggedTemplateExpression<'a>, ) { self.visit_expression(&mut tagged.tag); + if calls_one_of(&self.class_names, &tagged.tag) { + self.reading_css(true, |inline| { + inline.visit_template_literal(&mut tagged.quasi); + }); + return; + } let styles = is_style_root(self.style_roots, &tagged.tag); self.reading_styles(styles, |inline| { inline.visit_template_literal(&mut tagged.quasi); @@ -2082,8 +2110,24 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { }); } + fn visit_jsx_element(&mut self, element: &mut oxc_ast::ast::JSXElement<'a>) { + let calls = self.css_props.class_names_calls(element); + let taken = calls.len(); + self.class_names.extend(calls); + walk_mut::walk_jsx_element(self, element); + self.class_names.truncate(self.class_names.len() - taken); + } + fn visit_call_expression(&mut self, call: &mut oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&mut call.callee); + if calls_one_of(&self.class_names, &call.callee) { + self.reading_css(true, |inline| { + for argument in &mut call.arguments { + inline.visit_argument(argument); + } + }); + return; + } let objects = self.apis.reads(&call.callee); let styles = is_style_root(self.style_roots, &call.callee); let css = self diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 9415e1c89..e1a91c5e5 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -21453,4 +21453,75 @@ export const App = () => {({ css, cx, theme }) =>
};", + "export const App = ({ on }) => {({ css, cx }) =>
};", + "export const App = ({ on }) => {function ({ css: c }) { return ; }};", + "const mixin = { margin: 4 };\nexport const App = () =>
{({ css }) =>

}

;", + "export const App = () => {() =>
}
;", + "export const App = (p) => {({ cx }) => p.on()} title={String.raw`t`} id={String(1)} className={cx(p.className, cx('a'), p.on ? { x: p.on } : 'y', p.a || { z: p.b }, p.c ? cx('k') : null, p.d && cx())} />};", + "const tone = 'red';\nconst gap = 3;\nexport const App = () => {({ css }) => };", + ]; + let outputs: Vec = codes + .iter() + .map(|code| { + reset_class_map(); + reset_file_map(); + let output = + extract("test.tsx", &format!("{import}{code}"), emotion_option()).unwrap(); + format!("{}\n{:?}", output.code, output.styles) + }) + .collect(); + assert_debug_snapshot!(outputs); + } + + #[test] + #[serial] + fn test_emotion_class_names_reports_what_it_cannot_compile() { + let import = "import { ClassNames } from '@emotion/react';\n"; + let errors: Vec = [ + "export const App = () => {render};", + "export const App = () => {() =>
}{() => };", + "export const App = () => ;", + "export const App = () => {};", + "export const App = () => text;", + "export const App = () => {() => };", + "export const App = () => {(content) => };", + "export const App = () => {({ css, ...rest }) => };", + "export const App = () => {({ css = f }) => };", + "export const App = () => {({ other }) => };", + "export const App = () => {({ [k]: css }) => };", + "export const App = () => {({ css }, more) => };", + "export const App = () => {async ({ css }) => };", + "export const App = () => {function* ({ css }) { yield ; }};", + "export const App = () => {({ css }) => { const a = 1; return ; }};", + "export const App = () => {({ theme }) => };", + "export const App = () => {({ css, theme }) => };", + "export const App = () => {({ css }) => };", + "export const App = () => {({ cx }) => };", + "export const App = ({ s }) => {({ cx }) => };", + "export const App = () => {({ cx }) => };", + "export const App = ({ s }) => {({ css }) => };", + "export const App = ({ v }) => {({ css }) => };", + "export const App = ({ v }) => {({ css }) => };", + "export const App = ({ on }) => {({ css, cx }) => };", + "export const App = ({ on, off }) => {({ css, cx }) => };", + ] + .iter() + .map(|code| { + reset_class_map(); + reset_file_map(); + extract("test.tsx", &format!("{import}{code}"), emotion_option()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default() + }) + .collect(); + assert_debug_snapshot!(errors); + } } diff --git a/libs/extractor/src/mutations.rs b/libs/extractor/src/mutations.rs index f1ea6c2ef..e140a52ff 100644 --- a/libs/extractor/src/mutations.rs +++ b/libs/extractor/src/mutations.rs @@ -390,8 +390,12 @@ impl Context<'_, '_> { std::iter::once(node) .chain(self.nodes.ancestor_ids(node)) .any(|id| match self.nodes.kind(id) { - AstKind::CallExpression(call) => self.is_style(&call.callee), - AstKind::TaggedTemplateExpression(tagged) => self.is_style(&tagged.tag), + AstKind::CallExpression(call) => { + self.is_style(&call.callee) || self.is_class_names_call(id, &call.callee) + } + AstKind::TaggedTemplateExpression(tagged) => { + self.is_style(&tagged.tag) || self.is_class_names_call(id, &tagged.tag) + } AstKind::JSXOpeningElement(element) => self.is_style_element(&element.name), AstKind::JSXAttribute(attribute) => self.is_css_attribute(id, attribute), AstKind::ObjectProperty(property) => self.is_css_property(id, property), @@ -399,6 +403,18 @@ impl Context<'_, '_> { }) } + /// Whether `callee`, called at `id`, is the `css` or `cx` a `` + /// child function around takes, whose calls the build compiles + fn is_class_names_call(&self, id: NodeId, callee: &Expression<'_>) -> bool { + let (Some(css), Expression::Identifier(callee)) = (self.css, callee) else { + return false; + }; + self.nodes.ancestor_ids(id).any(|ancestor| { + matches!(self.nodes.kind(ancestor), AstKind::JSXElement(element) + if css.class_names_calls(element).iter().any(|name| name == callee.name.as_str())) + }) + } + /// Whether the attribute `attribute` at `id` is a `css` prop the build /// compiles fn is_css_attribute(&self, id: NodeId, attribute: &oxc_ast::ast::JSXAttribute<'_>) -> bool { diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_in_every_form.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_in_every_form.snap new file mode 100644 index 000000000..fcb3f6c39 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_in_every_form.snap @@ -0,0 +1,13 @@ +--- +source: libs/extractor/src/lib.rs +expression: outputs +--- +[ + "import \"@devup-ui/react/devup-ui.css\";\nexport const App = () =>
;\n\n{Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"var(--color)\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nexport const App = ({ on }) =>
;\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nexport const App = ({ on }) => ;\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nconst mixin = { margin: 4 };\nexport const App = () =>
{

}

;\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"4px\", level: 0, selector: None, style_order: None, layer: None })}", + "export const App = () =>
;\n\n{}", + "export const App = (p) => p.on()} title={String.raw`t`} id={String(1)} className={`a ${p.className} ${p.on ? p.on ? \"x\" : \"\" : \"y\"} ${p.a ? typeof p.a === \"string\" ? p.a : \"\" : p.b ? \"z\" : \"\"} ${p.c ? \"k\" : \"\"}`} />;\n\n{}", + "import \"@devup-ui/react/devup-ui.css\";\nconst tone = \"red\";\nconst gap = 3;\nexport const App = () => ;\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"3px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"2px\", level: 0, selector: None, style_order: None, layer: None })}", +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap new file mode 100644 index 000000000..785775e39 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap @@ -0,0 +1,32 @@ +--- +source: libs/extractor/src/lib.rs +expression: errors +--- +[ + "test.tsx:2:39: `` cannot use `render` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:26: `` cannot use `children` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:26: `` cannot use `children` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:38: `` cannot use `{}` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:26: `` cannot use `children` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:38: `` cannot use `an attribute` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `(content) =>
` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ css, ...rest }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ css = f }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ other }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ [k]: css }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ css }, more) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `async ({ css }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `(function* ({ css }) { yield ; })` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ css }) => { const a = 1; return ; }` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:64: `` cannot use `theme` at build time: it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets", + "test.tsx:2:73: `css()` cannot use `theme.space(2)` at build time: its values must be literals, theme tokens or constants, or be computed from them\ntest.tsx:2:87: `` cannot use `theme.space(2)` at build time: it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets", + "test.tsx:2:60: `` cannot use `css` at build time: the `css` and `cx` its child function takes can only be called, as the build compiles each call", + "test.tsx:2:65: `` cannot use `cx(getClass())` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "test.tsx:2:78: `` cannot use `s` at build time: an object `cx` takes must give each class a condition, as `{ name: condition }`", + "test.tsx:2:75: `` cannot use `(function() { return true; })` at build time: an object `cx` takes must give each class a condition, as `{ name: condition }`", + "test.tsx:2:71: `` cannot use `css(...s)` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "test.tsx:2:87: `` cannot use `v` at build time: a mixin must stand outside nested rules, where the parts it composes with can be split", + "test.tsx:2:71: `css()` cannot use `v` at build time: its values must be literals, theme tokens or constants, or be computed from them", + "test.tsx:2:76: `` cannot use `cx(on && cx(css({ color: \"red\" }), \"x\"))` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "test.tsx:2:81: `` cannot use `cx(on && cx(off ? css({ color: \"red\" }) : null))` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap index 919298f4f..c64fe2255 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_global_component_and_import_surface.snap @@ -32,5 +32,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global, ThemeProvider, useTheme } from \"@devup-ui/react/compat\";\nimport { ClassNames } from \"@emotion/react\";\nconst S = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = () => <>;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global, ThemeProvider, useTheme } from \"@devup-ui/react/compat\";\nconst S = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = () => <>;\n", } diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 10fff27b5..e5fb825c0 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -965,6 +965,16 @@ pub(super) const CSS_PROP_VALUE: &str = "it must be a style object, CSS text, a pub(super) const LOCAL_STYLES: &str = "a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it"; +pub(super) const CLASS_NAMES_CHILD: &str = + "it takes only a child function of `{ css, cx, theme }` giving what it renders at once"; + +pub(super) const CLASS_NAMES_CALL: &str = "the `css` and `cx` its child function takes can only be called, as the build compiles each call"; + +pub(super) const CLASS_NAMES_PART: &str = "`css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these"; + +pub(super) const CLASS_NAMES_CLASS_MAP: &str = + "an object `cx` takes must give each class a condition, as `{ name: condition }`"; + /// The `css` prop of the styled component `element` sets what its own styles /// set, which the build cannot order there pub(super) fn css_prop_override_error(element: &str) -> String { diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index a1f3eec31..a74a216aa 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1,7 +1,10 @@ use crate::as_visit::As; use crate::component::ExportVariableKind; use crate::composition::{KnownPart, KnownSide, KnownStyles, overlaps, set_prop_order}; -use crate::css_prop::{CssProp, template_parts, theme_rules}; +use crate::css_prop::{ + ClassNamesParams, CssProp, THEME_READ, class_names_params, read_theme, render_function, + template_parts, theme_rules, +}; use crate::css_utils::{ TemplateStyles, css_to_style_template, keyframes_to_keyframes_style, optimize_css_block, template_css_text, @@ -51,10 +54,12 @@ use oxc_ast::ast::{ ComputedMemberExpression, Expression, ExpressionStatement, FormalParameter, FormalParameterKind, FormalParameters, IdentifierName, ImportDeclaration, ImportOrExportKind, JSXAttributeItem, JSXAttributeValue, JSXChild, JSXElement, JSXElementName, - JSXExpressionContainer, ObjectProperty, ObjectPropertyKind, Program, PropertyKey, PropertyKind, - Statement, StaticMemberExpression, Str, StringLiteral, UnaryOperator, VariableDeclarator, + JSXExpressionContainer, ObjectExpression, ObjectProperty, ObjectPropertyKind, Program, + PropertyKey, PropertyKind, Statement, StaticMemberExpression, Str, StringLiteral, + UnaryOperator, VariableDeclarator, }; use oxc_ast_visit::VisitMut; +use oxc_ast_visit::walk_mut; use oxc_ast_visit::walk_mut::{ walk_call_expression, walk_expression, walk_expression_statement, walk_import_declaration, walk_jsx_attribute_value, walk_jsx_child, walk_jsx_element, walk_program, @@ -65,9 +70,10 @@ use oxc_syntax::operator::LogicalOperator; use strum::IntoEnumIterator; use crate::utils::{ - CSS_PROP_VALUE, LOCAL_STYLES, ParsedStyleOrder, RUNTIME_VALUE, STYLE_OBJECT, StyleArguments, - Suspends, build_time_error, call_with_values, css_prop_error, css_prop_override_error, - element_error, expression_to_style_order, fixed_value, get_str_by_property_key, + CLASS_NAMES_CALL, CLASS_NAMES_CHILD, CLASS_NAMES_CLASS_MAP, CLASS_NAMES_PART, CSS_PROP_VALUE, + LOCAL_STYLES, ParsedStyleOrder, RUNTIME_VALUE, STYLE_OBJECT, StyleArguments, Suspends, + build_time_error, call_with_values, css_prop_error, css_prop_override_error, element_error, + expression_to_style_order, fixed_value, get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, is_pure, jsx_expression_to_style_order, key_error, readable_argument, readable_code, reads_directly, reads_spreads_once, reads_unknown, runtime_classes, runtime_value, runtime_value_error, @@ -199,6 +205,11 @@ pub struct DevupVisitor<'a> { /// Local names bound to the `Global` component, whose `styles` prop declares /// global CSS instead of rendering markup. global_style_components: FxHashSet, + /// Local names bound to Emotion's `ClassNames`, whose element becomes + /// what its child function renders + class_names_components: FxHashSet, + /// What the `` child functions being compiled take + class_names_scope: Vec>, /// `defineVars` members flattened to `"vars.key"` -> `"var(--x)"`, so a /// `stylex.create()` value referencing one resolves to a static CSS value. @@ -394,13 +405,26 @@ impl<'a> DevupVisitor<'a> { } self.unknown_arguments("css", &call.arguments); self.changed_arguments("css", &call.arguments); + let (result, known) = self.composed_class(call.span.start, parts); + if let Some(known) = known { + self.css_styles = Some((call.span.start, known)); + } + Some(result) + } + /// The classes `parts`, composed at `offset`, give, with the styles they + /// always set when no class the build does not know joins them + fn composed_class( + &mut self, + offset: u32, + parts: Vec>, + ) -> (Expression<'a>, Option>) { let mut composition = crate::composition::Composition::default(); let mut classes = Vec::new(); for part in parts { match part { KnownPart::Styles(side) => { - let props = self.part_props(call.span.start, side, None); + let props = self.part_props(offset, side, None); composition.apply(&self.ast, props); } KnownPart::Conditional { @@ -408,8 +432,8 @@ impl<'a> DevupVisitor<'a> { consequent, alternate, } => { - let consequent = self.part_props(call.span.start, consequent, None); - let alternate = self.part_props(call.span.start, alternate, None); + let consequent = self.part_props(offset, consequent, None); + let alternate = self.part_props(offset, alternate, None); composition.apply_conditional(&self.ast, &test, consequent, alternate); } KnownPart::Class(mut class) => { @@ -418,7 +442,7 @@ impl<'a> DevupVisitor<'a> { } } } - let known = composition.unconditional(); + let known = composition.unconditional().filter(|_| classes.is_empty()); let mut props = composition.into_props(); // Class names come out in reverse, so they read in composing order props.reverse(); @@ -426,14 +450,10 @@ impl<'a> DevupVisitor<'a> { gen_class_names(&self.ast, &mut props, None, self.split_filename.as_deref()); self.styles .extend(props.into_iter().flat_map(ExtractStyleProp::into_extract)); - let composed_class = classes.is_empty(); let result = merge_expression_for_class_name(&self.ast, classes.into_iter().chain(class_name)) .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)); - if composed_class && let Some(known) = known { - self.css_styles = Some((call.span.start, known)); - } - Some(result) + (result, known) } /// Whether `expression`, or a part of it, is a class whose styles the @@ -512,6 +532,13 @@ impl<'a> DevupVisitor<'a> { &conditional.consequent, Some(&conditional.alternate), ), + Expression::CallExpression(call) if self.class_names_text(&call.callee).is_some() => { + let text = self.class_names_text(&call.callee)?; + for argument in &call.arguments { + self.known_parts(argument.as_expression()?, parts, text)?; + } + return Some(()); + } expression => { match self.known_side(expression, text)? { KnownSide::Styles(side) => parts.push(KnownPart::Styles(side)), @@ -600,10 +627,40 @@ impl<'a> DevupVisitor<'a> { | Expression::ComputedMemberExpression(_) | Expression::StringLiteral(_) | Expression::TemplateLiteral(_) => Some(KnownSide::Class(code())), + Expression::CallExpression(call) if self.class_names_text(&call.callee).is_some() => { + self.folded_side(expression, text) + } + Expression::ArrayExpression(_) if !self.class_names_scope.is_empty() => { + self.folded_side(expression, text) + } _ => None, } } + /// The parts `expression` composes as one side of a condition: only + /// styles, or only classes, which join + fn folded_side(&self, expression: &Expression<'a>, text: Text) -> Option> { + let mut parts = Vec::new(); + self.known_parts(expression, &mut parts, text)?; + let mut styles = Vec::new(); + let mut classes = Vec::new(); + for part in parts { + match part { + KnownPart::Styles(side) => styles.extend(side), + KnownPart::Class(class) => classes.push(class), + KnownPart::Conditional { .. } => return None, + } + } + match (styles.is_empty(), classes.is_empty()) { + (true, true) => Some(KnownSide::Empty), + (false, true) => Some(KnownSide::Styles(styles)), + (true, false) => { + merge_expression_for_class_name(&self.ast, classes).map(KnownSide::Class) + } + (false, false) => None, + } + } + /// The styles of a part, each at the order the part gives it; `element` /// names the element taking them as its `css` prop, which sets a value /// only the runtime gives as a CSS variable @@ -680,6 +737,8 @@ impl<'a> DevupVisitor<'a> { stylex_imports: FxHashSet::default(), stylex_named_imports: FxHashMap::default(), global_style_components: FxHashSet::default(), + class_names_components: FxHashSet::default(), + class_names_scope: Vec::new(), stylex_var_refs: FxHashMap::default(), stylex_var_names: FxHashMap::default(), stylex_theme_classes: FxHashMap::default(), @@ -1372,6 +1431,290 @@ impl<'a> DevupVisitor<'a> { add_class_and_style_to_object(&self.ast, &mut props.properties, class_name, style); } } + + /// What Emotion's `` element renders, its classes compiled; + /// `None` for any other element + fn class_names(&mut self, element: &mut JSXElement<'a>) -> Option> { + let JSXElementName::IdentifierReference(name) = &element.opening_element.name else { + return None; + }; + if !self.class_names_components.contains(name.name.as_str()) { + return None; + } + Some( + self.compile_class_names(element) + .unwrap_or_else(|(at, code)| { + self.errors + .push((at, element_error("ClassNames", &code, CLASS_NAMES_CHILD))); + Expression::new_null_literal(SPAN, &self.ast) + }), + ) + } + + /// `{({ css, cx, theme }) => rendered}` as + /// `rendered`: its `css` and `cx` calls compile as `css()`, and the theme + /// they read becomes the CSS variables the `ThemeProvider` sets. `Err` + /// holds where and what the build cannot compile. + fn compile_class_names( + &mut self, + element: &mut JSXElement<'a>, + ) -> Result, (u32, String)> { + if let Some(attribute) = element.opening_element.attributes.first() { + return Err((attribute.span().start, "an attribute".to_string())); + } + let span = element.span; + let mut children = element + .children + .iter_mut() + .filter(|child| !matches!(child, JSXChild::Text(text) if text.value.trim().is_empty())); + let (Some(JSXChild::ExpressionContainer(container)), None) = + (children.next(), children.next()) + else { + return Err((span.start, "children".to_string())); + }; + let Some(function) = container.expression.as_expression_mut() else { + return Err((container.span.start, "{}".to_string())); + }; + let function_at = function.span().start; + let function_code = readable_code(function); + let Some((names, body)) = render_function(function) + .and_then(|(params, body)| Some((class_names_params(params)?, body))) + else { + return Err((function_at, function_code)); + }; + let mut rendered = body.take_in(&self.ast); + let mut calls = ClassNamesCalls { + ast: &self.ast, + names, + unread: None, + }; + calls.visit_expression(&mut rendered); + if let Some((unread, requirement)) = calls.unread { + self.errors.push(( + unread.span().start, + element_error("ClassNames", &readable_code(&unread), requirement), + )); + } + self.class_names_scope.push(names); + self.visit_expression(&mut rendered); + self.class_names_scope.pop(); + Ok(rendered) + } + + /// What a call of the `css` or `cx` a `` child function + /// takes composes: `css` reads text as CSS, `cx` as classes + fn class_names_text(&self, callee: &Expression<'a>) -> Option { + let Expression::Identifier(callee) = callee else { + return None; + }; + let name = Some(callee.name.as_str()); + self.class_names_scope.iter().rev().find_map(|names| { + if names.css == name { + Some(Text::Rules) + } else if names.cx == name { + Some(Text::Classes) + } else { + None + } + }) + } + + /// Replace `it`, when a call of the `css` or `cx` a `` child + /// function takes, with the classes it composes + fn compile_class_names_call(&mut self, it: &mut Expression<'a>) -> bool { + let Expression::CallExpression(call) = it else { + return false; + }; + if self.class_names_text(&call.callee).is_none() { + return false; + } + let offset = call.span.start; + self.unknown_arguments("css", &call.arguments); + self.changed_arguments("css", &call.arguments); + let mut parts = Vec::new(); + *it = if self.known_parts(it, &mut parts, Text::Classes).is_some() { + self.composed_class(offset, parts).0 + } else { + self.errors.push(( + offset, + element_error("ClassNames", &readable_code(it), CLASS_NAMES_PART), + )); + Expression::new_string_literal(SPAN, "", None, &self.ast) + }; + true + } +} + +/// The calls of the `css` and `cx` a `` child function takes, +/// readied to compose: the theme their rules read becomes the CSS variables +/// the `ThemeProvider` sets, an object `cx` takes the conditions of its +/// classes, and CSS text `css` tags the parts around its mixins. The first +/// code the build cannot ready so is kept. +struct ClassNamesCalls<'r, 'a> { + ast: &'r AstBuilder<'a>, + names: ClassNamesParams<'a>, + unread: Option<(Expression<'a>, &'static str)>, +} + +impl<'a> ClassNamesCalls<'_, 'a> { + fn calls(callee: &Expression<'a>, name: Option<&'a str>) -> bool { + matches!(callee, Expression::Identifier(callee) if name == Some(callee.name.as_str())) + } + + fn read_theme(&mut self, expression: &mut Expression<'a>, value: bool) { + if let Some(theme) = self.names.theme + && let Err(unread) = read_theme(self.ast, expression, theme, value) + { + self.unread.get_or_insert((unread, THEME_READ)); + } + } + + /// `part` of what `cx` composes with each object in it written as the + /// conditions of the classes it names + fn class_maps(&mut self, part: &mut Expression<'a>) { + let classes = match unwrap_syntax_only_mut(part) { + Expression::ArrayExpression(array) => { + for part in &mut array.elements { + if let Some(part) = part.as_expression_mut() { + self.class_maps(part); + } + } + return; + } + Expression::ConditionalExpression(conditional) => { + self.class_maps(&mut conditional.consequent); + self.class_maps(&mut conditional.alternate); + return; + } + Expression::LogicalExpression(logical) => { + if logical.operator != LogicalOperator::And { + self.class_maps(&mut logical.left); + } + self.class_maps(&mut logical.right); + return; + } + Expression::ObjectExpression(object) => self.class_map(object), + _ => return, + }; + *part = classes; + } + + /// `{ name: condition }` as `[condition && 'name']` + fn class_map(&mut self, object: &mut ObjectExpression<'a>) -> Expression<'a> { + let mut classes = oxc_allocator::Vec::new_in(self.ast); + for property in object.properties.drain(..) { + let mut property = match property { + ObjectPropertyKind::ObjectProperty(property) + if property.kind == PropertyKind::Init && !property.method => + { + property + } + ObjectPropertyKind::ObjectProperty(mut property) => { + let value = property.value.take_in(self.ast); + self.unread.get_or_insert((value, CLASS_NAMES_CLASS_MAP)); + continue; + } + ObjectPropertyKind::SpreadProperty(mut spread) => { + let argument = spread.argument.take_in(self.ast); + self.unread.get_or_insert((argument, CLASS_NAMES_CLASS_MAP)); + continue; + } + }; + let class = if property.computed + && let Some(key) = property.key.as_expression_mut() + { + key.take_in(self.ast) + } else { + let name = property.key.static_name().unwrap_or_default(); + Expression::new_string_literal( + SPAN, + Str::from_in(name.as_ref(), self.ast.allocator()), + None, + self.ast, + ) + }; + let condition = property.value.take_in(self.ast); + classes.push( + Expression::new_logical_expression( + SPAN, + condition, + LogicalOperator::And, + class, + self.ast, + ) + .into(), + ); + } + Expression::new_array_expression(SPAN, classes, self.ast) + } +} + +impl<'a> VisitMut<'a> for ClassNamesCalls<'_, 'a> { + fn visit_expression(&mut self, it: &mut Expression<'a>) { + let Expression::TaggedTemplateExpression(tagged) = it else { + walk_mut::walk_expression(self, it); + return; + }; + if !Self::calls(&tagged.tag, self.names.css) { + walk_mut::walk_expression(self, it); + return; + } + for expression in &mut tagged.quasi.expressions { + self.read_theme(expression, true); + self.visit_expression(expression); + } + *it = match template_parts(self.ast, &tagged.quasi, true) { + Ok(parts) => Expression::new_call_expression( + SPAN, + tagged.tag.take_in(self.ast), + None::>>, + oxc_allocator::Vec::from_iter_in(parts.into_iter().map(Argument::from), self.ast), + false, + self.ast, + ), + Err(unplaced) => { + self.unread.get_or_insert(unplaced); + Expression::new_identifier(SPAN, "undefined", self.ast) + } + }; + } + + fn visit_call_expression(&mut self, call: &mut CallExpression<'a>) { + let cx = Self::calls(&call.callee, self.names.cx); + if !cx && !Self::calls(&call.callee, self.names.css) { + walk_call_expression(self, call); + return; + } + for argument in &mut call.arguments { + let part = match argument { + Argument::SpreadElement(spread) => &mut spread.argument, + argument => argument.to_expression_mut(), + }; + if cx { + self.class_maps(part); + } + self.read_theme(part, false); + self.visit_expression(part); + } + } + + /// A read of the theme or of `css` and `cx` the calls do not take + fn visit_identifier_reference(&mut self, it: &mut oxc_ast::ast::IdentifierReference<'a>) { + let name = Some(it.name.as_str()); + let requirement = if self.names.theme == name { + THEME_READ + } else if self.names.css == name || self.names.cx == name { + CLASS_NAMES_CALL + } else { + return; + }; + self.unread.get_or_insert_with(|| { + ( + Expression::new_identifier(it.span, it.name, self.ast), + requirement, + ) + }); + } } impl<'a> DevupVisitor<'a> { @@ -1981,6 +2324,9 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } } fn visit_expression(&mut self, it: &mut Expression<'a>) { + if !self.class_names_scope.is_empty() && self.compile_class_names_call(it) { + return; + } if !self.styled_imports.is_empty() { match it { Expression::CallExpression(call) => self.plain_styled(&mut call.callee), @@ -3260,6 +3606,12 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.jsx_imports.insert(local, imported_str); continue; } + if compat && imported_str == "ClassNames" { + self.class_names_components.insert(local.clone()); + self.compiled_names.insert(local); + specifiers.remove(i); + continue; + } if let Ok(kind) = imported_str.parse::() { self.imports.insert(local.clone(), kind); } else if let Some(kind) = UtilType::from_str_opt(&imported_str) { @@ -3311,6 +3663,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } #[allow(clippy::set_contains_or_insert)] fn visit_jsx_element(&mut self, elem: &mut JSXElement<'a>) { + if let Some(rendered) = self.class_names(elem) { + self.pending_replacement = Some(rendered); + return; + } let css = self.take_css_prop(elem); walk_jsx_element(self, elem); diff --git a/packages/react/src/compat/__tests__/index.test.ts b/packages/react/src/compat/__tests__/index.test.ts index 65dd5eb70..f0dc55c58 100644 --- a/packages/react/src/compat/__tests__/index.test.ts +++ b/packages/react/src/compat/__tests__/index.test.ts @@ -6,6 +6,7 @@ describe('compat entry', () => { const compat = await import('../index') expect({ ...compat }).toEqual({ + ClassNames: expect.any(Function), Global: expect.any(Function), ThemeProvider: expect.any(Function), @@ -27,6 +28,14 @@ describe('compat entry', () => { expect(jsx).toBe(createElement) }) + it('leaves ClassNames for the build to compile', async () => { + const { ClassNames } = await import('../index') + + expect(() => ClassNames({ children: () => null })).toThrow( + 'Cannot run on the runtime', + ) + }) + it('keeps its useTheme distinct from the devup-ui one', async () => { const { useTheme: compatUseTheme } = await import('../index') const { useTheme: devupUseTheme } = await import('../../index') diff --git a/packages/react/src/compat/emotion.d.ts b/packages/react/src/compat/emotion.d.ts index 6f53d0cf3..095bb0938 100644 --- a/packages/react/src/compat/emotion.d.ts +++ b/packages/react/src/compat/emotion.d.ts @@ -9,7 +9,9 @@ declare module '@emotion/styled' { declare module '@emotion/react' { export { css, keyframes } from '@devup-ui/react' export type { StyledTheme as Theme } from '@devup-ui/react/compat' + export type { ClassNamesArg, ClassNamesContent } from '@devup-ui/react/compat' export { + ClassNames, jsx as createElement, Global, jsx, diff --git a/packages/react/src/compat/index.ts b/packages/react/src/compat/index.ts index ed84439f0..847c0dc26 100644 --- a/packages/react/src/compat/index.ts +++ b/packages/react/src/compat/index.ts @@ -7,6 +7,8 @@ * rewritten imports here automatically. */ +import type { ReactNode } from 'react' + import type { DevupPropsWithTheme } from '../types/props' import type { StyledTheme } from '../utils/theme-vars' @@ -39,3 +41,30 @@ export type CssInterpolation = /** Emotion's `css` prop: styles, or a function of the theme giving them. */ export type CssProp = CssInterpolation | ((theme: StyledTheme) => CssInterpolation) + +/** A class `cx` composes, or an object giving each class its condition. */ +export type ClassNamesArg = + | string + | false + | null + | undefined + | Record + | readonly ClassNamesArg[] + +/** What the child function of Emotion's `ClassNames` takes. */ +export interface ClassNamesContent { + css: (...styles: CssInterpolation[]) => string + cx: (...classes: ClassNamesArg[]) => string + theme: StyledTheme +} + +/** + * Emotion's `ClassNames`: the build renders what its child function gives in + * its place, each `css` and `cx` call compiled to classes. + */ +export function ClassNames( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + props: { children: (content: ClassNamesContent) => ReactNode }, +): ReactNode { + throw new Error('Cannot run on the runtime') +} From 4f88bd43a053540bebf62985c0164043548bec99 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 05:33:25 +0900 Subject: [PATCH 3/5] test(extractor): cover ClassNames child functions taking rest or nested parameters Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/lib.rs | 2 ++ ...sts__emotion_class_names_reports_what_it_cannot_compile.snap | 2 ++ 2 files changed, 4 insertions(+) diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index e1a91c5e5..b26968d94 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -21497,6 +21497,8 @@ export const App = () => {({ other }) => };", "export const App = () => {({ [k]: css }) => };", "export const App = () => {({ css }, more) => };", + "export const App = () => {(...content) => };", + "export const App = () => {({ css: { a } }) => };", "export const App = () => {async ({ css }) => };", "export const App = () => {function* ({ css }) { yield ; }};", "export const App = () => {({ css }) => { const a = 1; return ; }};", diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap index 785775e39..796b3ae06 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap @@ -15,6 +15,8 @@ expression: errors "test.tsx:2:39: `` cannot use `({ other }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", "test.tsx:2:39: `` cannot use `({ [k]: css }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", "test.tsx:2:39: `` cannot use `({ css }, more) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `(...content) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", + "test.tsx:2:39: `` cannot use `({ css: { a } }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", "test.tsx:2:39: `` cannot use `async ({ css }) => ` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", "test.tsx:2:39: `` cannot use `(function* ({ css }) { yield ; })` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", "test.tsx:2:39: `` cannot use `({ css }) => { const a = 1; return ; }` at build time: it takes only a child function of `{ css, cx, theme }` giving what it renders at once", From d44950f769b5820b561b79b09a82b05b6cb78e9b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 10:15:19 +0900 Subject: [PATCH 4/5] fix(extractor): compile Emotion component selectors at build time Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...epack_log_emotion_component_selectors.json | 7 ++ libs/css/src/lib.rs | 38 +++++++++ libs/extractor/src/css_utils.rs | 67 +++++++++++---- .../extract_style_from_expression.rs | 5 +- .../extractor/extract_style_from_styled.rs | 80 +++++++++++++---- libs/extractor/src/lib.rs | 26 ++++++ ...r__tests__emotion_component_selectors.snap | 12 +++ libs/extractor/src/style_values.rs | 85 ++++++++++++++++++- libs/extractor/src/visit.rs | 61 +++++++++++-- 9 files changed, 334 insertions(+), 47 deletions(-) create mode 100644 .changepacks/changepack_log_emotion_component_selectors.json create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_component_selectors.snap diff --git a/.changepacks/changepack_log_emotion_component_selectors.json b/.changepacks/changepack_log_emotion_component_selectors.json new file mode 100644 index 000000000..98c3f7259 --- /dev/null +++ b/.changepacks/changepack_log_emotion_component_selectors.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Emotion and styled-components component selectors now compile at build time: a styled component the file defines that another style selects (${Child} { ... } in CSS text, [Child] or [`&:hover ${Child}`] as a rule key) gets a short marker class, which only such components get, and the selector reads it as .marker; a component extending it, or an element inlining it for its css prop, keeps the marker. Selectors written without & now nest under the component as stylis nests them (ul { ... } is & ul, a selector list nests each selector, :hover applies to the component itself) instead of becoming global rules, and rule-object keys starting with ., #, [, *, >, + or ~ are read as nested selectors instead of properties", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/css/src/lib.rs b/libs/css/src/lib.rs index 576ba53b9..427732ae7 100644 --- a/libs/css/src/lib.rs +++ b/libs/css/src/lib.rs @@ -429,6 +429,25 @@ pub fn keyframes_to_keyframes_name(keyframes: &str, filename: Option<&str>) -> S }) } +/// The class marking the component `name` defined in `filename`, which other +/// styles select it by +#[must_use] +pub fn component_marker(name: &str, filename: &str) -> String { + with_prefix(|prefix| { + if is_debug() { + format!("{prefix}c-{name}") + } else { + let class_num = class_num_for_key("", |key| { + key.push_str("c-"); + key.push_str(filename); + key.push('-'); + key.push_str(name); + }); + format!("{prefix}{class_num}") + } + }) +} + /// ASCII lookup table for selector encoding. `None` means pass through (alphanumeric, `-`, `_`) /// or fall through to the Unicode escape path. const SELECTOR_ENCODE: [Option<&str>; 128] = { @@ -753,6 +772,25 @@ mod tests { ); } + #[test] + #[serial] + fn test_component_marker() { + set_debug(false); + reset_class_map(); + let child = component_marker("Child", "a.tsx"); + assert_eq!(child, "a"); + assert_eq!(component_marker("Child", "a.tsx"), child); + assert_eq!(component_marker("Child", "b.tsx"), "b"); + assert_eq!(component_marker("Other", "a.tsx"), "c"); + assert_eq!( + sheet_to_classname("color", 0, Some("red"), None, None, None), + "d" + ); + set_debug(true); + assert_eq!(component_marker("Child", "a.tsx"), "c-Child"); + set_debug(false); + } + #[test] #[serial] fn test_sheet_to_classname() { diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index 6ea04a394..ab3239a64 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -587,19 +587,41 @@ fn nest_prelude(parent: Option<&StyleSelector>, prelude: &str) -> Option { - Some(selector) + Some(StyleSelector::nest_selector(parent, &descendants(prelude))) +} + +/// Each selector of the list `selectors` written without `&` as a descendant +/// of it, as stylis nests one +pub(crate) fn descendants(selectors: &str) -> String { + let mut depth = 0usize; + let mut from = 0; + let mut parts = Vec::new(); + for (index, c) in selectors.char_indices() { + match c { + '(' | '[' => depth += 1, + ')' | ']' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(&selectors[from..index]); + from = index + 1; + } + _ => {} } - Some(StyleSelector::At { selector, .. }) => selector.as_ref(), - None => None, - }; - let template = if prelude.contains('&') || parent_selector.is_none() { - Cow::Borrowed(prelude) - } else { - Cow::Owned(format!("& {prelude}")) - }; - Some(StyleSelector::nest_selector(parent, &template)) + } + parts.push(&selectors[from..]); + parts + .iter() + .map(|part| { + let part = part.trim(); + if part.contains('&') { + part.to_string() + } else if part.starts_with(':') { + format!("&{part}") + } else { + format!("& {part}") + } + }) + .collect::>() + .join(",") } /// Optimize a declaration's value only when its property warrants multi-value @@ -1171,7 +1193,7 @@ mod tests { #[case( "`ul { font-family: 'Roboto Hello', sans-serif; }`", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( @@ -1610,14 +1632,23 @@ mod tests { #[case( "ul { font-family: 'Roboto Hello', sans-serif; }", vec![ - ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("ul".to_string()))), + ("font-family", "\"Roboto Hello\",sans-serif", Some(StyleSelector::Selector("& ul".to_string()))), ] )] #[case( "div { color: red; ; { background: blue; } }", vec![ - ("color", "red", Some(StyleSelector::Selector("div".to_string()))), - ("background", "blue", Some(StyleSelector::Selector("div".to_string()))), + ("color", "red", Some(StyleSelector::Selector("& div".to_string()))), + ("background", "blue", Some(StyleSelector::Selector("& div".to_string()))), + ] + )] + // A selector written without `&` selects within the element, each one of + // a list on its own, and a pseudo-class applies to the element itself + #[case( + "a, :is(b, c) d { color: red; } :hover { color: blue; }", + vec![ + ("color", "red", Some(StyleSelector::Selector("& a,&:is(b,c) d".to_string()))), + ("color", "blue", Some(StyleSelector::Selector("&:hover".to_string()))), ] )] // As in CSS nesting, only the text after the last `;` is the nested rule's @@ -1626,7 +1657,7 @@ mod tests { "color:red;background:blue { width: 1px; }", vec![ ("color", "red", None), - ("width", "1px", Some(StyleSelector::Selector("background:blue".to_string()))), + ("width", "1px", Some(StyleSelector::Selector("& background:blue".to_string()))), ] )] #[case( @@ -1634,7 +1665,7 @@ mod tests { vec![( "width", "1px", - Some(StyleSelector::Selector("color:red".to_string())) + Some(StyleSelector::Selector("& color:red".to_string())) )] )] #[case( diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 4b8f7f27b..0b4b43d40 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -1257,9 +1257,10 @@ fn nest_underscore_name(parent: Option<&StyleSelector>, name: &str) -> Option li'`) rather +/// than CSS properties. fn is_nested_selector_key(key: &str) -> bool { - key.starts_with(':') || key.contains('&') + key.starts_with([':', '.', '#', '[', '*', '>', '+', '~']) || key.contains('&') } fn nest_selectors_key(parent: Option<&StyleSelector>, key: &str) -> Option { diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index e5955fb47..c1ceb13df 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -60,6 +60,27 @@ pub struct StyledDefinition<'a> { reads: Reads, /// Its `shouldForwardProp`, as the build evaluates it forward: Option, + /// The class other styles select it by, which a component extending it + /// gives too, as it renders this one + marker: Option, +} + +/// `marker` as a class among those a component gives +fn marker_class<'a>(ast_builder: &AstBuilder<'a>, marker: &str) -> Expression<'a> { + Expression::new_string_literal( + SPAN, + Str::from_in(marker, ast_builder.allocator()), + None, + ast_builder, + ) +} + +/// How the classes of a styled component are named: the file its atoms are +/// split into, and the class other styles select it by +#[derive(Clone, Copy, Default)] +pub struct Naming<'s> { + pub split_filename: Option<&'s str>, + pub marker: Option<&'s str>, } /// Whether a styled component renders a tag, which takes only valid @@ -119,6 +140,12 @@ impl<'a> StyledDefinition<'a> { .then_some((self.name.as_str(), self.styles.as_slice())) } + /// The class other styles select the component by + #[must_use] + pub fn marker(&self) -> Option<&str> { + self.marker.as_deref() + } + /// The styles the component gives what it renders #[must_use] pub fn styles(&self) -> &[ExtractStyleProp<'a>] { @@ -236,9 +263,11 @@ fn inherited_parts<'a>( |inherited| { ( inherited - .classes - .iter() - .map(|c| c.clone_in(allocator)) + .marker + .as_deref() + .map(|marker| marker_class(ast_builder, marker)) + .into_iter() + .chain(inherited.classes.iter().map(|c| c.clone_in(allocator))) .collect(), inherited .attrs @@ -294,6 +323,7 @@ impl<'a> Base<'a> { attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(), reads, forward, + marker: None, } } @@ -458,12 +488,16 @@ fn resolve_styled_call_target<'a>( pub fn extract_style_from_styled<'a>( ast_builder: &AstBuilder<'a>, expression: &mut Expression<'a>, - split_filename: Option<&str>, + naming: Naming<'_>, imports: &FxHashMap, attrs: &[Expression<'a>], inherited: Option<&StyledDefinition<'a>>, forward: Option, ) -> StyledExtraction<'a> { + let Naming { + split_filename, + marker, + } = naming; let forward = combine_forward(inherited.and_then(|i| i.forward.as_ref()), forward); let mut reads = inherited.map_or_else(Reads::default, |inherited| inherited.reads.clone()); for attr in attrs { @@ -547,12 +581,16 @@ pub fn extract_style_from_styled<'a>( })); let class_name = merge_expression_for_class_name( ast_builder, - clone_all(ast_builder, &classes).chain(gen_class_names( - ast_builder, - &mut props_styles, - None, - split_filename, - )), + marker + .map(|marker| marker_class(ast_builder, marker)) + .into_iter() + .chain(clone_all(ast_builder, &classes)) + .chain(gen_class_names( + ast_builder, + &mut props_styles, + None, + split_filename, + )), ); let tag = Some(Expression::new_string_literal( SPAN, @@ -567,8 +605,9 @@ pub fn extract_style_from_styled<'a>( &gen_styles(ast_builder, &props_styles, None), &base.withheld(&reads, forward.as_ref()), ); - let definition = + let mut definition = base.definition(ast_builder, classes, &props_styles, &attrs, reads, forward); + definition.marker = marker.map(str::to_string); let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); @@ -634,12 +673,16 @@ pub fn extract_style_from_styled<'a>( let class_name = merge_expression_for_class_name( ast_builder, - clone_all(ast_builder, &classes).chain(gen_class_names( - ast_builder, - &mut styles, - None, - split_filename, - )), + marker + .map(|marker| marker_class(ast_builder, marker)) + .into_iter() + .chain(clone_all(ast_builder, &classes)) + .chain(gen_class_names( + ast_builder, + &mut styles, + None, + split_filename, + )), ); let component = create_styled_component( ast_builder, @@ -648,7 +691,8 @@ pub fn extract_style_from_styled<'a>( &gen_styles(ast_builder, &styles, None), &base.withheld(&reads, forward.as_ref()), ); - let definition = base.definition(ast_builder, classes, &styles, &attrs, reads, forward); + let mut definition = base.definition(ast_builder, classes, &styles, &attrs, reads, forward); + definition.marker = marker.map(str::to_string); let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index b26968d94..ac1dcbde6 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -21480,6 +21480,32 @@ export const App = () => ;", + "const Child = styled.div({ color: 'red' });\nexport const Parent = styled.div({ [Child]: { color: 'green' }, [`&:hover ${Child}`]: { color: 'blue' } });", + "const Child = styled.div({ color: 'red' });\nconst Wide = styled(Child)({ width: 1 });\nexport const Parent = styled.div`${Child} { color: green; }`;\nexport const App = () => <>;", + "const Child = styled.div`color: red;`;\nexport const Parent = styled.div`${Child} { color: green; }`;\nexport const C = () => ;", + "const notStyled = 'x';\nexport const Parent = styled.div`${notStyled} { color: green; }`;", + "export const Parent = styled.div`a, :is(b, c) d { color: green; }`;", + ]; + let outputs: Vec = codes + .iter() + .map(|code| { + reset_class_map(); + reset_file_map(); + match extract("test.tsx", &format!("{import}{code}"), emotion_option()) { + Ok(output) => format!("{}\n{:?}", output.code, output.styles), + Err(error) => error.to_string(), + } + }) + .collect(); + assert_debug_snapshot!(outputs); + } + #[test] #[serial] fn test_emotion_class_names_reports_what_it_cannot_compile() { diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_component_selectors.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_component_selectors.snap new file mode 100644 index 000000000..7193e2389 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_component_selectors.snap @@ -0,0 +1,12 @@ +--- +source: libs/extractor/src/lib.rs +expression: outputs +--- +[ + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Child = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"c d\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = () => ;\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& .a\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: Some(Selector(\"&:hover .a,& .a + a\")), style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Child = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"c d\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: Some(Selector(\"&:hover .a\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& .a\")), style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Child = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nconst Wide = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a c b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"d\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = () => <>;\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& .a\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"width\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Child = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a b\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = () =>
;\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& .a\")), style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst notStyled = \"x\";\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& x\")), style_order: None, layer: None })}", + "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const Parent = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n\n{Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: Some(Selector(\"& a,&:is(b,c) d\")), style_order: None, layer: None })}", +] diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 0f1618d8b..e908704c0 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -9,7 +9,7 @@ use oxc_ast_visit::{VisitMut, walk_mut}; use oxc_semantic::Scoping; use oxc_span::SPAN; use oxc_syntax::symbol::SymbolId; -use rustc_hash::FxHashMap; +use rustc_hash::{FxHashMap, FxHashSet}; use crate::ExtractStyleValue; @@ -22,6 +22,9 @@ pub enum StyleValue { Class(String, Option>), /// A name `keyframes()` gives Keyframes(String), + /// The selector of a styled component other styles select, read in CSS + /// text and rule keys alike + Component(String), } #[derive(Default)] @@ -67,6 +70,10 @@ impl StyleValues { self.values.insert(symbol, value); } + pub fn remove(&mut self, symbol: SymbolId) { + self.values.remove(&symbol); + } + /// The binding `expression` reads, when it reads one pub fn symbol(&self, expression: &Expression<'_>) -> Option { let Expression::Identifier(identifier) = expression else { @@ -134,19 +141,89 @@ impl StyleValues { values: &self.values, in_text: false, in_rules: false, + in_key: false, }) } } +/// The bindings styles read as selectors: interpolated in CSS text where a +/// value cannot stand, or as the computed key of a rule object +pub fn selected(program: &oxc_ast::ast::Program<'_>, values: &StyleValues) -> FxHashSet { + let mut selected = Selected { + values, + symbols: FxHashSet::default(), + }; + oxc_ast_visit::Visit::visit_program(&mut selected, program); + selected.symbols +} + +struct Selected<'s> { + values: &'s StyleValues, + symbols: FxHashSet, +} + +impl<'a> oxc_ast_visit::Visit<'a> for Selected<'_> { + fn visit_tagged_template_expression( + &mut self, + it: &oxc_ast::ast::TaggedTemplateExpression<'a>, + ) { + let quasi = &it.quasi; + let mut text = String::new(); + for (index, expression) in quasi.expressions.iter().enumerate() { + text.push_str(quasi.quasis[index].value.raw.as_str()); + if matches!( + crate::css_utils::interpolation_place(&text, &quasi.quasis[index + 1..]), + crate::css_utils::Place::Other + ) { + self.symbols.extend(self.values.symbol(expression)); + } + } + oxc_ast_visit::walk::walk_tagged_template_expression(self, it); + } + + fn visit_object_property(&mut self, it: &oxc_ast::ast::ObjectProperty<'a>) { + if it.computed + && let Some(key) = it.key.as_expression() + { + self.symbols.extend(self.values.symbol(key)); + } + oxc_ast_visit::walk::walk_object_property(self, it); + } +} + struct Reads<'s, 'a> { ast: &'s AstBuilder<'a>, scoping: &'s Scoping, values: &'s FxHashMap, in_text: bool, in_rules: bool, + /// Inside the key of a rule, which a component is read in as its selector + in_key: bool, } impl<'a> VisitMut<'a> for Reads<'_, 'a> { + fn visit_property_key(&mut self, it: &mut oxc_ast::ast::PropertyKey<'a>) { + // A component standing for the whole key selects it within the rule + if let Some(Expression::Identifier(identifier)) = it.as_expression() + && let Some(StyleValue::Component(selector)) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| self.values.get(&symbol)) + { + *it = oxc_ast::ast::PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed( + SPAN, + Str::from_in(format!("& {selector}").as_str(), self.ast.allocator()), + None, + self.ast, + )); + return; + } + let outer = std::mem::replace(&mut self.in_key, true); + walk_mut::walk_property_key(self, it); + self.in_key = outer; + } + fn visit_expression(&mut self, it: &mut Expression<'a>) { if let Expression::Identifier(identifier) = it && let Some(value) = identifier @@ -157,7 +234,11 @@ impl<'a> VisitMut<'a> for Reads<'_, 'a> { { let value = match value { StyleValue::Class(..) if self.in_text => return, - StyleValue::Class(value, _) | StyleValue::Keyframes(value) => value, + // Elsewhere it is the component itself + StyleValue::Component(_) if !self.in_text && !self.in_key => return, + StyleValue::Class(value, _) + | StyleValue::Keyframes(value) + | StyleValue::Component(value) => value, }; *it = Expression::new_string_literal( SPAN, diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index a74a216aa..f78d276d3 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -26,8 +26,9 @@ use crate::extractor::{ }, extract_style_from_jsx::extract_style_from_jsx, extract_style_from_styled::{ - FORWARD_REF, StyledDefinition, StyledExtraction, extended, extract_style_from_styled, - forward_ref, read_forward, take_styled_modifiers, with_component, + FORWARD_REF, Naming, StyledDefinition, StyledExtraction, extended, + extract_style_from_styled, forward_ref, read_forward, take_styled_modifiers, + with_component, }, }; use crate::gen_class_name::{gen_class_names, merge_expression_for_class_name}; @@ -249,6 +250,11 @@ pub struct DevupVisitor<'a> { /// The styled component just built, by where it starts, for the `const` /// it initializes pending_styled: Option<(u32, StyledDefinition<'a>)>, + /// The styled bindings other styles select + selected_components: FxHashSet, + /// The marker class of the selected styled component the declaration + /// being visited defines, with where its definition starts + pending_marker: Option<(u32, String)>, /// Whether a generated styled component forwards refs through React's /// `forwardRef`, which the program then imports forwards_refs: bool, @@ -755,6 +761,8 @@ impl<'a> DevupVisitor<'a> { style_values: crate::style_values::StyleValues::default(), css_styles: None, pending_styled: None, + selected_components: FxHashSet::default(), + pending_marker: None, forwards_refs: false, styled_definitions: FxHashMap::default(), imported_css: FxHashMap::default(), @@ -1289,12 +1297,22 @@ impl<'a> DevupVisitor<'a> { .filter(|_| renders) .and_then(StyledDefinition::inline) .map(|(tag, styles)| (tag.to_string(), clone(styles))); + let marker = definition.and_then(StyledDefinition::marker).map(|marker| { + ExtractStyleProp::Expression { + expression: Expression::new_string_literal( + SPAN, + Str::from_in(marker, allocator), + None, + &self.ast, + ), + styles: vec![], + } + }); let own = definition.map(|definition| clone(definition.styles())); if let Some((tag, styles)) = inline { - return Some(( - Some(tag), - self.compose_css_prop(element, css, styles, class_name), - )); + let mut props = self.compose_css_prop(element, css, styles, class_name); + props.extend(marker); + return Some((Some(tag), props)); } let offset = css.offset; let props = self.compose_css_prop(element, css, vec![], class_name); @@ -2262,6 +2280,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ); self.style_values .import(std::mem::take(&mut self.imported_css)); + self.selected_components = crate::style_values::selected(it, &self.style_values); } walk_program(self, it); if !self.compiled_names.is_empty() { @@ -2415,6 +2434,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { .and_then(|symbol| self.styled_definitions.get(&symbol)) .filter(|definition| definition.extendable()); let start = it.span().start; + let marker = self + .pending_marker + .take_if(|(at, _)| *at == start) + .map(|(_, marker)| marker); let StyledExtraction { result, expression, @@ -2423,7 +2446,10 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } = extract_style_from_styled( &self.ast, it, - self.split_filename.as_deref(), + Naming { + split_filename: self.split_filename.as_deref(), + marker: marker.as_deref(), + }, &self.imports, &attrs, inherited, @@ -3494,8 +3520,29 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.local_styles.insert(symbol); } + let marked = styled_binding + .filter(|symbol| self.selected_components.contains(symbol)) + .zip(start) + .zip(it.id.get_binding_identifier().map(|id| id.name.to_string())); + if let Some(((symbol, start), name)) = &marked { + let (symbol, start) = (*symbol, *start); + let marker = css::component_marker(name, &self.filename); + self.style_values.insert( + symbol, + crate::style_values::StyleValue::Component(format!(".{marker}")), + ); + self.pending_marker = Some((start, marker)); + } + walk_variable_declarator(self, it); + // A binding selected but not to a styled component stays unread + if let Some(((symbol, _), _)) = marked + && self.pending_marker.take().is_some() + { + self.style_values.remove(symbol); + } + if let Some((at, definition)) = self.pending_styled.take() && Some(at) == start && let Some(symbol) = styled_binding From b312482b45f8d08bdb262eb20325ad051f980d85 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 16 Oct 2026 20:13:29 +0900 Subject: [PATCH 5/5] test(extractor): cover unsupported numeric unary class guard Refs #682 #689 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/class_names_numeric_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/extractor/src/class_names_numeric_tests.rs b/libs/extractor/src/class_names_numeric_tests.rs index 7af679077..83b5d9a02 100644 --- a/libs/extractor/src/class_names_numeric_tests.rs +++ b/libs/extractor/src/class_names_numeric_tests.rs @@ -157,6 +157,7 @@ fn class_names_numeric_arguments_keep_unknown_call_guards(#[case] expression: &s #[case("cx(-'2')")] #[case("cx(+true)")] #[case("cx(-false)")] +#[case("cx(~1)")] #[case("cx(-on)")] #[case("cx(+on)")] #[case("cx(-getClass())")]