From 79653abadb1dd5766974ea62424f3b3de70b3370 Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Mon, 7 Sep 2026 16:23:19 +0800 Subject: [PATCH 1/5] fix: normalize rc dependency default imports in ESM builds --- README.md | 17 +++ README.zh-CN.md | 17 +++ package.json | 3 + src/babelPluginDefaultInterop.ts | 81 ++++++++++++++ src/index.ts | 5 +- test/babelPluginDefaultInterop.test.js | 145 +++++++++++++++++++++++++ 6 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/babelPluginDefaultInterop.ts create mode 100644 test/babelPluginDefaultInterop.test.js diff --git a/README.md b/README.md index a65c5c9..90c234c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,23 @@ export default defineConfig({ ## API +### Default imports in native ESM + +The ESM Babel configuration normalizes default imports from `@rc-component/trigger`, `@rc-component/resize-observer`, and `@rc-component/overflow`. Their Node entries currently expose transpiled CommonJS exports. A small helper is generated in each output file that needs it; component source code keeps ordinary default imports. Native ESM defaults pass through unchanged, so the same output also works when a browser bundler selects these dependencies' ESM entries. + +For native Node ESM with Father 4.6.37 or newer, explicitly select Babel: + +```ts | pure +export default defineConfig({ + plugins: ['@rc-component/father-plugin'], + esm: { platform: 'node', transformer: 'babel', autoExtension: true }, +}); +``` + +Father normally selects esbuild for `platform: 'node'`; esbuild and SWC do not run `extraBabelPlugins`. This plugin does not silently change the chosen transformer. The rule only handles default imports from the three package roots, including `import { default as Name }`; it does not rewrite named imports, namespace imports, type-only imports, re-exports from dependencies, or other packages. CommonJS output keeps Father's normal interop handling. No runtime dependency is added. + +This is a compatibility bridge until these packages and their dependencies provide native ESM entries. The generated code still checks the loaded export at runtime; the compiler cannot assume which entry a downstream resolver will select. + | Option | Description | | --------- | -------------------------------------------------------- | | `plugins` | Register `@rc-component/father-plugin` in father config. | diff --git a/README.zh-CN.md b/README.zh-CN.md index 40c6f46..6b999a6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -40,6 +40,23 @@ export default defineConfig({ ## API +### 原生 ESM 的默认导入 + +ESM 的 Babel 配置会处理 `@rc-component/trigger`、`@rc-component/resize-observer` 和 `@rc-component/overflow` 的默认导入。这些依赖的 Node 入口目前仍提供转译后的 CommonJS 导出。插件只在需要的产物文件里生成兼容函数,组件源码保持普通默认导入。如果浏览器打包器选择依赖的 ESM 入口,正常的默认导出会原样返回。 + +使用 Father 4.6.37 或更高版本构建原生 Node ESM 时,需要显式选择 Babel: + +```ts | pure +export default defineConfig({ + plugins: ['@rc-component/father-plugin'], + esm: { platform: 'node', transformer: 'babel', autoExtension: true }, +}); +``` + +Father 在 `platform: 'node'` 时默认选择 esbuild;esbuild 和 SWC 不执行 `extraBabelPlugins`,本插件也不会隐式切换编译器。规则只处理这三个包根入口的默认导入,包括 `import { default as Name }`,不会改写命名导入、命名空间导入、纯类型导入、依赖的再导出或其他包。CommonJS 产物继续使用 Father 原有的兼容处理,不增加运行时依赖。 + +这是一项过渡措施,待这些包及其依赖提供原生 ESM 入口后可移除。产物仍会在运行时检查导出,编译器无法预先确定下游解析器最终会选择哪个入口。 + | 名称 | 说明 | | --------- | ---------------------------------------------------- | | `plugins` | 在 father 配置中注册 `@rc-component/father-plugin`。 | diff --git a/package.json b/package.json index 49ab66b..dc1400a 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,14 @@ "fs-extra": "^11.3.0" }, "devDependencies": { + "@babel/core": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7", "@commitlint/cli": "^21.2.0", "@commitlint/config-conventional": "^21.2.0", "@eslint/compat": "^2.1.0", "@eslint/js": "^10.0.1", "@rc-component/np": "^1.0.4", + "@types/babel__core": "^7.20.5", "@types/fs-extra": "^11.0.4", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", diff --git a/src/babelPluginDefaultInterop.ts b/src/babelPluginDefaultInterop.ts new file mode 100644 index 0000000..2aeca4b --- /dev/null +++ b/src/babelPluginDefaultInterop.ts @@ -0,0 +1,81 @@ +import type * as Babel from '@babel/core'; + +// TODO: Remove this bridge when these packages and their dependencies expose native ESM entries. +const interopPackages = new Set([ + '@rc-component/trigger', + '@rc-component/resize-observer', + '@rc-component/overflow', +]); + +/** Normalize the default imports of rc dependencies that still expose transpiled CJS to Node. */ +export default function defaultInterop({ + types: t, + template, +}: typeof Babel): Babel.PluginObj { + return { + name: 'rc-component-default-interop', + visitor: { + Program: { + exit(program) { + const helper = + program.scope.generateUidIdentifier('rcDefaultInterop'); + const declarations: Babel.types.VariableDeclarator[] = []; + + // Run after TypeScript has removed imports that are only used as types. + for (const statement of program.get('body')) { + if ( + !statement.isImportDeclaration() || + statement.node.importKind === 'type' || + !interopPackages.has(statement.node.source.value) + ) { + continue; + } + + for (const specifier of statement.node.specifiers) { + if ( + !t.isImportDefaultSpecifier(specifier) && + !( + t.isImportSpecifier(specifier) && + specifier.importKind !== 'type' && + (t.isIdentifier(specifier.imported, { name: 'default' }) || + t.isStringLiteral(specifier.imported, { value: 'default' })) + ) + ) { + continue; + } + + const local = specifier.local; + const imported = program.scope.generateUidIdentifier( + `${local.name}Module`, + ); + specifier.local = imported; + declarations.push( + t.variableDeclarator( + local, + t.callExpression(helper, [t.cloneNode(imported)]), + ), + ); + } + } + + if (!declarations.length) return; + + const normalize = template.statement(` + function %%NAME%%(value) { + return value && typeof value === 'object' && + '__esModule' in value && value.__esModule && 'default' in value + ? value.default : value; + } + `)({ NAME: helper }); + + // Imports are hoisted, so initialize their local aliases before any source statements. + program.unshiftContainer('body', [ + normalize, + t.variableDeclaration('var', declarations), + ]); + program.scope.crawl(); + }, + }, + }, + }; +} diff --git a/src/index.ts b/src/index.ts index b1d78a8..09b4f94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,7 +86,10 @@ export default (api: IApi) => { esm: { output: 'es', // transform all rc-xx/lib to rc-xx/es for esm build - extraBabelPlugins: [require.resolve('./babelPluginImportLib2Es')], + extraBabelPlugins: [ + require.resolve('./babelPluginImportLib2Es'), + require.resolve('./babelPluginDefaultInterop'), + ], }, cjs: { // specific platform to browser, father 4 build cjs for node by default diff --git a/test/babelPluginDefaultInterop.test.js b/test/babelPluginDefaultInterop.test.js new file mode 100644 index 0000000..6bdcfdd --- /dev/null +++ b/test/babelPluginDefaultInterop.test.js @@ -0,0 +1,145 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { afterEach, test } = require('node:test'); +const { pathToFileURL } = require('node:url'); +const { transformSync } = require('@babel/core'); +const interop = require('../dist/babelPluginDefaultInterop').default; +const fatherPlugin = require('../dist').default; + +const fixtures = []; +afterEach(() => { + fixtures + .splice(0) + .forEach((directory) => + fs.rmSync(directory, { recursive: true, force: true }), + ); +}); + +function transform(source, typescript = false) { + return transformSync(source, { + filename: typescript ? 'consumer.ts' : 'consumer.js', + configFile: false, + babelrc: false, + sourceMaps: true, + plugins: [ + interop, + ...(typescript ? [require('@babel/plugin-transform-typescript')] : []), + ], + }); +} + +async function run(source, esm = false) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-interop-')); + fixtures.push(directory); + for (const name of ['trigger', 'resize-observer', 'overflow']) { + const dependency = path.join( + directory, + 'node_modules', + '@rc-component', + name, + ); + fs.mkdirSync(dependency, { recursive: true }); + fs.writeFileSync( + path.join(dependency, 'package.json'), + JSON.stringify({ + name: `@rc-component/${name}`, + type: esm ? 'module' : 'commonjs', + main: 'index.js', + }), + ); + fs.writeFileSync( + path.join(dependency, 'index.js'), + esm + ? `export const marker = '${name}'; export default function component() { return marker; }` + : `Object.defineProperty(exports, '__esModule', { value: true }); + exports.marker = '${name}'; exports.default = function component() { return exports.marker; };`, + ); + } + const output = transform(source); + const entry = path.join(directory, 'consumer.mjs'); + fs.writeFileSync(entry, output.code); + return { module: await import(pathToFileURL(entry).href), ...output }; +} + +for (const esm of [false, true]) { + test(`default imports work with ${esm ? 'native ESM' : 'transpiled CommonJS'} dependencies`, async () => { + const { module, code } = await run( + ` + import Trigger, { marker } from '@rc-component/trigger'; + import { default as ResizeObserver } from '@rc-component/resize-observer'; + import Overflow from '@rc-component/overflow'; + export const result = [Trigger(), ResizeObserver(), Overflow(), marker]; + export { Trigger as default }; + `, + esm, + ); + assert.deepEqual(module.result, [ + 'trigger', + 'resize-observer', + 'overflow', + 'trigger', + ]); + assert.equal(module.default(), 'trigger'); + assert.equal((code.match(/function _rcDefaultInterop/g) || []).length, 1); + }); +} + +test('keeps import hoisting, shadowed bindings, directives, and generated-name collisions', async () => { + const { module, code, map } = await run(` + 'use client'; + export const result = Trigger(); + import Trigger from '@rc-component/trigger'; + const _rcDefaultInterop = 'user helper'; + const _TriggerModule = 'user binding'; + export function shadow(Trigger) { return Trigger; } + export const names = [_rcDefaultInterop, _TriggerModule]; + `); + assert.equal(module.result, 'trigger'); + assert.equal(module.shadow('local'), 'local'); + assert.deepEqual(module.names, ['user helper', 'user binding']); + assert.match(code, /^'use client';/); + assert.deepEqual(map.sources, ['consumer.js']); +}); + +test('does not change named imports, namespace imports, other packages, or rc deep paths', () => { + const { code } = transform(` + import { marker } from '@rc-component/trigger'; + import * as observer from '@rc-component/resize-observer'; + import React from 'react'; + import deep from '@rc-component/overflow/lib'; + export { marker, observer, React, deep }; + `); + assert.doesNotMatch(code, /rcDefaultInterop|__esModule/); +}); + +test('does not introduce runtime imports for TypeScript-only dependencies', () => { + const { code } = transform( + ` + import type Trigger from '@rc-component/trigger'; + import ResizeObserver from '@rc-component/resize-observer'; + import { type default as Overflow } from '@rc-component/overflow'; + export type Values = [Trigger, typeof ResizeObserver, Overflow]; + `, + true, + ); + assert.doesNotMatch(code, /rc-component|rcDefaultInterop|__esModule/); +}); + +test('is only installed in the ESM Babel configuration', () => { + let config; + fatherPlugin({ + onStart() {}, + modifyDefaultConfig(modify) { + config = modify({}); + }, + }); + assert.ok( + config.esm.extraBabelPlugins.includes( + require.resolve('../dist/babelPluginDefaultInterop'), + ), + ); + assert.equal(config.cjs.extraBabelPlugins, undefined); + assert.equal(config.esm.transformer, undefined); +}); From 201952d772d63e3dc0e17b22791740c6260d38d0 Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Mon, 7 Sep 2026 16:57:01 +0800 Subject: [PATCH 2/5] fix: normalize CommonJS defaults across Father compilers --- README.md | 12 +- README.zh-CN.md | 12 +- package.json | 11 +- src/babelPluginDefaultInterop.ts | 81 ------- src/defaultInterop.ts | 156 +++++++++++++ src/index.ts | 16 +- src/transformer.ts | 20 ++ test/babelPluginDefaultInterop.test.js | 145 ------------ test/defaultInterop.test.js | 303 +++++++++++++++++++++++++ 9 files changed, 513 insertions(+), 243 deletions(-) delete mode 100644 src/babelPluginDefaultInterop.ts create mode 100644 src/defaultInterop.ts create mode 100644 src/transformer.ts delete mode 100644 test/babelPluginDefaultInterop.test.js create mode 100644 test/defaultInterop.test.js diff --git a/README.md b/README.md index 90c234c..bc9a413 100644 --- a/README.md +++ b/README.md @@ -42,20 +42,22 @@ export default defineConfig({ ### Default imports in native ESM -The ESM Babel configuration normalizes default imports from `@rc-component/trigger`, `@rc-component/resize-observer`, and `@rc-component/overflow`. Their Node entries currently expose transpiled CommonJS exports. A small helper is generated in each output file that needs it; component source code keeps ordinary default imports. Native ESM defaults pass through unchanged, so the same output also works when a browser bundler selects these dependencies' ESM entries. +For `esm.platform: 'node'`, the plugin normalizes default imports from statically identifiable transpiled CommonJS dependencies. It resolves each package's Node **import** entry, then checks for `__esModule` and `default` exports without executing the dependency. Package names are not hardcoded: scoped packages, package subpaths, and statically identifiable CommonJS re-export entries are supported. -For native Node ESM with Father 4.6.37 or newer, explicitly select Babel: +No additional interop option or compiler switch is needed. For native Node ESM with Father 4.6.37 or newer: ```ts | pure export default defineConfig({ plugins: ['@rc-component/father-plugin'], - esm: { platform: 'node', transformer: 'babel', autoExtension: true }, + esm: { platform: 'node', autoExtension: true }, }); ``` -Father normally selects esbuild for `platform: 'node'`; esbuild and SWC do not run `extraBabelPlugins`. This plugin does not silently change the chosen transformer. The rule only handles default imports from the three package roots, including `import { default as Name }`; it does not rewrite named imports, namespace imports, type-only imports, re-exports from dependencies, or other packages. CommonJS output keeps Father's normal interop handling. No runtime dependency is added. +Father keeps its default esbuild compiler for Node. The same output normalization also works with explicitly selected Babel or SWC, after their TypeScript/JSX transforms. Source maps are composed back to the original source. One small helper is generated per affected output file, so component source keeps ordinary default imports, including `import { default as Name }`. -This is a compatibility bridge until these packages and their dependencies provide native ESM entries. The generated code still checks the loaded export at runtime; the compiler cannot assume which entry a downstream resolver will select. +Native ESM entries and plain CommonJS exports stay unchanged. The rule skips named imports, namespace imports, type-only imports, relative imports, builtins, dynamic imports, and dependency re-export statements in the consuming source. Unresolved dependencies and export structures that cannot be classified statically are left untouched. Browser-targeted and CommonJS builds keep their existing compiler output. + +This is a compatibility bridge until dependencies expose native ESM entries. Generated code still checks the loaded value at runtime, since downstream bundlers can select another entry. The parsing and resolution dependencies run only during the library build; no helper package is imported by the generated output. | Option | Description | | --------- | -------------------------------------------------------- | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6b999a6..d1144c8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -42,20 +42,22 @@ export default defineConfig({ ### 原生 ESM 的默认导入 -ESM 的 Babel 配置会处理 `@rc-component/trigger`、`@rc-component/resize-observer` 和 `@rc-component/overflow` 的默认导入。这些依赖的 Node 入口目前仍提供转译后的 CommonJS 导出。插件只在需要的产物文件里生成兼容函数,组件源码保持普通默认导入。如果浏览器打包器选择依赖的 ESM 入口,正常的默认导出会原样返回。 +对于 `esm.platform: 'node'`,插件会处理能静态识别的转译后 CommonJS 依赖的默认导入。它按照 Node 的 **import** 条件解析依赖入口,检查 `__esModule` 和 `default` 导出,全程不执行依赖代码。不维护包名白名单,支持带 scope 的包、包子路径和可静态识别的 CommonJS 转导出入口。 -使用 Father 4.6.37 或更高版本构建原生 Node ESM 时,需要显式选择 Babel: +不需要额外的 interop 开关,也不需要切换编译器。使用 Father 4.6.37 或更高版本构建原生 Node ESM: ```ts | pure export default defineConfig({ plugins: ['@rc-component/father-plugin'], - esm: { platform: 'node', transformer: 'babel', autoExtension: true }, + esm: { platform: 'node', autoExtension: true }, }); ``` -Father 在 `platform: 'node'` 时默认选择 esbuild;esbuild 和 SWC 不执行 `extraBabelPlugins`,本插件也不会隐式切换编译器。规则只处理这三个包根入口的默认导入,包括 `import { default as Name }`,不会改写命名导入、命名空间导入、纯类型导入、依赖的再导出或其他包。CommonJS 产物继续使用 Father 原有的兼容处理,不增加运行时依赖。 +Father 继续使用 Node 平台默认的 esbuild;显式选择 Babel 或 SWC 时也会在 TypeScript/JSX 编译完成后执行相同的处理,并将 source map 合并回原始源码。每个涉及的产物文件只生成一个小型兼容函数,组件源码保持普通默认导入,包括 `import { default as Name }`。 -这是一项过渡措施,待这些包及其依赖提供原生 ESM 入口后可移除。产物仍会在运行时检查导出,编译器无法预先确定下游解析器最终会选择哪个入口。 +正常 ESM 入口和普通 CommonJS 导出保持原样。规则不处理命名导入、命名空间导入、纯类型导入、相对路径、内置模块、动态导入以及消费方源码中的依赖再导出语句。无法解析的依赖和无法静态识别的导出结构也保持原样。面向浏览器的构建和 CommonJS 构建继续使用原有编译产物。 + +这是一项过渡措施,待依赖提供原生 ESM 入口后可移除。下游打包器可能选择其他入口,因此产物仍会在运行时检查导出值。解析相关依赖只在组件库构建时运行,产物不会额外导入 helper 包。 | 名称 | 说明 | | --------- | ---------------------------------------------------- | diff --git a/package.json b/package.json index dc1400a..18e0632 100644 --- a/package.json +++ b/package.json @@ -37,17 +37,22 @@ ] }, "dependencies": { - "fs-extra": "^11.3.0" + "@ampproject/remapping": "^2.3.0", + "acorn": "^8.18.0", + "cjs-module-lexer": "^2.2.1", + "enhanced-resolve": "^5.24.5", + "fs-extra": "^11.3.0", + "magic-string": "^0.30.21" }, "devDependencies": { "@babel/core": "^7.29.7", - "@babel/plugin-transform-typescript": "^7.29.7", "@commitlint/cli": "^21.2.0", "@commitlint/config-conventional": "^21.2.0", "@eslint/compat": "^2.1.0", "@eslint/js": "^10.0.1", + "@jridgewell/trace-mapping": "^0.3.31", "@rc-component/np": "^1.0.4", - "@types/babel__core": "^7.20.5", + "@swc/core": "^1.16.2", "@types/fs-extra": "^11.0.4", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", diff --git a/src/babelPluginDefaultInterop.ts b/src/babelPluginDefaultInterop.ts deleted file mode 100644 index 2aeca4b..0000000 --- a/src/babelPluginDefaultInterop.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type * as Babel from '@babel/core'; - -// TODO: Remove this bridge when these packages and their dependencies expose native ESM entries. -const interopPackages = new Set([ - '@rc-component/trigger', - '@rc-component/resize-observer', - '@rc-component/overflow', -]); - -/** Normalize the default imports of rc dependencies that still expose transpiled CJS to Node. */ -export default function defaultInterop({ - types: t, - template, -}: typeof Babel): Babel.PluginObj { - return { - name: 'rc-component-default-interop', - visitor: { - Program: { - exit(program) { - const helper = - program.scope.generateUidIdentifier('rcDefaultInterop'); - const declarations: Babel.types.VariableDeclarator[] = []; - - // Run after TypeScript has removed imports that are only used as types. - for (const statement of program.get('body')) { - if ( - !statement.isImportDeclaration() || - statement.node.importKind === 'type' || - !interopPackages.has(statement.node.source.value) - ) { - continue; - } - - for (const specifier of statement.node.specifiers) { - if ( - !t.isImportDefaultSpecifier(specifier) && - !( - t.isImportSpecifier(specifier) && - specifier.importKind !== 'type' && - (t.isIdentifier(specifier.imported, { name: 'default' }) || - t.isStringLiteral(specifier.imported, { value: 'default' })) - ) - ) { - continue; - } - - const local = specifier.local; - const imported = program.scope.generateUidIdentifier( - `${local.name}Module`, - ); - specifier.local = imported; - declarations.push( - t.variableDeclarator( - local, - t.callExpression(helper, [t.cloneNode(imported)]), - ), - ); - } - } - - if (!declarations.length) return; - - const normalize = template.statement(` - function %%NAME%%(value) { - return value && typeof value === 'object' && - '__esModule' in value && value.__esModule && 'default' in value - ? value.default : value; - } - `)({ NAME: helper }); - - // Imports are hoisted, so initialize their local aliases before any source statements. - program.unshiftContainer('body', [ - normalize, - t.variableDeclaration('var', declarations), - ]); - program.scope.crawl(); - }, - }, - }, - }; -} diff --git a/src/defaultInterop.ts b/src/defaultInterop.ts new file mode 100644 index 0000000..d439c04 --- /dev/null +++ b/src/defaultInterop.ts @@ -0,0 +1,156 @@ +import remapping from '@ampproject/remapping'; +import { parse as parseModule } from 'acorn'; +import { parse as parseCommonJS } from 'cjs-module-lexer'; +import { create } from 'enhanced-resolve'; +import fs from 'fs'; +import MagicString from 'magic-string'; +import { builtinModules, createRequire } from 'module'; +import path from 'path'; + +// Match Node's import branch, rather than accidentally inspecting a dual package's require entry. +const resolveImport = create.sync({ + conditionNames: ['node', 'import', 'default'], + mainFields: ['main'], + extensions: ['.js', '.json', '.node'], +}); + +function commonJSExports( + filename: string, + seen = new Set(), +): Set { + if (seen.has(filename)) return new Set(); + seen.add(filename); + if (!/\.(?:c?js)$/.test(filename)) return new Set(); + + try { + const { exports, reexports } = parseCommonJS( + fs.readFileSync(filename, 'utf8'), + ); + const names = new Set(exports); + for (const request of reexports) { + try { + const dependency = createRequire(filename).resolve(request); + commonJSExports(dependency, seen).forEach((name) => names.add(name)); + } catch { + // Optional or unresolved re-exports cannot be classified statically. + } + } + return names; + } catch { + // Native ESM and unrecognized syntax must keep their original import semantics. + return new Set(); + } +} + +function needsInterop(request: string, importer: string): boolean { + if ( + /^(?:[./#]|[a-z][\w+.-]*:)/i.test(request) || + builtinModules.includes(request) + ) + return false; + + try { + const entry = resolveImport(path.dirname(importer), request); + if (!entry) return false; + const names = commonJSExports(entry); + return names.has('__esModule') && names.has('default'); + } catch { + return false; + } +} + +/** Normalize statically identifiable transpiled CommonJS defaults after JS compilation. */ +export default function defaultInterop( + code: string, + importer: string, + sourceMap?: string | null, +): [string, (string | null)?] { + const names = new Set(); + const program = parseModule(code, { + ecmaVersion: 'latest', + sourceType: 'module', + allowHashBang: true, + onToken(token) { + if ( + token.type.label === 'name' && + 'value' in token && + typeof token.value === 'string' + ) { + names.add(token.value); + } + }, + }); + const uid = (name: string) => { + let candidate = `_${name}`; + while (names.has(candidate)) candidate += '_'; + names.add(candidate); + return candidate; + }; + const helper = uid('rcDefaultInterop'); + const output = new MagicString(code); + const declarations: string[] = []; + + for (const statement of program.body) { + if (statement.type !== 'ImportDeclaration') continue; + const defaults = statement.specifiers.filter( + (specifier) => + specifier.type === 'ImportDefaultSpecifier' || + (specifier.type === 'ImportSpecifier' && + (specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value) === 'default'), + ); + if ( + !defaults.length || + !needsInterop(String(statement.source.value), importer) + ) + continue; + + for (const specifier of defaults) { + const imported = uid(`${specifier.local.name}Module`); + output.overwrite(specifier.local.start, specifier.local.end, imported); + declarations.push( + `var ${specifier.local.name} = ${helper}(${imported});`, + ); + } + } + + if (!declarations.length) return [code, sourceMap]; + + let insertion = code.startsWith('#!') ? code.indexOf('\n') + 1 : 0; + for (const statement of program.body) { + if (statement.type !== 'ExpressionStatement' || !statement.directive) break; + insertion = statement.end; + } + // TODO: Remove the bridge when the dependencies expose native ESM entries. + // Imports are hoisted; initialize aliases before any original executable statement. + output.appendLeft( + insertion, + ` +function ${helper}(value) { + return value && (typeof value === 'object' || typeof value === 'function') && + value.__esModule && 'default' in value ? value.default : value; +} +${declarations.join('\n')} +`, + ); + + const map = sourceMap + ? remapping( + [ + JSON.parse( + output + .generateMap({ + source: importer, + includeContent: true, + hires: true, + }) + .toString(), + ), + JSON.parse(sourceMap), + ], + () => null, + ).toString() + : sourceMap; + return [output.toString(), map]; +} diff --git a/src/index.ts b/src/index.ts index 09b4f94..a877f00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { execSync } from 'child_process'; import type { IApi } from 'father'; import fs from 'fs-extra'; +import { createRequire } from 'module'; import path from 'path'; const cwd = process.cwd(); @@ -43,6 +44,16 @@ function checkNpmPackageDependency(packageJson: any, packageName: string) { export default (api: IApi) => { // Compile break if export type without consistent api.onStart(async () => { + if (api.name === 'build' || api.name === 'dev') { + // Father 4 collects addJSTransformer before loading project plugins. + // Register after initialization, against the project's actual Father instance. + const projectRequire = createRequire(path.join(api.cwd, 'package.json')); + const { addTransformer } = projectRequire('father/dist/builder/bundless'); + for (const id of ['babel', 'esbuild', 'swc']) { + addTransformer({ id, transformer: require.resolve('./transformer') }); + } + } + if (api.name !== 'build') { return; } @@ -86,10 +97,7 @@ export default (api: IApi) => { esm: { output: 'es', // transform all rc-xx/lib to rc-xx/es for esm build - extraBabelPlugins: [ - require.resolve('./babelPluginImportLib2Es'), - require.resolve('./babelPluginDefaultInterop'), - ], + extraBabelPlugins: [require.resolve('./babelPluginImportLib2Es')], }, cjs: { // specific platform to browser, father 4 build cjs for node by default diff --git a/src/transformer.ts b/src/transformer.ts new file mode 100644 index 0000000..bf4b6a9 --- /dev/null +++ b/src/transformer.ts @@ -0,0 +1,20 @@ +import type { IJSTransformer } from 'father'; +import { createRequire } from 'module'; +import path from 'path'; +import defaultInterop from './defaultInterop'; + +type Transformer = NonNullable; + +// Delegate to Father's compiler so its JSX, aliases, targets, and source maps stay in effect. +const transformer: Transformer = async function (content) { + const loadCompiler = createRequire(path.join(this.paths.cwd, 'package.json')); + const original = loadCompiler( + `father/dist/builder/bundless/loaders/javascript/${this.config.transformer}`, + ); + const result = await (original.default || original).call(this, content); + if (this.config.format !== 'esm' || this.config.platform !== 'node') + return result; + return defaultInterop(result[0], this.paths.fileAbsPath, result[1]); +}; + +export default transformer; diff --git a/test/babelPluginDefaultInterop.test.js b/test/babelPluginDefaultInterop.test.js deleted file mode 100644 index 6bdcfdd..0000000 --- a/test/babelPluginDefaultInterop.test.js +++ /dev/null @@ -1,145 +0,0 @@ -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { afterEach, test } = require('node:test'); -const { pathToFileURL } = require('node:url'); -const { transformSync } = require('@babel/core'); -const interop = require('../dist/babelPluginDefaultInterop').default; -const fatherPlugin = require('../dist').default; - -const fixtures = []; -afterEach(() => { - fixtures - .splice(0) - .forEach((directory) => - fs.rmSync(directory, { recursive: true, force: true }), - ); -}); - -function transform(source, typescript = false) { - return transformSync(source, { - filename: typescript ? 'consumer.ts' : 'consumer.js', - configFile: false, - babelrc: false, - sourceMaps: true, - plugins: [ - interop, - ...(typescript ? [require('@babel/plugin-transform-typescript')] : []), - ], - }); -} - -async function run(source, esm = false) { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-interop-')); - fixtures.push(directory); - for (const name of ['trigger', 'resize-observer', 'overflow']) { - const dependency = path.join( - directory, - 'node_modules', - '@rc-component', - name, - ); - fs.mkdirSync(dependency, { recursive: true }); - fs.writeFileSync( - path.join(dependency, 'package.json'), - JSON.stringify({ - name: `@rc-component/${name}`, - type: esm ? 'module' : 'commonjs', - main: 'index.js', - }), - ); - fs.writeFileSync( - path.join(dependency, 'index.js'), - esm - ? `export const marker = '${name}'; export default function component() { return marker; }` - : `Object.defineProperty(exports, '__esModule', { value: true }); - exports.marker = '${name}'; exports.default = function component() { return exports.marker; };`, - ); - } - const output = transform(source); - const entry = path.join(directory, 'consumer.mjs'); - fs.writeFileSync(entry, output.code); - return { module: await import(pathToFileURL(entry).href), ...output }; -} - -for (const esm of [false, true]) { - test(`default imports work with ${esm ? 'native ESM' : 'transpiled CommonJS'} dependencies`, async () => { - const { module, code } = await run( - ` - import Trigger, { marker } from '@rc-component/trigger'; - import { default as ResizeObserver } from '@rc-component/resize-observer'; - import Overflow from '@rc-component/overflow'; - export const result = [Trigger(), ResizeObserver(), Overflow(), marker]; - export { Trigger as default }; - `, - esm, - ); - assert.deepEqual(module.result, [ - 'trigger', - 'resize-observer', - 'overflow', - 'trigger', - ]); - assert.equal(module.default(), 'trigger'); - assert.equal((code.match(/function _rcDefaultInterop/g) || []).length, 1); - }); -} - -test('keeps import hoisting, shadowed bindings, directives, and generated-name collisions', async () => { - const { module, code, map } = await run(` - 'use client'; - export const result = Trigger(); - import Trigger from '@rc-component/trigger'; - const _rcDefaultInterop = 'user helper'; - const _TriggerModule = 'user binding'; - export function shadow(Trigger) { return Trigger; } - export const names = [_rcDefaultInterop, _TriggerModule]; - `); - assert.equal(module.result, 'trigger'); - assert.equal(module.shadow('local'), 'local'); - assert.deepEqual(module.names, ['user helper', 'user binding']); - assert.match(code, /^'use client';/); - assert.deepEqual(map.sources, ['consumer.js']); -}); - -test('does not change named imports, namespace imports, other packages, or rc deep paths', () => { - const { code } = transform(` - import { marker } from '@rc-component/trigger'; - import * as observer from '@rc-component/resize-observer'; - import React from 'react'; - import deep from '@rc-component/overflow/lib'; - export { marker, observer, React, deep }; - `); - assert.doesNotMatch(code, /rcDefaultInterop|__esModule/); -}); - -test('does not introduce runtime imports for TypeScript-only dependencies', () => { - const { code } = transform( - ` - import type Trigger from '@rc-component/trigger'; - import ResizeObserver from '@rc-component/resize-observer'; - import { type default as Overflow } from '@rc-component/overflow'; - export type Values = [Trigger, typeof ResizeObserver, Overflow]; - `, - true, - ); - assert.doesNotMatch(code, /rc-component|rcDefaultInterop|__esModule/); -}); - -test('is only installed in the ESM Babel configuration', () => { - let config; - fatherPlugin({ - onStart() {}, - modifyDefaultConfig(modify) { - config = modify({}); - }, - }); - assert.ok( - config.esm.extraBabelPlugins.includes( - require.resolve('../dist/babelPluginDefaultInterop'), - ), - ); - assert.equal(config.cjs.extraBabelPlugins, undefined); - assert.equal(config.esm.transformer, undefined); -}); diff --git a/test/defaultInterop.test.js b/test/defaultInterop.test.js new file mode 100644 index 0000000..fa3888d --- /dev/null +++ b/test/defaultInterop.test.js @@ -0,0 +1,303 @@ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { afterEach, test } = require('node:test'); +const { pathToFileURL } = require('node:url'); +const { TraceMap, originalPositionFor } = require('@jridgewell/trace-mapping'); +const normalize = require('../dist/defaultInterop').default; +const transformer = require('../dist/transformer').default; + +const fixtures = []; +afterEach(() => { + fixtures + .splice(0) + .forEach((directory) => + fs.rmSync(directory, { recursive: true, force: true }), + ); +}); + +function fixture() { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-interop-')); + fixtures.push(directory); + const add = (name, files, config = {}) => { + const root = path.join(directory, 'node_modules', name); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ name, main: 'index.js', ...config }), + ); + for (const [file, code] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), code); + } + }; + const cjs = `Object.defineProperty(exports, '__esModule', { value: true }); + exports.marker = 'cjs'; exports.default = function component() { return exports.marker; };`; + add('any-legacy-package', { 'index.js': cjs }); + add( + '@example/components', + { + 'index.js': `module.exports = require('./component.js');`, + 'component.js': cjs, + }, + { exports: { '.': './index.js', './feature': './component.js' } }, + ); + add('plain-cjs', { + 'index.js': `module.exports = function plain() { return 'plain'; };`, + }); + fs.symlinkSync( + path.dirname(require.resolve('father/package.json')), + path.join(directory, 'node_modules/father'), + 'junction', + ); + fs.writeFileSync( + path.join(directory, 'package.json'), + JSON.stringify({ + name: 'interop-fixture', + version: '1.0.0', + type: 'commonjs', + }), + ); + add( + 'dual-package', + { + 'index.cjs': cjs, + 'index.js': `export const marker = 'esm'; + export let current = { __esModule: true, default: 'intentional ESM value' }; + export { current as default }; + export function update() { current = 'updated'; }`, + }, + { + type: 'module', + exports: { import: './index.js', require: './index.cjs' }, + }, + ); + return { directory, add, cjs }; +} + +async function run(directory, source) { + const entry = path.join(directory, 'consumer.mjs'); + const [code] = normalize(source, path.join(directory, 'src.ts')); + fs.writeFileSync(entry, code); + return { module: await import(pathToFileURL(entry).href), code }; +} + +test('handles arbitrary package names, scoped subpaths, and CommonJS re-export entries', async () => { + const { directory } = fixture(); + const { module, code } = await run( + directory, + ` + import Component, { marker } from 'any-legacy-package'; + import { default as Wrapped } from '@example/components'; + import Deep from '@example/components/feature'; + export const result = [Component(), Wrapped(), Deep(), marker]; + export { Component as default }; + `, + ); + assert.deepEqual(module.result, ['cjs', 'cjs', 'cjs', 'cjs']); + assert.equal(module.default(), 'cjs'); + assert.equal((code.match(/function _rcDefaultInterop/g) || []).length, 1); +}); + +test('uses the import condition and preserves native ESM default values and live bindings', async () => { + const { directory } = fixture(); + const source = `import Value, { update } from 'dual-package'; + import Plain from 'plain-cjs'; + export const before = Value; + update(); + export const after = Value; + export const plain = Plain();`; + const { module, code } = await run(directory, source); + assert.equal(code, source); + assert.deepEqual(module.before, { + __esModule: true, + default: 'intentional ESM value', + }); + assert.equal(module.after, 'updated'); + assert.equal(module.plain, 'plain'); +}); + +test('keeps import hoisting, shadowed bindings, directives, and generated-name collisions', async () => { + const { directory } = fixture(); + const { module, code } = await run( + directory, + `'use client'; + export const result = Component(); + import Component from 'any-legacy-package'; + const _rcDefaultInterop = 'user helper'; + const _ComponentModule = 'user binding'; + export function shadow(Component) { return Component; } + export const names = [_rcDefaultInterop, _ComponentModule]; + `, + ); + assert.equal(module.result, 'cjs'); + assert.equal(module.shadow('local'), 'local'); + assert.deepEqual(module.names, ['user helper', 'user binding']); + assert.ok(code.startsWith("'use client';")); +}); + +test('leaves named, namespace, dynamic, relative, builtin, and unresolved imports unchanged', () => { + const { directory } = fixture(); + const source = ` + import { marker } from 'any-legacy-package'; + import * as ns from '@example/components'; + import 'any-legacy-package'; + import Relative from './local.js'; + import FS from 'fs'; + import HTTP from 'node:http'; + import Optional from 'not-installed'; + export const lazy = () => import('any-legacy-package'); + export { marker, ns, Relative, FS, HTTP, Optional }; + `; + assert.equal(normalize(source, path.join(directory, 'entry.js'))[0], source); +}); + +test('does not execute dependencies while inspecting their exports', () => { + const { directory, add, cjs } = fixture(); + add('must-not-execute', { + 'index.js': `throw new Error('executed at build time');\n${cjs}`, + }); + const [code] = normalize( + `import Value from 'must-not-execute'; export default Value;`, + path.join(directory, 'entry.js'), + ); + assert.match(code, /rcDefaultInterop/); +}); + +test('checks runtime values when a downstream resolver selects another entry', async () => { + for (const value of [null, false, 0, 'native value']) { + const { directory, add, cjs } = fixture(); + add('switch-entry', { 'index.js': cjs }); + const [code] = normalize( + `import Value from 'switch-entry'; export default Value;`, + path.join(directory, 'entry.js'), + ); + assert.match(code, /rcDefaultInterop/); + add( + 'switch-entry', + { 'index.js': `export default ${JSON.stringify(value)};` }, + { type: 'module' }, + ); + const entry = path.join(directory, 'compiled.mjs'); + fs.writeFileSync(entry, code); + assert.equal((await import(pathToFileURL(entry).href)).default, value); + } +}); + +test('handles cyclic CommonJS re-exports and ignores unrecognized export structures', () => { + const { directory, add } = fixture(); + add('cyclic', { + 'index.js': `module.exports = require('./other.js');`, + 'other.js': `module.exports = require('./index.js');`, + }); + const source = `import Value from 'cyclic'; export default Value;`; + assert.equal(normalize(source, path.join(directory, 'entry.js'))[0], source); +}); + +async function compile(directory, source, options = {}) { + const file = path.join(directory, 'entry.ts'); + fs.writeFileSync(file, source); + fs.writeFileSync( + path.join(directory, 'tsconfig.json'), + JSON.stringify({ compilerOptions: { target: 'ES2020' } }), + ); + const context = { + config: { + transformer: 'esbuild', + format: 'esm', + platform: 'node', + sourcemap: true, + ...options, + }, + pkg: {}, + paths: { + cwd: directory, + fileAbsPath: file, + itemDistAbsPath: path.join(directory, 'dist/entry.mjs'), + }, + }; + return { result: await transformer.call(context, source), context }; +} + +for (const compiler of ['esbuild', 'babel', 'swc']) { + test(`keeps ${compiler} compilation and maps back to TypeScript source`, async () => { + const { directory } = fixture(); + const source = `import Component from 'any-legacy-package';\nexport const result: string = Component();`; + const { + result: [code, map], + } = await compile(directory, source, { transformer: compiler }); + assert.match(code, /rcDefaultInterop/); + assert.doesNotMatch(code, /: string/); + assert.ok(JSON.parse(map).sourcesContent.includes(source)); + const declaration = /(?:var|const) result\b/.exec(code); + assert.ok(declaration); + const prefix = code.slice(0, declaration.index); + const position = originalPositionFor(new TraceMap(map), { + line: prefix.split('\n').length, + column: prefix.length - prefix.lastIndexOf('\n') - 1, + }); + assert.equal(position.line, 2); + assert.ok(position.source.endsWith('entry.ts')); + const entry = path.join(directory, 'compiled.mjs'); + fs.writeFileSync(entry, code); + assert.equal((await import(pathToFileURL(entry).href)).result, 'cjs'); + }); +} + +test('type-only imports are removed before interop analysis', async () => { + const { directory } = fixture(); + const { + result: [code], + } = await compile( + directory, + ` + import type Component from 'any-legacy-package'; + import Other from '@example/components'; + export type Value = [typeof Component, typeof Other]; + `, + ); + assert.doesNotMatch(code, /rcDefaultInterop|any-legacy-package|@example/); +}); + +test('returns the original compiler output for CJS and browser builds', async () => { + const { directory } = fixture(); + const source = `import Component from 'any-legacy-package'; export default Component;`; + const original = + require('father/dist/builder/bundless/loaders/javascript/esbuild').default; + for (const options of [{ format: 'cjs' }, { platform: 'browser' }]) { + const { result, context } = await compile(directory, source, options); + assert.deepEqual(result, await original.call(context, source)); + } +}); + +test('a real Father build uses default esbuild with the plugin registered', async () => { + const { directory } = fixture(); + fs.mkdirSync(path.join(directory, 'src')); + fs.writeFileSync( + path.join(directory, 'src/index.ts'), + `import Component from 'any-legacy-package'; export default Component;`, + ); + fs.writeFileSync( + path.join(directory, '.fatherrc.ts'), + `export default ${JSON.stringify({ + plugins: [require.resolve('../dist')], + esm: { platform: 'node', autoExtension: true }, + })};`, + ); + const log = execFileSync( + process.execPath, + [require.resolve('father/bin/father.js'), 'build'], + { + cwd: directory, + env: { ...process.env, FATHER_CACHE: 'none' }, + stdio: 'pipe', + encoding: 'utf8', + }, + ); + const entry = path.join(directory, 'es/index.mjs'); + assert.ok(fs.existsSync(entry), log); + assert.match(fs.readFileSync(entry, 'utf8'), /rcDefaultInterop/); + assert.equal((await import(pathToFileURL(entry).href)).default(), 'cjs'); +}); From 8011d40dc45ef930f8e3fd3c63c8efd8707f2267 Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Mon, 7 Sep 2026 16:59:13 +0800 Subject: [PATCH 3/5] fix: preserve output with unsupported inspection syntax --- README.md | 2 +- README.zh-CN.md | 2 +- src/defaultInterop.ts | 34 ++++++++++++++++++++-------------- test/defaultInterop.test.js | 9 +++++++++ 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bc9a413..5edd897 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ export default defineConfig({ Father keeps its default esbuild compiler for Node. The same output normalization also works with explicitly selected Babel or SWC, after their TypeScript/JSX transforms. Source maps are composed back to the original source. One small helper is generated per affected output file, so component source keeps ordinary default imports, including `import { default as Name }`. -Native ESM entries and plain CommonJS exports stay unchanged. The rule skips named imports, namespace imports, type-only imports, relative imports, builtins, dynamic imports, and dependency re-export statements in the consuming source. Unresolved dependencies and export structures that cannot be classified statically are left untouched. Browser-targeted and CommonJS builds keep their existing compiler output. +Native ESM entries and plain CommonJS exports stay unchanged. The rule skips named imports, namespace imports, type-only imports, relative imports, builtins, dynamic imports, and dependency re-export statements in the consuming source. Unresolved dependencies, unrecognized export structures, and output syntax unsupported by the inspection parser are left untouched. Browser-targeted and CommonJS builds keep their existing compiler output. This is a compatibility bridge until dependencies expose native ESM entries. Generated code still checks the loaded value at runtime, since downstream bundlers can select another entry. The parsing and resolution dependencies run only during the library build; no helper package is imported by the generated output. diff --git a/README.zh-CN.md b/README.zh-CN.md index d1144c8..0ad995a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -55,7 +55,7 @@ export default defineConfig({ Father 继续使用 Node 平台默认的 esbuild;显式选择 Babel 或 SWC 时也会在 TypeScript/JSX 编译完成后执行相同的处理,并将 source map 合并回原始源码。每个涉及的产物文件只生成一个小型兼容函数,组件源码保持普通默认导入,包括 `import { default as Name }`。 -正常 ESM 入口和普通 CommonJS 导出保持原样。规则不处理命名导入、命名空间导入、纯类型导入、相对路径、内置模块、动态导入以及消费方源码中的依赖再导出语句。无法解析的依赖和无法静态识别的导出结构也保持原样。面向浏览器的构建和 CommonJS 构建继续使用原有编译产物。 +正常 ESM 入口和普通 CommonJS 导出保持原样。规则不处理命名导入、命名空间导入、纯类型导入、相对路径、内置模块、动态导入以及消费方源码中的依赖再导出语句。无法解析的依赖、无法静态识别的导出结构以及检查用解析器不支持的产物语法也保持原样。面向浏览器的构建和 CommonJS 构建继续使用原有编译产物。 这是一项过渡措施,待依赖提供原生 ESM 入口后可移除。下游打包器可能选择其他入口,因此产物仍会在运行时检查导出值。解析相关依赖只在组件库构建时运行,产物不会额外导入 helper 包。 diff --git a/src/defaultInterop.ts b/src/defaultInterop.ts index d439c04..1a2b521 100644 --- a/src/defaultInterop.ts +++ b/src/defaultInterop.ts @@ -66,20 +66,26 @@ export default function defaultInterop( sourceMap?: string | null, ): [string, (string | null)?] { const names = new Set(); - const program = parseModule(code, { - ecmaVersion: 'latest', - sourceType: 'module', - allowHashBang: true, - onToken(token) { - if ( - token.type.label === 'name' && - 'value' in token && - typeof token.value === 'string' - ) { - names.add(token.value); - } - }, - }); + let program: ReturnType; + try { + program = parseModule(code, { + ecmaVersion: 'latest', + sourceType: 'module', + allowHashBang: true, + onToken(token) { + if ( + token.type.label === 'name' && + 'value' in token && + typeof token.value === 'string' + ) { + names.add(token.value); + } + }, + }); + } catch { + // Do not reject compiler output whose syntax this inspection parser cannot handle. + return [code, sourceMap]; + } const uid = (name: string) => { let candidate = `_${name}`; while (names.has(candidate)) candidate += '_'; diff --git a/test/defaultInterop.test.js b/test/defaultInterop.test.js index fa3888d..c5934e0 100644 --- a/test/defaultInterop.test.js +++ b/test/defaultInterop.test.js @@ -166,6 +166,15 @@ test('does not execute dependencies while inspecting their exports', () => { assert.match(code, /rcDefaultInterop/); }); +test('preserves compiler output with syntax unsupported by the inspection parser', () => { + const source = `import data from './data.json' assert { type: 'json' }; export default data;`; + const sourceMap = '{"version":3,"sources":[],"names":[],"mappings":""}'; + assert.deepEqual(normalize(source, '/project/entry.js', sourceMap), [ + source, + sourceMap, + ]); +}); + test('checks runtime values when a downstream resolver selects another entry', async () => { for (const value of [null, false, 0, 'native value']) { const { directory, add, cjs } = fixture(); From dcdb97c66a11e56ddae12048ff3a302ec2ddf56e Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Mon, 7 Sep 2026 17:17:34 +0800 Subject: [PATCH 4/5] fix: make CommonJS default interop opt-in --- README.md | 25 ++++-- README.zh-CN.md | 25 ++++-- package.json | 4 +- src/index.ts | 17 +++- src/transformer.ts | 10 ++- test/defaultInterop.test.js | 153 +++++++++++++++++++++++++++++++----- types.d.ts | 15 ++++ 7 files changed, 212 insertions(+), 37 deletions(-) create mode 100644 types.d.ts diff --git a/README.md b/README.md index 5edd897..b37301e 100644 --- a/README.md +++ b/README.md @@ -42,26 +42,37 @@ export default defineConfig({ ### Default imports in native ESM -For `esm.platform: 'node'`, the plugin normalizes default imports from statically identifiable transpiled CommonJS dependencies. It resolves each package's Node **import** entry, then checks for `__esModule` and `default` exports without executing the dependency. Package names are not hardcoded: scoped packages, package subpaths, and statically identifiable CommonJS re-export entries are supported. +`cjsDefaultInterop` is **off by default**. When omitted or `false`, the plugin does not register the interop transformer or load its inspection dependencies; existing compiler output and import semantics are preserved. -No additional interop option or compiler switch is needed. For native Node ESM with Father 4.6.37 or newer: +Opt in for a package that needs transpiled CommonJS defaults to work in native Node ESM. With Father 4.6.37 or newer: ```ts | pure +import type {} from '@rc-component/father-plugin'; +import { defineConfig } from 'father'; + export default defineConfig({ plugins: ['@rc-component/father-plugin'], + cjsDefaultInterop: true, esm: { platform: 'node', autoExtension: true }, }); ``` +The type-only import enables the plugin's configuration types for `defineConfig`; it emits no runtime import. The switch is a top-level plugin option, separate from `esm` and `cjs`. Changing it also changes Father's per-file build cache key. + +When enabled, the plugin normalizes default imports from statically identifiable transpiled CommonJS dependencies in **Node ESM output only**. It resolves each package's Node **import** entry, then checks for `__esModule` and `default` exports without executing the dependency. Package names are not hardcoded: scoped packages, package subpaths, and statically identifiable CommonJS re-export entries are supported. + Father keeps its default esbuild compiler for Node. The same output normalization also works with explicitly selected Babel or SWC, after their TypeScript/JSX transforms. Source maps are composed back to the original source. One small helper is generated per affected output file, so component source keeps ordinary default imports, including `import { default as Name }`. -Native ESM entries and plain CommonJS exports stay unchanged. The rule skips named imports, namespace imports, type-only imports, relative imports, builtins, dynamic imports, and dependency re-export statements in the consuming source. Unresolved dependencies, unrecognized export structures, and output syntax unsupported by the inspection parser are left untouched. Browser-targeted and CommonJS builds keep their existing compiler output. +Entries identified as native ESM at build time and plain CommonJS exports stay unchanged. The rule skips named imports, namespace imports, type-only imports, relative imports, builtins, dynamic imports, and dependency re-export statements in the consuming source. Unresolved dependencies, unrecognized export structures, and output syntax unsupported by the inspection parser are left untouched. Browser-targeted and CommonJS builds keep their existing compiler output. + +**Enabling this option changes default-import semantics.** For a recognized CommonJS dependency, `import pkg from 'legacy'` receives its inner `default` value instead of the CommonJS exports object. Code that already calls `pkg.default()` or reads other properties of that object must be reviewed before enabling it. If a downstream resolver selects a native ESM entry after the build identified the dependency as CommonJS, the generated local variable captures the initial value; subsequent updates to that default export are not reflected. The runtime check does not preserve ESM live bindings in this case. Validate the package's supported consumers before opting in. -This is a compatibility bridge until dependencies expose native ESM entries. Generated code still checks the loaded value at runtime, since downstream bundlers can select another entry. The parsing and resolution dependencies run only during the library build; no helper package is imported by the generated output. +This is a compatibility bridge until dependencies expose native ESM entries. Generated code still checks the loaded value at runtime. The parsing and resolution dependencies run only during the library build; no helper package is imported by the generated output. -| Option | Description | -| --------- | -------------------------------------------------------- | -| `plugins` | Register `@rc-component/father-plugin` in father config. | +| Option | Default | Description | +| --- | --- | --- | +| `plugins` | — | Register `@rc-component/father-plugin` in father config. | +| `cjsDefaultInterop` | `false` | Opt in to CommonJS default-import normalization for Node ESM output. | ## Development diff --git a/README.zh-CN.md b/README.zh-CN.md index 0ad995a..849c753 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -42,26 +42,37 @@ export default defineConfig({ ### 原生 ESM 的默认导入 -对于 `esm.platform: 'node'`,插件会处理能静态识别的转译后 CommonJS 依赖的默认导入。它按照 Node 的 **import** 条件解析依赖入口,检查 `__esModule` 和 `default` 导出,全程不执行依赖代码。不维护包名白名单,支持带 scope 的包、包子路径和可静态识别的 CommonJS 转导出入口。 +`cjsDefaultInterop` **默认关闭**。不配置或设为 `false` 时,插件不注册 interop 编译处理,也不加载相关检查依赖,保留原有编译产物和导入语义。 -不需要额外的 interop 开关,也不需要切换编译器。使用 Father 4.6.37 或更高版本构建原生 Node ESM: +需要让转译后的 CommonJS 默认导入在原生 Node ESM 中工作时,由组件库显式开启。使用 Father 4.6.37 或更高版本: ```ts | pure +import type {} from '@rc-component/father-plugin'; +import { defineConfig } from 'father'; + export default defineConfig({ plugins: ['@rc-component/father-plugin'], + cjsDefaultInterop: true, esm: { platform: 'node', autoExtension: true }, }); ``` +类型导入为 `defineConfig` 加载插件的配置类型,不产生运行时导入。开关位于配置顶层,与 `esm`、`cjs` 同级。切换开关也会改变 Father 的文件构建缓存键。 + +开启后,仅对 **Node ESM 产物** 中能静态识别的转译后 CommonJS 依赖处理默认导入。插件按照 Node 的 **import** 条件解析依赖入口,检查 `__esModule` 和 `default` 导出,全程不执行依赖代码。不维护包名白名单,支持带 scope 的包、包子路径和可静态识别的 CommonJS 转导出入口。 + Father 继续使用 Node 平台默认的 esbuild;显式选择 Babel 或 SWC 时也会在 TypeScript/JSX 编译完成后执行相同的处理,并将 source map 合并回原始源码。每个涉及的产物文件只生成一个小型兼容函数,组件源码保持普通默认导入,包括 `import { default as Name }`。 -正常 ESM 入口和普通 CommonJS 导出保持原样。规则不处理命名导入、命名空间导入、纯类型导入、相对路径、内置模块、动态导入以及消费方源码中的依赖再导出语句。无法解析的依赖、无法静态识别的导出结构以及检查用解析器不支持的产物语法也保持原样。面向浏览器的构建和 CommonJS 构建继续使用原有编译产物。 +构建时识别为原生 ESM 的入口和普通 CommonJS 导出保持原样。规则不处理命名导入、命名空间导入、纯类型导入、相对路径、内置模块、动态导入以及消费方源码中的依赖再导出语句。无法解析的依赖、无法静态识别的导出结构以及检查用解析器不支持的产物语法也保持原样。面向浏览器的构建和 CommonJS 构建继续使用原有编译产物。 + +**开启此选项会改变默认导入语义。** 对于识别到的 CommonJS 依赖,`import pkg from 'legacy'` 拿到的是内部的 `default` 值,原本的 CommonJS 导出对象会被解包。因此,已有的 `pkg.default()` 调用或对该对象其他属性的访问需要先检查。如果构建时识别为 CommonJS,下游实际却选择了原生 ESM 入口,生成的局部变量会保存初始值,无法反映默认导出的后续更新;运行时检查不能保留这种情况下的 ESM 实时绑定。组件库应验证其支持的消费方式后再开启。 -这是一项过渡措施,待依赖提供原生 ESM 入口后可移除。下游打包器可能选择其他入口,因此产物仍会在运行时检查导出值。解析相关依赖只在组件库构建时运行,产物不会额外导入 helper 包。 +这是一项过渡措施,待依赖提供原生 ESM 入口后可移除。产物仍会在运行时检查导出值。解析相关依赖只在组件库构建时运行,产物不会额外导入 helper 包。 -| 名称 | 说明 | -| --------- | ---------------------------------------------------- | -| `plugins` | 在 father 配置中注册 `@rc-component/father-plugin`。 | +| 名称 | 默认值 | 说明 | +| --- | --- | --- | +| `plugins` | — | 在 father 配置中注册 `@rc-component/father-plugin`。 | +| `cjsDefaultInterop` | `false` | 显式开启 Node ESM 产物的 CommonJS 默认导入兼容处理。 | ## 本地开发 diff --git a/package.json b/package.json index 18e0632..c6b7266 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,10 @@ "repository": "https://github.com/react-component/father-plugin.git", "license": "MIT", "main": "dist/index.js", + "types": "types.d.ts", "files": [ - "dist" + "dist", + "types.d.ts" ], "scripts": { "build": "father build", diff --git a/src/index.ts b/src/index.ts index a877f00..7553222 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,9 +42,24 @@ function checkNpmPackageDependency(packageJson: any, packageName: string) { } export default (api: IApi) => { + // Keep this separate from the shared plugin: false must only disable interop. + api.registerPlugins([ + { + id: 'virtual: rc-cjs-default-interop', + key: 'cjsDefaultInterop', + config: { + default: false, + schema: (joi: any) => joi.boolean().strict(), + }, + }, + ]); + // Compile break if export type without consistent api.onStart(async () => { - if (api.name === 'build' || api.name === 'dev') { + if ( + api.config.cjsDefaultInterop === true && + (api.name === 'build' || api.name === 'dev') + ) { // Father 4 collects addJSTransformer before loading project plugins. // Register after initialization, against the project's actual Father instance. const projectRequire = createRequire(path.join(api.cwd, 'package.json')); diff --git a/src/transformer.ts b/src/transformer.ts index bf4b6a9..63f1469 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -1,4 +1,4 @@ -import type { IJSTransformer } from 'father'; +import type { IFatherConfig, IJSTransformer } from 'father'; import { createRequire } from 'module'; import path from 'path'; import defaultInterop from './defaultInterop'; @@ -12,7 +12,13 @@ const transformer: Transformer = async function (content) { `father/dist/builder/bundless/loaders/javascript/${this.config.transformer}`, ); const result = await (original.default || original).call(this, content); - if (this.config.format !== 'esm' || this.config.platform !== 'node') + const config = this.config as typeof this.config & + Pick; + if ( + config.cjsDefaultInterop !== true || + config.format !== 'esm' || + config.platform !== 'node' + ) return result; return defaultInterop(result[0], this.paths.fileAbsPath, result[1]); }; diff --git a/test/defaultInterop.test.js b/test/defaultInterop.test.js index c5934e0..88f4cdb 100644 --- a/test/defaultInterop.test.js +++ b/test/defaultInterop.test.js @@ -217,6 +217,7 @@ async function compile(directory, source, options = {}) { transformer: 'esbuild', format: 'esm', platform: 'node', + cjsDefaultInterop: true, sourcemap: true, ...options, }, @@ -270,43 +271,157 @@ test('type-only imports are removed before interop analysis', async () => { assert.doesNotMatch(code, /rcDefaultInterop|any-legacy-package|@example/); }); -test('returns the original compiler output for CJS and browser builds', async () => { +test('returns the original compiler output when disabled, or for CJS and browser builds', async () => { const { directory } = fixture(); const source = `import Component from 'any-legacy-package'; export default Component;`; const original = require('father/dist/builder/bundless/loaders/javascript/esbuild').default; - for (const options of [{ format: 'cjs' }, { platform: 'browser' }]) { + for (const options of [ + { cjsDefaultInterop: undefined }, + { cjsDefaultInterop: false }, + { format: 'cjs' }, + { platform: 'browser' }, + ]) { const { result, context } = await compile(directory, source, options); assert.deepEqual(result, await original.call(context, source)); } }); -test('a real Father build uses default esbuild with the plugin registered', async () => { +test('a real Father build opts in with default esbuild and invalidates the cache when toggled', async () => { const { directory } = fixture(); fs.mkdirSync(path.join(directory, 'src')); fs.writeFileSync( path.join(directory, 'src/index.ts'), `import Component from 'any-legacy-package'; export default Component;`, ); + const entry = path.join(directory, 'es/index.mjs'); + let original; + let enabled; + for (const option of [undefined, true, false, true, undefined]) { + fs.writeFileSync( + path.join(directory, '.fatherrc.ts'), + `export default ${JSON.stringify({ + plugins: [require.resolve('../dist')], + cjsDefaultInterop: option, + esm: { platform: 'node', autoExtension: true }, + })};`, + ); + const log = execFileSync( + process.execPath, + [require.resolve('father/bin/father.js'), 'build'], + { + cwd: directory, + env: { + ...process.env, + FATHER_CACHE: 'true', + FATHER_CACHE_DIR: path.join(directory, '.cache'), + }, + stdio: 'pipe', + encoding: 'utf8', + }, + ); + assert.ok(fs.existsSync(entry), log); + // The shared plugin's output defaults still apply even when interop is false. + assert.ok(fs.existsSync(path.join(directory, 'lib/index.js')), log); + const code = fs.readFileSync(entry, 'utf8'); + if (option === true) { + assert.match(code, /rcDefaultInterop/); + enabled ??= code; + assert.equal(code, enabled); + } else { + assert.doesNotMatch(code, /rcDefaultInterop/); + original ??= code; + assert.equal(code, original); + } + const consume = `import Component from './es/index.mjs'; + console.log(${option === true ? 'Component()' : 'Component.default()'});`; + assert.equal( + execFileSync(process.execPath, ['--input-type=module', '-e', consume], { + cwd: directory, + encoding: 'utf8', + }).trim(), + 'cjs', + ); + } + assert.ok( + fs.readdirSync(path.join(directory, '.cache/bundless-loader')).length, + ); +}); + +test('opting out preserves explicit .default access and downstream ESM live bindings', async () => { + for (const cjsDefaultInterop of [undefined, false]) { + const { directory, add, cjs } = fixture(); + add('switch-entry', { 'index.js': cjs }); + const source = `import Legacy from 'any-legacy-package'; + import Value, { update } from 'switch-entry'; + export const explicit = Legacy.default(); + export const read = () => Value; + export { Value as current, update };`; + const { + result: [code], + } = await compile(directory, source, { cjsDefaultInterop }); + assert.doesNotMatch(code, /rcDefaultInterop/); + // Model a downstream resolver choosing ESM after the library was built against CJS. + add( + 'switch-entry', + { + 'index.js': `let value = 1; export { value as default }; + export function update() { value = 2; }`, + }, + { type: 'module' }, + ); + const entry = path.join(directory, 'compiled.mjs'); + fs.writeFileSync(entry, code); + const consumer = await import(pathToFileURL(entry).href); + assert.equal(consumer.explicit, 'cjs'); + assert.equal(consumer.read(), 1); + assert.equal(consumer.current, 1); + consumer.update(); + assert.equal(consumer.read(), 2); + assert.equal(consumer.current, 2); + } +}); + +test('the published declaration supports the opt-in in Father defineConfig', () => { + const { directory, add } = fixture(); + add( + '@rc-component/father-plugin', + { + 'types.d.ts': fs.readFileSync( + path.join(__dirname, '../types.d.ts'), + 'utf8', + ), + }, + { types: 'types.d.ts' }, + ); + const config = path.join(directory, '.fatherrc.ts'); fs.writeFileSync( - path.join(directory, '.fatherrc.ts'), - `export default ${JSON.stringify({ - plugins: [require.resolve('../dist')], + config, + `import type {} from '@rc-component/father-plugin'; + import { defineConfig } from 'father'; + export default defineConfig({ + plugins: ['@rc-component/father-plugin'], + cjsDefaultInterop: true, esm: { platform: 'node', autoExtension: true }, - })};`, + }); + defineConfig({ cjsDefaultInterop: false }); + defineConfig({ + // @ts-expect-error Only booleans are accepted. + cjsDefaultInterop: 'true', + });`, ); - const log = execFileSync( + execFileSync( process.execPath, - [require.resolve('father/bin/father.js'), 'build'], - { - cwd: directory, - env: { ...process.env, FATHER_CACHE: 'none' }, - stdio: 'pipe', - encoding: 'utf8', - }, + [ + require.resolve('typescript/bin/tsc'), + '--noEmit', + '--skipLibCheck', + '--module', + 'commonjs', + '--target', + 'es2020', + config, + ], + { cwd: directory, stdio: 'pipe' }, ); - const entry = path.join(directory, 'es/index.mjs'); - assert.ok(fs.existsSync(entry), log); - assert.match(fs.readFileSync(entry, 'utf8'), /rcDefaultInterop/); - assert.equal((await import(pathToFileURL(entry).href)).default(), 'cjs'); }); diff --git a/types.d.ts b/types.d.ts new file mode 100644 index 0000000..79d128a --- /dev/null +++ b/types.d.ts @@ -0,0 +1,15 @@ +import type { IApi } from 'father'; + +declare module 'father/dist/types' { + interface IFatherConfig { + /** + * Normalize transpiled CommonJS default imports in Node ESM output. + * Changes default-import semantics; see the plugin README before enabling. + * @default false + */ + cjsDefaultInterop?: boolean; + } +} + +declare const plugin: (api: IApi) => void; +export default plugin; From 1d2bf5d48ba5fadde42440786f68b3f60183c8ca Mon Sep 17 00:00:00 2001 From: zoomdong <1344492820@qq.com> Date: Mon, 7 Sep 2026 17:50:13 +0800 Subject: [PATCH 5/5] refactor: simplify default interop plumbing --- src/defaultInterop.ts | 22 +++++----- src/index.ts | 3 +- src/transformer.ts | 25 +++++------ test/defaultInterop.test.js | 84 ++++++++++++++++++------------------- 4 files changed, 62 insertions(+), 72 deletions(-) diff --git a/src/defaultInterop.ts b/src/defaultInterop.ts index 1a2b521..5d52a95 100644 --- a/src/defaultInterop.ts +++ b/src/defaultInterop.ts @@ -18,9 +18,8 @@ function commonJSExports( filename: string, seen = new Set(), ): Set { - if (seen.has(filename)) return new Set(); + if (seen.has(filename) || !/\.c?js$/.test(filename)) return new Set(); seen.add(filename); - if (!/\.(?:c?js)$/.test(filename)) return new Set(); try { const { exports, reexports } = parseCommonJS( @@ -144,16 +143,15 @@ ${declarations.join('\n')} const map = sourceMap ? remapping( [ - JSON.parse( - output - .generateMap({ - source: importer, - includeContent: true, - hires: true, - }) - .toString(), - ), - JSON.parse(sourceMap), + { + version: 3, + ...output.generateDecodedMap({ + source: importer, + includeContent: true, + hires: true, + }), + }, + sourceMap, ], () => null, ).toString() diff --git a/src/index.ts b/src/index.ts index 7553222..f372846 100644 --- a/src/index.ts +++ b/src/index.ts @@ -86,8 +86,7 @@ export default (api: IApi) => { process.exit(1); } - const inputFolder = - api?.config?.esm?.input || api?.config?.esm?.input || 'src/'; + const inputFolder = api.config.esm?.input || 'src/'; const isEslintInstalled = checkNpmPackageDependency(packageJson, 'eslint'); if (isEslintInstalled) { diff --git a/src/transformer.ts b/src/transformer.ts index 63f1469..085e389 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -7,20 +7,17 @@ type Transformer = NonNullable; // Delegate to Father's compiler so its JSX, aliases, targets, and source maps stay in effect. const transformer: Transformer = async function (content) { - const loadCompiler = createRequire(path.join(this.paths.cwd, 'package.json')); - const original = loadCompiler( - `father/dist/builder/bundless/loaders/javascript/${this.config.transformer}`, - ); - const result = await (original.default || original).call(this, content); - const config = this.config as typeof this.config & - Pick; - if ( - config.cjsDefaultInterop !== true || - config.format !== 'esm' || - config.platform !== 'node' - ) - return result; - return defaultInterop(result[0], this.paths.fileAbsPath, result[1]); + const { config, paths } = this; + const loadCompiler = createRequire(path.join(paths.cwd, 'package.json')); + const compile = loadCompiler( + `father/dist/builder/bundless/loaders/javascript/${config.transformer}`, + ).default; + const result = await compile.call(this, content); + return (config as IFatherConfig).cjsDefaultInterop === true && + config.format === 'esm' && + config.platform === 'node' + ? defaultInterop(result[0], paths.fileAbsPath, result[1]) + : result; }; export default transformer; diff --git a/test/defaultInterop.test.js b/test/defaultInterop.test.js index 88f4cdb..6fdfbc2 100644 --- a/test/defaultInterop.test.js +++ b/test/defaultInterop.test.js @@ -11,26 +11,37 @@ const transformer = require('../dist/transformer').default; const fixtures = []; afterEach(() => { - fixtures - .splice(0) - .forEach((directory) => - fs.rmSync(directory, { recursive: true, force: true }), - ); + for (const directory of fixtures.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } }); +function writeFile(file, content) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync( + file, + typeof content === 'string' ? content : JSON.stringify(content), + ); + return file; +} + +function loadOutput(directory, code) { + const entry = writeFile(path.join(directory, 'compiled.mjs'), code); + return import(pathToFileURL(entry).href); +} + function fixture() { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'rc-interop-')); fixtures.push(directory); const add = (name, files, config = {}) => { const root = path.join(directory, 'node_modules', name); - fs.mkdirSync(root, { recursive: true }); - fs.writeFileSync( - path.join(root, 'package.json'), - JSON.stringify({ name, main: 'index.js', ...config }), - ); + writeFile(path.join(root, 'package.json'), { + name, + main: 'index.js', + ...config, + }); for (const [file, code] of Object.entries(files)) { - fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); - fs.writeFileSync(path.join(root, file), code); + writeFile(path.join(root, file), code); } }; const cjs = `Object.defineProperty(exports, '__esModule', { value: true }); @@ -52,14 +63,11 @@ function fixture() { path.join(directory, 'node_modules/father'), 'junction', ); - fs.writeFileSync( - path.join(directory, 'package.json'), - JSON.stringify({ - name: 'interop-fixture', - version: '1.0.0', - type: 'commonjs', - }), - ); + writeFile(path.join(directory, 'package.json'), { + name: 'interop-fixture', + version: '1.0.0', + type: 'commonjs', + }); add( 'dual-package', { @@ -78,10 +86,8 @@ function fixture() { } async function run(directory, source) { - const entry = path.join(directory, 'consumer.mjs'); const [code] = normalize(source, path.join(directory, 'src.ts')); - fs.writeFileSync(entry, code); - return { module: await import(pathToFileURL(entry).href), code }; + return { module: await loadOutput(directory, code), code }; } test('handles arbitrary package names, scoped subpaths, and CommonJS re-export entries', async () => { @@ -189,9 +195,7 @@ test('checks runtime values when a downstream resolver selects another entry', a { 'index.js': `export default ${JSON.stringify(value)};` }, { type: 'module' }, ); - const entry = path.join(directory, 'compiled.mjs'); - fs.writeFileSync(entry, code); - assert.equal((await import(pathToFileURL(entry).href)).default, value); + assert.equal((await loadOutput(directory, code)).default, value); } }); @@ -206,12 +210,10 @@ test('handles cyclic CommonJS re-exports and ignores unrecognized export structu }); async function compile(directory, source, options = {}) { - const file = path.join(directory, 'entry.ts'); - fs.writeFileSync(file, source); - fs.writeFileSync( - path.join(directory, 'tsconfig.json'), - JSON.stringify({ compilerOptions: { target: 'ES2020' } }), - ); + const file = writeFile(path.join(directory, 'entry.ts'), source); + writeFile(path.join(directory, 'tsconfig.json'), { + compilerOptions: { target: 'ES2020' }, + }); const context = { config: { transformer: 'esbuild', @@ -250,9 +252,7 @@ for (const compiler of ['esbuild', 'babel', 'swc']) { }); assert.equal(position.line, 2); assert.ok(position.source.endsWith('entry.ts')); - const entry = path.join(directory, 'compiled.mjs'); - fs.writeFileSync(entry, code); - assert.equal((await import(pathToFileURL(entry).href)).result, 'cjs'); + assert.equal((await loadOutput(directory, code)).result, 'cjs'); }); } @@ -289,8 +289,7 @@ test('returns the original compiler output when disabled, or for CJS and browser test('a real Father build opts in with default esbuild and invalidates the cache when toggled', async () => { const { directory } = fixture(); - fs.mkdirSync(path.join(directory, 'src')); - fs.writeFileSync( + writeFile( path.join(directory, 'src/index.ts'), `import Component from 'any-legacy-package'; export default Component;`, ); @@ -298,7 +297,7 @@ test('a real Father build opts in with default esbuild and invalidates the cache let original; let enabled; for (const option of [undefined, true, false, true, undefined]) { - fs.writeFileSync( + writeFile( path.join(directory, '.fatherrc.ts'), `export default ${JSON.stringify({ plugins: [require.resolve('../dist')], @@ -370,9 +369,7 @@ test('opting out preserves explicit .default access and downstream ESM live bind }, { type: 'module' }, ); - const entry = path.join(directory, 'compiled.mjs'); - fs.writeFileSync(entry, code); - const consumer = await import(pathToFileURL(entry).href); + const consumer = await loadOutput(directory, code); assert.equal(consumer.explicit, 'cjs'); assert.equal(consumer.read(), 1); assert.equal(consumer.current, 1); @@ -394,9 +391,8 @@ test('the published declaration supports the opt-in in Father defineConfig', () }, { types: 'types.d.ts' }, ); - const config = path.join(directory, '.fatherrc.ts'); - fs.writeFileSync( - config, + const config = writeFile( + path.join(directory, '.fatherrc.ts'), `import type {} from '@rc-component/father-plugin'; import { defineConfig } from 'father'; export default defineConfig({