From 26950a687b768367f9eb0f79e65557db2f5abf95 Mon Sep 17 00:00:00 2001 From: fireairforce <1344492820@qq.com> Date: Tue, 20 Oct 2026 15:22:45 +0800 Subject: [PATCH] fix: preserve UMD externals semantics with utoopack --- src/builder/utils.ts | 19 +-- tests/utoo-pack.config.test.ts | 75 ++++++++++++ tests/utoo-pack.externals.test.ts | 188 ++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 tests/utoo-pack.externals.test.ts diff --git a/src/builder/utils.ts b/src/builder/utils.ts index 49d911dd..e99ba0b6 100644 --- a/src/builder/utils.ts +++ b/src/builder/utils.ts @@ -170,8 +170,16 @@ export function getContentHash(content: string, length = 8) { return createHash('md5').update(content).digest('hex').slice(0, length); } +function toUmdExternal(name: string) { + // Preserve explicitly typed and whitespace-containing values. + if (!name || /\s/.test(name)) return name; + + // Webpack UMD uses the same request for CommonJS and browser globals. + return { root: name, commonjs: name }; +} + /** - * Convert webpack externals configuration to utoopack externals format + * Convert webpack UMD externals configuration to utoopack externals format * @param externals webpack externals configuration * @returns utoopack externals configuration */ @@ -184,15 +192,13 @@ export function convertExternalsToUtooPackExternals( // Handle different webpack externals formats if (typeof externals === 'string') { - // Single string external: "lodash" -> { "lodash": "lodash" } - return { [externals]: externals }; + return { [externals]: toUmdExternal(externals) }; } if (Array.isArray(externals)) { - // Array of externals: ["lodash", "react"] -> { "lodash": "lodash", "react": "react" } return externals.reduce((acc, external) => { if (typeof external === 'string') { - acc[external] = external; + acc[external] = toUmdExternal(external); } else if (typeof external === 'object' && external !== null) { Object.assign(acc, convertExternalsToUtooPackExternals(external)); } @@ -219,8 +225,7 @@ export function convertExternalsToUtooPackExternals( result[key] = value; } } else { - // Simple string mapping: { "react": "React" } - result[key] = value; + result[key] = toUmdExternal(value); } } else if (Array.isArray(value)) { // Array format handling diff --git a/tests/utoo-pack.config.test.ts b/tests/utoo-pack.config.test.ts index dbd976f9..482c200f 100644 --- a/tests/utoo-pack.config.test.ts +++ b/tests/utoo-pack.config.test.ts @@ -1,6 +1,7 @@ import Joi from '@umijs/utils/compiled/@hapi/joi'; import { mergeUtooPackConfig } from '../src/builder/bundle'; import type { IBundleConfig } from '../src/builder/config'; +import { convertExternalsToUtooPackExternals } from '../src/builder/utils'; import { getSchemas } from '../src/features/configPlugins/schema'; test('utoopack: merges user config into father defaults', () => { @@ -57,3 +58,77 @@ test('utoopack: accepts config passthrough in father schema', () => { expect(result.error).toBeUndefined(); }); + +describe('utoopack UMD externals', () => { + test('preserves the external request name for CommonJS and browser consumers', () => { + expect( + convertExternalsToUtooPackExternals({ + react: 'React', + 'react-dom': 'ReactDOM', + }), + ).toEqual({ + react: { root: 'React', commonjs: 'React' }, + 'react-dom': { root: 'ReactDOM', commonjs: 'ReactDOM' }, + }); + }); + + test('supports a single module name', () => { + expect(convertExternalsToUtooPackExternals('react')).toEqual({ + react: { root: 'react', commonjs: 'react' }, + }); + }); + + test('supports mixed arrays without dropping UMD semantics', () => { + expect( + convertExternalsToUtooPackExternals([ + 'react', + { 'react-dom': 'ReactDOM' }, + ]), + ).toEqual({ + react: { root: 'react', commonjs: 'react' }, + 'react-dom': { root: 'ReactDOM', commonjs: 'ReactDOM' }, + }); + }); + + test.each([ + 'commonjs React', + 'commonjs2 React', + 'module react', + 'esm react', + 'global React', + 'var React', + 'window React', + 'promise Promise.resolve(React)', + 'script React@https://example.com/react.js', + ])('preserves an explicitly typed external: %s', (external) => { + expect(convertExternalsToUtooPackExternals({ react: external })).toEqual({ + react: external, + }); + }); + + test('preserves explicit objects and script shorthand', () => { + const externals = { + react: { root: 'React', commonjs: 'react', commonjs2: 'react' }, + jquery: { root: 'jQuery', type: 'global' }, + }; + expect(convertExternalsToUtooPackExternals(externals)).toEqual(externals); + expect( + convertExternalsToUtooPackExternals({ + react: 'React@https://example.com/react.js', + }), + ).toEqual({ react: 'script React@https://example.com/react.js' }); + }); + + test.each(['', 'React .Component', 'React\t.Component'])( + 'leaves empty and whitespace-containing values unchanged: %j', + (external) => { + expect(convertExternalsToUtooPackExternals({ react: external })).toEqual({ + react: external, + }); + }, + ); + + test('keeps absent externals absent', () => { + expect(convertExternalsToUtooPackExternals()).toBeUndefined(); + }); +}); diff --git a/tests/utoo-pack.externals.test.ts b/tests/utoo-pack.externals.test.ts new file mode 100644 index 00000000..20b26e74 --- /dev/null +++ b/tests/utoo-pack.externals.test.ts @@ -0,0 +1,188 @@ +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { URL } from 'url'; +import vm from 'vm'; + +const rootPath = path.resolve(__dirname, '..'); +const fixtures: string[] = []; +const libraryName = 'ExternalsRuntimeFixture'; +const externalName = 'HostDependency'; + +type LoadMode = 'commonjs' | 'umd-loader' | 'browser'; +interface HostDependency { + state: { value: number }; +} +interface Library { + dependency: HostDependency; + namedState: HostDependency['state']; + increment(): number; +} + +function build(bundler: 'webpack' | 'utoopack') { + // Keep the fixture inside rootPath so Pack can read its source and loaders. + const cwd = fs.mkdtempSync(path.join(__dirname, '.tmp-umd-externals-')); + fixtures.push(cwd); + fs.mkdirSync(path.join(cwd, 'src')); + fs.writeFileSync( + path.join(cwd, 'package.json'), + JSON.stringify({ name: 'externals-runtime-fixture', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(cwd, 'src/index.js'), + `import dependency, { state } from 'host-dep'; +export { dependency }; +export const namedState = state; +export function increment() { + dependency.state.value += 1; + return dependency.state.value; +} +`, + ); + fs.writeFileSync( + path.join(cwd, '.fatherrc.js'), + `export default ${JSON.stringify({ + umd: { + entry: 'src/index.js', + name: libraryName, + bundler, + rootPath, + output: { path: 'dist', filename: 'externals.min.js' }, + externals: { 'host-dep': externalName }, + }, + })};`, + ); + + // Run the real CLI/service/build pipeline in a child process, loading source + // with tsx so this regression never accidentally tests a stale dist build. + // No bundler mocks or parent-process environment changes are involved. + let output: string; + try { + output = execFileSync( + process.execPath, + [ + '--require', + require.resolve('tsx/cjs'), + '-e', + `require(${JSON.stringify( + path.join(rootPath, 'src/cli/cli.ts'), + )}).run({ args: { _: ['build'], $0: 'node' } }).catch(error => { + console.error(error); + process.exitCode = 1; + });`, + ], + { + cwd, + env: { + ...process.env, + APP_ROOT: cwd, + FATHER_CACHE: 'none', + NODE_ENV: 'production', + TURBO_TASKS_AVAILABLE_PARALLELISM: '2', + }, + encoding: 'utf-8', + stdio: 'pipe', + timeout: 120000, + maxBuffer: 10 * 1024 * 1024, + }, + ); + } catch (error: any) { + throw new Error( + `${bundler} CLI build failed:\n${error.stdout || ''}\n${ + error.stderr || error.message + }`, + ); + } + + const outputFile = path.join(cwd, 'dist/externals.min.js'); + if (!fs.existsSync(outputFile)) { + throw new Error(`${bundler} did not emit its UMD bundle:\n${output}`); + } + return fs.readFileSync(outputFile, 'utf-8'); +} + +function load(code: string, mode: LoadMode, host: HostDependency) { + const required: string[] = []; + const inject = (name: string) => { + required.push(name); + if (name !== externalName) { + throw new Error(`Unexpected external request: ${name}`); + } + return host; + }; + const sandbox: Record = { URL }; + sandbox.self = sandbox; + const context = vm.createContext(sandbox); + let library: Library; + + if (mode === 'browser') { + // No module/exports/require: the UMD must use the host's browser global. + sandbox[externalName] = host; + vm.runInContext(code, context, { timeout: 5000 }); + library = sandbox[libraryName]; + } else { + // A CommonJS host injects require without defining HostDependency globally. + const module = { exports: {} }; + expect(externalName in sandbox).toBe(false); + if (mode === 'commonjs') { + Object.assign(sandbox, { + module, + exports: module.exports, + require: inject, + }); + vm.runInContext(code, context, { timeout: 5000 }); + } else { + // @alipay/umd-loader evaluates UMD with module/exports/require function + // arguments. Exercise that branch without adding a dependency global. + const evaluate = vm.runInContext( + `(function(module, exports, require) {\n${code}\n})`, + context, + { timeout: 5000 }, + ); + evaluate(module, module.exports, inject); + } + expect(externalName in sandbox).toBe(false); + library = module.exports as Library; + } + + return { library, required }; +} + +afterAll(() => { + fixtures.forEach((cwd) => fs.rmSync(cwd, { recursive: true, force: true })); +}); + +// Webpack is a real-build oracle for the public father externals contract; both +// bundlers execute identical source/configuration and the same runtime checks. +describe.each(['webpack', 'utoopack'] as const)( + '%s UMD externals runtime', + (bundler) => { + let code: string; + beforeAll(() => { + code = build(bundler); + }); + + test.each(['commonjs', 'umd-loader', 'browser'])( + 'uses the supplied dependency singleton in %s', + (mode) => { + const host = { state: { value: 41 } }; + const { library, required } = load(code, mode, host); + + expect(Object.keys(library).sort()).toEqual([ + 'dependency', + 'increment', + 'namedState', + ]); + expect(library.dependency).toBe(host); + expect(library.namedState).toBe(host.state); + expect(library.increment()).toBe(42); + expect(host.state.value).toBe(42); + host.state.value = 50; + expect(library.increment()).toBe(51); + + if (mode === 'browser') expect(required).toEqual([]); + else expect(new Set(required)).toEqual(new Set([externalName])); + }, + ); + }, +);