From 441db7f03d045b7e33a5dc1443d0732936ea9245 Mon Sep 17 00:00:00 2001 From: fireairforce <1344492820@qq.com> Date: Tue, 13 Oct 2026 23:23:29 +0800 Subject: [PATCH 1/2] fix: preserve browser targets and transforms in utoopack builds --- src/builder/bundle/index.ts | 10 +- src/builder/bundle/utooCompat.ts | 171 +++++++++++++++++++ tests/utoo-pack.config.test.ts | 279 ++++++++++++++++++++++++++++++- 3 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 src/builder/bundle/utooCompat.ts diff --git a/src/builder/bundle/index.ts b/src/builder/bundle/index.ts index 23ba2d2d..c6aeccc4 100644 --- a/src/builder/bundle/index.ts +++ b/src/builder/bundle/index.ts @@ -13,6 +13,7 @@ import { getBundleTargets, getSWCTransformReactOpts, } from '../utils'; +import { getUtooPackCompatibility } from './utooCompat'; const webpackBundler: typeof import('@umijs/bundler-webpack') = importLazy( path.dirname(require.resolve('@umijs/bundler-webpack/package.json')), @@ -260,9 +261,14 @@ async function bundle(opts: IBundleOpts): Promise { opts.configProvider.pkg, opts.cwd, ); + const compatibility = getUtooPackCompatibility(config, { + cwd: opts.cwd, + development: Boolean(opts.watch), + }); const utooPackOpts: BundleOptions = { config: mergeUtooPackConfig( { + ...compatibility, entry: [ { name: entryName, @@ -282,9 +288,11 @@ async function bundle(opts: IBundleOpts): Promise { define: config.define, react: { runtime: reactOpts.runtime, - importSource: reactOpts.importSource, + // Emotion must not implicitly switch the JSX runtime. + importSource: reactOpts.importSource ?? 'react', }, styles: { + ...compatibility.styles, ...(config.extractCSS !== false ? {} : { inlineCss: {} }), }, output: { diff --git a/src/builder/bundle/utooCompat.ts b/src/builder/bundle/utooCompat.ts new file mode 100644 index 00000000..da91cc73 --- /dev/null +++ b/src/builder/bundle/utooCompat.ts @@ -0,0 +1,171 @@ +import type { BundleOptions } from '@utoo/pack'; +import path from 'path'; +import type { IBundleConfig } from '../config'; +import { getBundleTargets } from '../utils'; + +type UtooPackConfig = BundleOptions['config']; + +export function getUtooPackCompatibility( + config: IBundleConfig, + { cwd, development }: { cwd: string; development: boolean }, +): Partial { + const compatibility: Partial = {}; + if (config.platform !== 'node') { + compatibility.target = Object.entries(getBundleTargets(config)) + .map( + ([browser, version]) => + `${browser === 'ios' ? 'ios_saf' : browser} ${version}`, + ) + .join(', '); + } + + for (const plugin of (config.extraBabelPlugins || []).filter(Boolean)) { + const [name, options = {}] = Array.isArray(plugin) ? plugin : [plugin]; + if ((options as unknown) === false) continue; + if (options === null) { + throw new Error( + `Babel plugin options for ${String( + name, + )} must be an object, false, or undefined.`, + ); + } + if (name === 'babel-plugin-transform-typescript-metadata') { + const ts: typeof import('typescript') = require('typescript'); + // Native transforms use the nearest tsconfig/jsconfig, independent of + // FATHER_TSCONFIG_NAME. TypeScript resolves inherited compiler flags. + const configPath = ['tsconfig.json', 'jsconfig.json'] + .map((file) => ts.findConfigFile(cwd, ts.sys.fileExists, file)) + .filter((file): file is string => Boolean(file)) + .sort((a, b) => path.dirname(b).length - path.dirname(a).length)[0]; + const sourceFile = configPath + ? ts.readJsonConfigFile(configPath, ts.sys.readFile) + : undefined; + const compilerOptions = + configPath && sourceFile + ? ts.parseJsonSourceFileConfigFileContent( + sourceFile, + ts.sys, + path.dirname(configPath), + undefined, + configPath, + ).options + : undefined; + const configFiles = configPath + ? [configPath, ...(sourceFile?.extendedSourceFiles || [])] + : []; + if ( + configFiles.some((file) => + Array.isArray( + ts.readConfigFile(file, ts.sys.readFile).config?.extends, + ), + ) + ) { + throw new Error( + 'utoopack does not support array extends in the tsconfig/jsconfig inheritance chain for babel-plugin-transform-typescript-metadata. Use string extends or the webpack bundler.', + ); + } + if ( + !compilerOptions?.experimentalDecorators || + !compilerOptions.emitDecoratorMetadata + ) { + throw new Error( + 'utoopack requires experimentalDecorators: true and emitDecoratorMetadata: true in tsconfig.json/jsconfig.json for babel-plugin-transform-typescript-metadata. Enable these flags or use the webpack bundler.', + ); + } + continue; + } + if (name !== '@emotion' && name !== '@emotion/babel-plugin') { + throw new Error( + `utoopack does not support extraBabelPlugins entry ${String( + name, + )}. Use the webpack bundler for this Babel plugin.`, + ); + } + + if (options.cssPropOptimization === false) { + throw new Error( + 'utoopack does not support Emotion cssPropOptimization: false. Use the webpack bundler for this Babel plugin option.', + ); + } + const autoLabel = options.autoLabel || 'dev-only'; + if (!['dev-only', 'always', 'never'].includes(autoLabel)) { + throw new Error(`Invalid Emotion autoLabel option: ${String(autoLabel)}`); + } + const emotionOptions = { + ...options, + // SWC currently does not resolve dev-only against the build mode. + autoLabel: + autoLabel === 'dev-only' + ? development + ? 'always' + : 'never' + : autoLabel, + }; + compatibility.styles = { + emotion: { + ...(options.sourceMap === undefined + ? {} + : { sourcemap: options.sourceMap }), + autoLabel: emotionOptions.autoLabel, + ...(options.labelFormat === undefined + ? {} + : { labelFormat: options.labelFormat }), + ...(options.importMap === undefined + ? {} + : { importMap: options.importMap }), + }, + }; + const nativeEmotion = config.utoopack?.styles?.emotion; + if (nativeEmotion === false) continue; + const foreignEmotionOptions: Record = { ...emotionOptions }; + if (typeof nativeEmotion === 'object') { + for (const [nativeOption, babelOption] of [ + ['sourcemap', 'sourceMap'], + ['autoLabel', 'autoLabel'], + ['labelFormat', 'labelFormat'], + ['importMap', 'importMap'], + ] as const) { + if (nativeEmotion[nativeOption] !== undefined) { + foreignEmotionOptions[babelOption] = nativeEmotion[nativeOption]; + } + } + } + // Native Emotion transforms cover project sources. Dependencies need the + // same Babel plugin, without a second TypeScript/decorator transform. + compatibility.module = { + rules: Object.fromEntries( + ['*.js', '*.jsx', '*.mjs', '*.cjs'] + .filter( + (pattern) => !(pattern in (config.utoopack?.module?.rules || {})), + ) + .map((pattern) => [ + pattern, + { + condition: 'foreign', + loaders: [ + { + loader: require.resolve( + '@umijs/bundler-webpack/compiled/babel-loader', + ), + options: { + cwd, + babelrc: false, + configFile: false, + sourceType: 'unambiguous', + parserOpts: { plugins: ['jsx'] }, + plugins: [['@emotion/babel-plugin', foreignEmotionOptions]], + }, + }, + ], + }, + ]), + ), + }; + } + if (config.extraBabelPresets?.filter(Boolean).length) { + throw new Error( + 'utoopack does not support extraBabelPresets. Use the webpack bundler for Babel presets.', + ); + } + return compatibility; +} diff --git a/tests/utoo-pack.config.test.ts b/tests/utoo-pack.config.test.ts index dbd976f9..f1d27fd0 100644 --- a/tests/utoo-pack.config.test.ts +++ b/tests/utoo-pack.config.test.ts @@ -1,7 +1,282 @@ +import { JSMinifier } from '@umijs/bundler-webpack/dist/types'; import Joi from '@umijs/utils/compiled/@hapi/joi'; -import { mergeUtooPackConfig } from '../src/builder/bundle'; -import type { IBundleConfig } from '../src/builder/config'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import bundle, { mergeUtooPackConfig } from '../src/builder/bundle'; +import { getUtooPackCompatibility } from '../src/builder/bundle/utooCompat'; import { getSchemas } from '../src/features/configPlugins/schema'; +import { IFatherBuildTypes } from '../src/types'; + +type IBundleConfig = import('../src/builder/config').IBundleConfig; +type BundleConfigProvider = + import('../src/builder/config').BundleConfigProvider; + +jest.mock('@utoo/pack', () => ({ + build: jest.fn().mockResolvedValue(undefined), +})); + +let metadataRoot: string; +let metadataCwd: string; +beforeAll(() => { + metadataRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'father-metadata-compat-'), + ); + metadataCwd = path.join(metadataRoot, 'project'); + fs.mkdirSync(metadataCwd); + fs.writeFileSync( + path.join(metadataRoot, 'tsconfig.base.json'), + JSON.stringify({ + compilerOptions: { + experimentalDecorators: true, + emitDecoratorMetadata: true, + }, + }), + ); + fs.writeFileSync( + path.join(metadataCwd, 'tsconfig.json'), + JSON.stringify({ extends: '../tsconfig.base.json' }), + ); +}); +afterAll(() => fs.rmSync(metadataRoot, { recursive: true, force: true })); + +function getCompatibility( + overrides: Partial = {}, + development = false, + cwd = path.resolve(__dirname, '..'), +) { + return getUtooPackCompatibility( + { + type: IFatherBuildTypes.BUNDLE, + entry: 'src/index.ts', + output: { path: 'dist', filename: 'index.min.js' }, + jsMinifier: JSMinifier.terser, + ...overrides, + }, + { cwd, development }, + ); +} + +test('utoopack: preserves Father browser targets and excludes node builds', () => { + expect(getCompatibility().target).toBe('ie 11'); + expect(getCompatibility({ targets: {} }).target).toBe('ie 11'); + expect(getCompatibility({ targets: { chrome: 80, ios: 12.1 } }).target).toBe( + 'chrome 80, ios_saf 12.1', + ); + expect( + getCompatibility({ platform: 'node', targets: { node: 18 } }).target, + ).toBeUndefined(); + expect(getCompatibility({ platform: 'node' }).target).toBeUndefined(); +}); + +test.each(['@emotion', '@emotion/babel-plugin'])( + 'utoopack: translates Emotion %s and enabled metadata without a second TS transform', + (plugin) => { + const config = getCompatibility( + { + extraBabelPlugins: [ + [plugin, { sourceMap: false, labelFormat: '[filename]--[local]' }], + 'babel-plugin-transform-typescript-metadata', + ], + }, + false, + metadataCwd, + ); + expect(config.styles?.emotion).toEqual({ + sourcemap: false, + autoLabel: 'never', + labelFormat: '[filename]--[local]', + }); + expect(Object.keys(config.module?.rules || {}).sort()).toEqual([ + '*.cjs', + '*.js', + '*.jsx', + '*.mjs', + ]); + expect(config.module?.rules?.['*.js']).toHaveProperty( + 'condition', + 'foreign', + ); + expect(config.module?.rules?.['*.js']).toHaveProperty( + 'loaders.0.options.plugins', + [ + [ + '@emotion/babel-plugin', + { + sourceMap: false, + autoLabel: 'never', + labelFormat: '[filename]--[local]', + }, + ], + ], + ); + }, +); + +test('utoopack: rejects a metadata plugin when native compiler flags do not emit metadata', () => { + for (const compilerOptions of [ + {}, + { experimentalDecorators: true, emitDecoratorMetadata: false }, + { experimentalDecorators: false, emitDecoratorMetadata: true }, + ]) { + const cwd = fs.mkdtempSync(path.join(metadataRoot, 'disabled-')); + fs.writeFileSync( + path.join(cwd, 'tsconfig.json'), + JSON.stringify({ compilerOptions }), + ); + expect(() => + getCompatibility( + { extraBabelPlugins: ['babel-plugin-transform-typescript-metadata'] }, + false, + cwd, + ), + ).toThrow(/experimentalDecorators.*emitDecoratorMetadata.*webpack/); + } +}); + +test('utoopack: metadata uses the nearest native jsconfig rather than an ancestor tsconfig', () => { + const cwd = path.join(metadataCwd, 'child'); + fs.mkdirSync(cwd); + fs.writeFileSync( + path.join(cwd, 'jsconfig.json'), + JSON.stringify({ + compilerOptions: { + experimentalDecorators: true, + emitDecoratorMetadata: false, + }, + }), + ); + expect(() => + getCompatibility( + { extraBabelPlugins: ['babel-plugin-transform-typescript-metadata'] }, + false, + cwd, + ), + ).toThrow(/emitDecoratorMetadata/); +}); + +test('utoopack: rejects metadata flags inherited through a nested array extends', () => { + const cwd = fs.mkdtempSync(path.join(metadataRoot, 'array-')); + fs.writeFileSync( + path.join(cwd, 'array-base.json'), + JSON.stringify({ extends: ['../tsconfig.base.json'] }), + ); + fs.writeFileSync( + path.join(cwd, 'tsconfig.json'), + JSON.stringify({ extends: './array-base.json' }), + ); + expect(() => + getCompatibility( + { extraBabelPlugins: ['babel-plugin-transform-typescript-metadata'] }, + false, + cwd, + ), + ).toThrow(/array extends.*webpack/); +}); + +test('utoopack: resolves Emotion labels by mode and honors explicit labels', () => { + expect( + getCompatibility({ extraBabelPlugins: ['@emotion'] }, true).styles?.emotion, + ).toEqual(expect.objectContaining({ autoLabel: 'always' })); + expect( + getCompatibility({ + extraBabelPlugins: [['@emotion', { autoLabel: 'always' }]], + }).styles?.emotion, + ).toEqual(expect.objectContaining({ autoLabel: 'always' })); + expect( + getCompatibility( + { extraBabelPlugins: [['@emotion', { autoLabel: 'never' }]] }, + true, + ).styles?.emotion, + ).toEqual(expect.objectContaining({ autoLabel: 'never' })); +}); + +test('utoopack: preserves explicit native overrides and does not merge automatic loaders into user rules', () => { + const overrides = { + target: 'chrome 120', + styles: { emotion: { sourcemap: true, autoLabel: 'always' as const } }, + module: { rules: { '*.js': { loaders: [] }, '*.jsx': ['custom-loader'] } }, + }; + const compatibility = getCompatibility({ + extraBabelPlugins: [['@emotion', { sourceMap: false }]], + utoopack: overrides, + }); + const config = mergeUtooPackConfig( + { + entry: [], + ...compatibility, + react: { runtime: 'automatic', importSource: 'react' }, + }, + overrides, + ); + expect(config.target).toBe('chrome 120'); + expect(config.styles?.emotion).toEqual(overrides.styles.emotion); + expect(config.react?.importSource).toBe('react'); + expect(config.module?.rules?.['*.js']).toEqual({ loaders: [] }); + expect(config.module?.rules?.['*.jsx']).toEqual(['custom-loader']); + expect(config.module?.rules?.['*.mjs']).toHaveProperty( + 'loaders.0.options.plugins', + [['@emotion/babel-plugin', { sourceMap: true, autoLabel: 'always' }]], + ); + expect( + getCompatibility({ + extraBabelPlugins: ['@emotion'], + utoopack: { styles: { emotion: false } }, + }).module, + ).toBeUndefined(); +}); + +test('utoopack: does not silently ignore unsupported Babel configuration', () => { + expect( + getCompatibility({ + extraBabelPlugins: [false, null, undefined, ['@emotion', false]], + }).styles, + ).toBeUndefined(); + expect(() => + getCompatibility({ extraBabelPlugins: [['@emotion', null]] }), + ).toThrow(/options.*object, false, or undefined/); + expect(() => + getCompatibility({ extraBabelPlugins: ['babel-plugin-not-supported'] }), + ).toThrow(/babel-plugin-not-supported.*webpack/); + expect(() => + getCompatibility({ + extraBabelPlugins: [['@emotion', { cssPropOptimization: false }]], + }), + ).toThrow(/cssPropOptimization.*webpack/); + expect(() => + getCompatibility({ extraBabelPresets: ['unsupported-preset'] }), + ).toThrow(/extraBabelPresets.*webpack/); +}); + +test('utoopack: bundle wiring preserves the React JSX runtime when Emotion is enabled', async () => { + const config: IBundleConfig = { + type: IFatherBuildTypes.BUNDLE, + bundler: 'utoopack', + entry: 'src/index.ts', + output: { path: 'dist', filename: 'index.min.js' }, + jsMinifier: JSMinifier.terser, + extraBabelPlugins: ['@emotion'], + }; + await bundle({ + cwd: path.resolve(__dirname, '..'), + configProvider: { + configs: [config], + pkg: { dependencies: { react: '18.2.0' } }, + } as BundleConfigProvider, + }); + const { build } = require('@utoo/pack'); + expect(build).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + target: 'ie 11', + react: { runtime: 'automatic', importSource: 'react' }, + styles: { emotion: { autoLabel: 'never' } }, + }), + }), + path.resolve(__dirname, '..'), + undefined, + ); +}); test('utoopack: merges user config into father defaults', () => { const defaults = { From cd0e12d3b858aa71dd28d5ca260ad84404afef01 Mon Sep 17 00:00:00 2001 From: fireairforce <32598811+fireairforce@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:57:32 +0800 Subject: [PATCH 2/2] fix: preserve native Emotion JSX inference --- src/builder/bundle/index.ts | 9 ++++-- tests/utoo-pack.config.test.ts | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/builder/bundle/index.ts b/src/builder/bundle/index.ts index c6aeccc4..329c46d9 100644 --- a/src/builder/bundle/index.ts +++ b/src/builder/bundle/index.ts @@ -265,6 +265,7 @@ async function bundle(opts: IBundleOpts): Promise { cwd: opts.cwd, development: Boolean(opts.watch), }); + const nativeEmotion = config.utoopack?.styles?.emotion; const utooPackOpts: BundleOptions = { config: mergeUtooPackConfig( { @@ -288,8 +289,12 @@ async function bundle(opts: IBundleOpts): Promise { define: config.define, react: { runtime: reactOpts.runtime, - // Emotion must not implicitly switch the JSX runtime. - importSource: reactOpts.importSource ?? 'react', + // Preserve Pack's JSX inference for explicit native Emotion. + importSource: + reactOpts.importSource ?? + (nativeEmotion === undefined || nativeEmotion === false + ? 'react' + : undefined), }, styles: { ...compatibility.styles, diff --git a/tests/utoo-pack.config.test.ts b/tests/utoo-pack.config.test.ts index f1d27fd0..91f39da2 100644 --- a/tests/utoo-pack.config.test.ts +++ b/tests/utoo-pack.config.test.ts @@ -278,6 +278,59 @@ test('utoopack: bundle wiring preserves the React JSX runtime when Emotion is en ); }); +for (const watch of [false, true]) { + test.each([ + { emotion: true, importSource: undefined, expected: undefined }, + { emotion: {}, importSource: undefined, expected: undefined }, + { + emotion: { autoLabel: 'always' as const }, + importSource: undefined, + expected: undefined, + }, + { emotion: false, importSource: undefined, expected: 'react' }, + { emotion: undefined, importSource: undefined, expected: 'react' }, + { emotion: true, importSource: 'react', expected: 'react' }, + { + emotion: {}, + importSource: '@emotion/react', + expected: '@emotion/react', + }, + { emotion: true, importSource: 'preact', expected: 'preact' }, + ])( + `utoopack: preserves JSX inference and overrides for %p (watch: ${watch})`, + async ({ emotion, importSource, expected }) => { + const config: IBundleConfig = { + type: IFatherBuildTypes.BUNDLE, + bundler: 'utoopack', + entry: 'src/index.ts', + output: { path: 'dist', filename: 'index.min.js' }, + jsMinifier: JSMinifier.terser, + utoopack: { styles: { emotion }, react: { importSource } }, + }; + await bundle({ + cwd: path.resolve(__dirname, '..'), + watch, + configProvider: { + configs: [config], + pkg: { dependencies: { react: '18.2.0' } }, + } as BundleConfigProvider, + }); + const { build } = require('@utoo/pack'); + expect(build).toHaveBeenLastCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + mode: watch ? 'development' : 'production', + react: { runtime: 'automatic', importSource: expected }, + styles: { emotion }, + }), + }), + path.resolve(__dirname, '..'), + undefined, + ); + }, + ); +} + test('utoopack: merges user config into father defaults', () => { const defaults = { entry: [],