From d18499873c635427577108fd763e62d3e220b300 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Fri, 10 Apr 2026 16:07:56 +0200 Subject: [PATCH] fix(emotion): handle keyframes labels separately --- .changeset/mean-bulldogs-pretend.md | 5 + .../__tests__/fixtures/keyframes-input.js | 15 ++ .../fixtures/keyframes-namespace-input.js | 15 ++ packages/emotion/__tests__/wasm.test.ts | 153 ++++++++++-------- packages/emotion/transform/src/import_map.rs | 2 +- packages/emotion/transform/src/lib.rs | 92 ++++++----- .../tests/fixture/issues/607/input.tsx | 19 +++ .../tests/fixture/issues/607/output.ts | 3 + 8 files changed, 199 insertions(+), 105 deletions(-) create mode 100644 .changeset/mean-bulldogs-pretend.md create mode 100644 packages/emotion/__tests__/fixtures/keyframes-input.js create mode 100644 packages/emotion/__tests__/fixtures/keyframes-namespace-input.js create mode 100644 packages/emotion/transform/tests/fixture/issues/607/input.tsx create mode 100644 packages/emotion/transform/tests/fixture/issues/607/output.ts diff --git a/.changeset/mean-bulldogs-pretend.md b/.changeset/mean-bulldogs-pretend.md new file mode 100644 index 000000000..e05383198 --- /dev/null +++ b/.changeset/mean-bulldogs-pretend.md @@ -0,0 +1,5 @@ +--- +"@swc/plugin-emotion": patch +--- + +Fix the emotion `keyframes` auto-label regression so generated animations keep the plain name string instead of receiving a `label:` CSS fragment. diff --git a/packages/emotion/__tests__/fixtures/keyframes-input.js b/packages/emotion/__tests__/fixtures/keyframes-input.js new file mode 100644 index 000000000..43e82cd7d --- /dev/null +++ b/packages/emotion/__tests__/fixtures/keyframes-input.js @@ -0,0 +1,15 @@ +import { keyframes } from "@emotion/react"; + +export const pulse = keyframes` + 0% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +`; diff --git a/packages/emotion/__tests__/fixtures/keyframes-namespace-input.js b/packages/emotion/__tests__/fixtures/keyframes-namespace-input.js new file mode 100644 index 000000000..46f699be2 --- /dev/null +++ b/packages/emotion/__tests__/fixtures/keyframes-namespace-input.js @@ -0,0 +1,15 @@ +import * as emotionReact from "@emotion/react"; + +export const pulse = emotionReact.keyframes` + 0% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +`; diff --git a/packages/emotion/__tests__/wasm.test.ts b/packages/emotion/__tests__/wasm.test.ts index 23a95fecd..b0555b450 100644 --- a/packages/emotion/__tests__/wasm.test.ts +++ b/packages/emotion/__tests__/wasm.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { expect, test } from "vitest"; import path from "node:path"; import fs from "node:fs/promises"; import url from "node:url"; @@ -10,6 +10,33 @@ const pluginPath = path.join( "..", pluginName, ); +const fixtureDir = path.resolve( + url.fileURLToPath(import.meta.url), + "..", + "fixtures", +); + +async function readFixture(name: string) { + return fs.readFile(path.resolve(fixtureDir, name), "utf-8"); +} + +async function transformWithEmotion( + code: string, + pluginOptions: Record = {}, + envName?: string, +) { + return transform(code, { + envName, + jsc: { + parser: { + syntax: "ecmascript", + }, + experimental: { + plugins: [[pluginPath, pluginOptions]], + }, + }, + }); +} const options: Options = { jsc: { @@ -17,57 +44,22 @@ const options: Options = { syntax: "ecmascript", }, experimental: { - plugins: [ - [ - pluginPath, - {}, - ], - ], + plugins: [[pluginPath, {}]], }, }, }; test("Should transform emotion css correctly", async () => { - const code = await fs.readFile( - path.resolve( - url.fileURLToPath(import.meta.url), - "..", - "fixtures", - "input.js", - ), - "utf-8", - ); + const code = await readFixture("input.js"); const output = await transform(code, options); expect(output.code).toMatchSnapshot(); }); test("Should add label to css tagged template when autoLabel is 'always'", async () => { - const code = await fs.readFile( - path.resolve( - url.fileURLToPath(import.meta.url), - "..", - "fixtures", - "auto-label-input.js", - ), - "utf-8", - ); - const output = await transform(code, { - jsc: { - parser: { - syntax: "ecmascript", - }, - experimental: { - plugins: [ - [ - pluginPath, - { - autoLabel: "always", - sourceMap: false, - }, - ], - ], - }, - }, + const code = await readFixture("auto-label-input.js"); + const output = await transformWithEmotion(code, { + autoLabel: "always", + sourceMap: false, }); expect(output.code).toMatchSnapshot(); // Verify labels are added with the correct "label:" prefix @@ -76,33 +68,58 @@ test("Should add label to css tagged template when autoLabel is 'always'", async }); test("Should not add label to css tagged template when autoLabel is 'never'", async () => { - const code = await fs.readFile( - path.resolve( - url.fileURLToPath(import.meta.url), - "..", - "fixtures", - "auto-label-input.js", - ), - "utf-8", - ); - const output = await transform(code, { - jsc: { - parser: { - syntax: "ecmascript", - }, - experimental: { - plugins: [ - [ - pluginPath, - { - autoLabel: "never", - sourceMap: false, - }, - ], - ], - }, - }, + const code = await readFixture("auto-label-input.js"); + const output = await transformWithEmotion(code, { + autoLabel: "never", + sourceMap: false, }); expect(output.code).not.toContain('"label:testCls"'); expect(output.code).not.toContain('"label:anotherStyle"'); }); + +test("Should keep plain keyframes label when sourceMap is enabled", async () => { + const code = await readFixture("keyframes-input.js"); + const output = await transformWithEmotion( + code, + { + autoLabel: "always", + sourceMap: true, + }, + "development", + ); + + expect(output.code).toContain( + 'keyframes("0%{opacity:1;}50%{opacity:0.5;}100%{opacity:1;}", "pulse", "/*# sourceMappingURL=', + ); + expect(output.code).not.toContain("label:pulse"); +}); + +test("Should keep plain keyframes label when sourceMap is disabled", async () => { + const code = await readFixture("keyframes-input.js"); + const output = await transformWithEmotion(code, { + autoLabel: "always", + sourceMap: false, + }); + + expect(output.code).toContain( + 'keyframes("0%{opacity:1;}50%{opacity:0.5;}100%{opacity:1;}", "pulse");', + ); + expect(output.code).not.toContain("label:pulse"); +}); + +test("Should keep plain keyframes label for namespace imports", async () => { + const code = await readFixture("keyframes-namespace-input.js"); + const output = await transformWithEmotion( + code, + { + autoLabel: "always", + sourceMap: true, + }, + "development", + ); + + expect(output.code).toContain( + 'emotionReact.keyframes("0%{opacity:1;}50%{opacity:0.5;}100%{opacity:1;}", "pulse", "/*# sourceMappingURL=', + ); + expect(output.code).not.toContain("label:pulse"); +}); diff --git a/packages/emotion/transform/src/import_map.rs b/packages/emotion/transform/src/import_map.rs index b3d32c320..1090c75df 100644 --- a/packages/emotion/transform/src/import_map.rs +++ b/packages/emotion/transform/src/import_map.rs @@ -48,7 +48,7 @@ static EMOTION_OFFICIAL_LIBRARIES: Lazy>> = Lazy::n }, ExportItem { name: "keyframes".to_owned(), - kind: ExprKind::Css, + kind: ExprKind::Keyframes, }, ExportItem { name: "Global".to_owned(), diff --git a/packages/emotion/transform/src/lib.rs b/packages/emotion/transform/src/lib.rs index d1349306f..d3c03553f 100644 --- a/packages/emotion/transform/src/lib.rs +++ b/packages/emotion/transform/src/lib.rs @@ -102,6 +102,7 @@ enum ImportType { enum ExprKind { #[default] Css, + Keyframes, Styled, GlobalJSX, } @@ -244,12 +245,25 @@ impl<'a, C: Comments> EmotionTransformer<'a, C> { label } - fn create_tagged_tpl_label_arg(&self) -> ExprOrSpread { - let mut label = self.create_label(true); - if !label.is_empty() && self.options.sourcemap.unwrap_or(false) && !label.ends_with(';') { + fn create_runtime_label(&self, kind: ExprKind, terminate_before_sourcemap: bool) -> String { + let mut label = self.create_label(matches!(kind, ExprKind::Css)); + if terminate_before_sourcemap + && matches!(kind, ExprKind::Css) + && !label.is_empty() + && self.options.sourcemap.unwrap_or(false) + && !label.ends_with(';') + { label.push(';'); } - label.as_arg() + label + } + + fn create_call_label_arg(&self, kind: ExprKind) -> ExprOrSpread { + self.create_runtime_label(kind, false).as_arg() + } + + fn create_tagged_tpl_label_arg(&self, kind: ExprKind) -> ExprOrSpread { + self.create_runtime_label(kind, true).as_arg() } fn create_sourcemap(&mut self, pos: BytePos) -> Option { @@ -560,15 +574,17 @@ impl Fold for EmotionTransformer<'_, C> { } if let Callee::Expr(e) = &mut expr.callee { match e.as_mut() { - // css({}) + // css({}) / keyframes(...) Expr::Ident(i) => { if let Some(package) = self.import_packages.get(&i.to_id()) { if !expr.args.is_empty() { if let PackageMeta::Named(kind) = package { - if matches!(kind, ExprKind::Css) && !self.in_jsx_element { + if matches!(*kind, ExprKind::Css | ExprKind::Keyframes) + && !self.in_jsx_element + { self.comments.add_pure_comment(expr.span.lo()); if self.options.auto_label.unwrap_or(false) { - expr.args.push(self.create_label(true).as_arg()); + expr.args.push(self.create_call_label_arg(*kind)); } if let Some(cm) = self.create_sourcemap(expr.span.lo) { expr.args.push(cm.as_arg()); @@ -651,6 +667,7 @@ impl Fold for EmotionTransformer<'_, C> { } // styled.div({}) // customEmotionReact.css({}) + // customEmotionReact.keyframes({}) Expr::Member(m) => { if let Expr::Ident(i) = m.obj.as_ref() { if let Some(package) = self.import_packages.get(&i.to_id()) { @@ -702,13 +719,14 @@ impl Fold for EmotionTransformer<'_, C> { } } if let PackageMeta::Namespace(c) = package { - if c.exported_names + if let Some(kind) = c + .exported_names .iter() - .any(|n| match_css_export(n, &m.prop)) + .find_map(|item| match_runtime_export(item, &m.prop)) { self.comments.add_pure_comment(expr.span.lo()); if self.options.auto_label.unwrap_or(false) { - expr.args.push(self.create_label(true).as_arg()); + expr.args.push(self.create_call_label_arg(kind)); } if let Some(cm) = self.create_sourcemap(expr.span.lo()) { expr.args.push(cm.as_arg()); @@ -840,30 +858,31 @@ impl Fold for EmotionTransformer<'_, C> { } } } - // css`` + // css`` / keyframes`` Expr::Ident(i) => { - if let Some(PackageMeta::Named(ExprKind::Css)) = - self.import_packages.get(&i.to_id()) - { - let mut args = self.create_args_from_tagged_tpl(&mut tagged_tpl.tpl); - if !self.in_jsx_element { - self.comments.add_pure_comment(i.span.lo()); - if self.options.auto_label.unwrap_or(false) { - args.push(self.create_tagged_tpl_label_arg()); - } - if let Some(cm) = self.create_sourcemap(tagged_tpl.span.lo()) { - args.push(cm.as_arg()); + if let Some(PackageMeta::Named(kind)) = self.import_packages.get(&i.to_id()) { + if matches!(*kind, ExprKind::Css | ExprKind::Keyframes) { + let mut args = self.create_args_from_tagged_tpl(&mut tagged_tpl.tpl); + if !self.in_jsx_element { + self.comments.add_pure_comment(i.span.lo()); + if self.options.auto_label.unwrap_or(false) { + args.push(self.create_tagged_tpl_label_arg(*kind)); + } + if let Some(cm) = self.create_sourcemap(tagged_tpl.span.lo()) { + args.push(cm.as_arg()); + } } + return Expr::Call(CallExpr { + callee: i.take().as_callee(), + args, + ..Default::default() + }); } - return Expr::Call(CallExpr { - callee: i.take().as_callee(), - args, - ..Default::default() - }); } } // styled.div`` // customEmotionReact.css`` + // customEmotionReact.keyframes`` Expr::Member(member_expr) => { if let Expr::Ident(i) = member_expr.obj.as_mut() { if let Some(p) = self.import_packages.get(&i.to_id()) { @@ -913,10 +932,9 @@ impl Fold for EmotionTransformer<'_, C> { } } PackageMeta::Namespace(c) => { - if c.exported_names - .iter() - .any(|item| match_css_export(item, &member_expr.prop)) - { + if let Some(kind) = c.exported_names.iter().find_map(|item| { + match_runtime_export(item, &member_expr.prop) + }) { self.comments.add_pure_comment(member_expr.span.lo()); return Expr::Call(CallExpr { callee: member_expr.take().as_callee(), @@ -925,7 +943,9 @@ impl Fold for EmotionTransformer<'_, C> { &mut tagged_tpl.tpl, ); if self.options.auto_label.unwrap_or(false) { - args.push(self.create_tagged_tpl_label_arg()); + args.push( + self.create_tagged_tpl_label_arg(kind), + ); } if let Some(cm) = self.create_sourcemap(tagged_tpl.span.lo()) @@ -1046,15 +1066,15 @@ impl Fold for EmotionTransformer<'_, C> { } } -fn match_css_export(item: &ExportItem, prop: &MemberProp) -> bool { - if matches!(item.kind, ExprKind::Css) { +fn match_runtime_export(item: &ExportItem, prop: &MemberProp) -> Option { + if matches!(item.kind, ExprKind::Css | ExprKind::Keyframes) { if let MemberProp::Ident(prop) = prop { if item.name.as_str() == prop.sym.as_ref() { - return true; + return Some(item.kind); } } } - false + None } #[inline] diff --git a/packages/emotion/transform/tests/fixture/issues/607/input.tsx b/packages/emotion/transform/tests/fixture/issues/607/input.tsx new file mode 100644 index 000000000..8fccdce40 --- /dev/null +++ b/packages/emotion/transform/tests/fixture/issues/607/input.tsx @@ -0,0 +1,19 @@ +import { css, keyframes } from "@emotion/react"; + +const pulse = keyframes` + 0% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } + + 100% { + opacity: 1; + } +`; + +const className = css` + animation: ${pulse} 1s infinite; +`; diff --git a/packages/emotion/transform/tests/fixture/issues/607/output.ts b/packages/emotion/transform/tests/fixture/issues/607/output.ts new file mode 100644 index 000000000..3c4a5f70b --- /dev/null +++ b/packages/emotion/transform/tests/fixture/issues/607/output.ts @@ -0,0 +1,3 @@ +import { css, keyframes } from "@emotion/react"; +const pulse = /*#__PURE__*/ keyframes("0%{opacity:1;}50%{opacity:0.5;}100%{opacity:1;}", "pulse", "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5wdXQudHMiLCJzb3VyY2VzIjpbImlucHV0LnRzIl0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IGNzcywga2V5ZnJhbWVzIH0gZnJvbSBcIkBlbW90aW9uL3JlYWN0XCI7XG5cbmNvbnN0IHB1bHNlID0ga2V5ZnJhbWVzYFxuICAwJSB7XG4gICAgb3BhY2l0eTogMTtcbiAgfVxuXG4gIDUwJSB7XG4gICAgb3BhY2l0eTogMC41O1xuICB9XG5cbiAgMTAwJSB7XG4gICAgb3BhY2l0eTogMTtcbiAgfVxuYDtcblxuY29uc3QgY2xhc3NOYW1lID0gY3NzYFxuICBhbmltYXRpb246ICR7cHVsc2V9IDFzIGluZmluaXRlO1xuYDtcbiJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFFYyJ9 */"); +const className = /*#__PURE__*/ css("animation:", pulse, " 1s infinite;", "label:className;", "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5wdXQudHMiLCJzb3VyY2VzIjpbImlucHV0LnRzIl0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IGNzcywga2V5ZnJhbWVzIH0gZnJvbSBcIkBlbW90aW9uL3JlYWN0XCI7XG5cbmNvbnN0IHB1bHNlID0ga2V5ZnJhbWVzYFxuICAwJSB7XG4gICAgb3BhY2l0eTogMTtcbiAgfVxuXG4gIDUwJSB7XG4gICAgb3BhY2l0eTogMC41O1xuICB9XG5cbiAgMTAwJSB7XG4gICAgb3BhY2l0eTogMTtcbiAgfVxuYDtcblxuY29uc3QgY2xhc3NOYW1lID0gY3NzYFxuICBhbmltYXRpb246ICR7cHVsc2V9IDFzIGluZmluaXRlO1xuYDtcbiJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFnQmtCIn0= */");