From 9c31c24299f8fe5c5b94ce3bf2a82c152df35ca5 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Wed, 14 Oct 2026 15:27:23 +0200 Subject: [PATCH 1/2] fix: skip the generated package.json when the root type matches Bob writes a package.json with a `type` field to output folders such as lib/module and lib/typescript. That file starts a new package scope. Node.js, TypeScript and Metro resolve the `imports` field from the closest package.json, so subpath imports used in the source fail to resolve in the built files. When the root package.json already has the same `type`, the generated file isn't needed, so skip it. Also warn when the file is generated and the root package.json has an `imports` field. --- docs/pages/esm.md | 21 +++ .../src/__tests__/typescript.test.ts | 74 +++++++++ .../src/__tests__/writePackageType.test.ts | 142 ++++++++++++++++++ .../src/targets/typescript.ts | 25 ++- .../src/utils/compile.ts | 7 +- .../src/utils/writePackageType.ts | 60 ++++++++ 6 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 packages/react-native-builder-bob/src/__tests__/writePackageType.test.ts create mode 100644 packages/react-native-builder-bob/src/utils/writePackageType.ts diff --git a/docs/pages/esm.md b/docs/pages/esm.md index 57e1c978f..d3c1496e9 100644 --- a/docs/pages/esm.md +++ b/docs/pages/esm.md @@ -140,6 +140,27 @@ Additionally, the `import.meta` syntax is currently not supported in [Metro](htt So be careful when using properties from `import.meta`, as relying on properties only available in specific tools may lock your library into supporting only those specific tools. Also, since this is an ESM-only feature, you should avoid using it if you compile your library to CommonJS as well. +### Subpath imports + +When `esm` is enabled, Bob adds a `package.json` file with the `type` field to the output folders (e.g. `lib/module/package.json`) so that tools know which module system the files use. Node.js, TypeScript and Metro read [subpath imports](https://nodejs.org/api/packages.html#subpath-imports) from the closest `package.json` file, so the `imports` field in your `package.json` won't apply to the files in these folders. + +Bob skips this file when the `type` field in your `package.json` has the same value. So to use the `imports` field, set `type` to `module` in your `package.json`: + +```json +"type": "module", +"imports": { + "#internal/*": { + "my-library-source": "./src/internal/*.ts", + "types": "./lib/typescript/src/internal/*.d.ts", + "default": "./lib/module/internal/*.js" + } +} +``` + +Node.js will then treat the `.js` files in your project as ES modules, so CommonJS config files in the project root (e.g. `babel.config.js`) need to use the `.cjs` extension or ESM syntax. + +In a [dual package setup](#dual-package-setup), the CommonJS build still gets a `package.json` with `"type": "commonjs"`, so subpath imports won't work in the CommonJS build. + ## Dual package setup The previously mentioned setup only works with tools that support ES modules. If you want to support tools that don't support ESM and use the CommonJS module system, you can configure a dual package setup. diff --git a/packages/react-native-builder-bob/src/__tests__/typescript.test.ts b/packages/react-native-builder-bob/src/__tests__/typescript.test.ts index 5891e0eb3..d562655b6 100644 --- a/packages/react-native-builder-bob/src/__tests__/typescript.test.ts +++ b/packages/react-native-builder-bob/src/__tests__/typescript.test.ts @@ -267,3 +267,77 @@ test('keeps platform-specific declaration imports extensionless', async () => { await fs.remove(root); } }); + +test('resolves package imports when package.json has the same type', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-')); + + try { + vi.mocked(report.warn).mockClear(); + + await buildLibrary(root, { + compilerOptions: { customConditions: ['library-source'] }, + packageJson: { + imports: { + '#internal/*': { + 'library-source': './src/internal/*.ts', + types: './lib/typescript/src/internal/*.d.ts', + }, + }, + }, + files: { + 'src/index.ts': "export { type Foo } from '#internal/foo';\n", + 'src/internal/foo.ts': 'export type Foo = { value: string };\n', + }, + }); + + expect( + await fs.pathExists(path.join(root, 'lib/typescript/package.json')) + ).toBe(false); + + expect(report.warn).not.toHaveBeenCalled(); + + const stdout = await typeCheckConsumer( + root, + ["import type { Foo } from 'library';", '', 'type Test = Foo;'].join('\n') + ); + + expect(stdout).toBeUndefined(); + } finally { + await fs.remove(root); + } +}); + +test('warns about package imports when package.json has a different type', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-')); + + try { + vi.mocked(report.warn).mockClear(); + + await buildLibrary(root, { + compilerOptions: { customConditions: ['library-source'] }, + packageJson: { + type: undefined, + imports: { + '#internal/*': { + 'library-source': './src/internal/*.ts', + types: './lib/typescript/src/internal/*.d.ts', + }, + }, + }, + files: { + 'src/index.ts': "export { type Foo } from '#internal/foo';\n", + 'src/internal/foo.ts': 'export type Foo = { value: string };\n', + }, + }); + + expect( + await fs.readJSON(path.join(root, 'lib/typescript/package.json')) + ).toEqual({ type: 'module' }); + + expect(report.warn).toHaveBeenCalledWith( + expect.stringContaining("won't apply to the files in") + ); + } finally { + await fs.remove(root); + } +}); diff --git a/packages/react-native-builder-bob/src/__tests__/writePackageType.test.ts b/packages/react-native-builder-bob/src/__tests__/writePackageType.test.ts new file mode 100644 index 000000000..02dbf072a --- /dev/null +++ b/packages/react-native-builder-bob/src/__tests__/writePackageType.test.ts @@ -0,0 +1,142 @@ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'fs-extra'; +import { expect, test, vi } from 'vitest'; +import type { Report } from '../types.ts'; +import { writePackageType } from '../utils/writePackageType.ts'; + +const createReport = (): Report => ({ + info: vi.fn(), + warn: vi.fn(), + success: vi.fn(), + error: vi.fn(), +}); + +test.each([ + ['module', undefined, { type: 'module' }], + ['module', 'commonjs', { type: 'module' }], + ['module', 'module', undefined], + ['commonjs', undefined, { type: 'commonjs' }], + ['commonjs', 'module', { type: 'commonjs' }], + ['commonjs', 'commonjs', undefined], +] as const)( + 'handles %s output when the root type is %s', + async (type, rootType, expected) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-package-type-')); + const output = path.join(root, 'lib'); + const report = createReport(); + + try { + await fs.mkdirp(output); + await writePackageType({ + root, + output, + type, + pkg: { type: rootType }, + report, + }); + + const packageJsonPath = path.join(output, 'package.json'); + + expect( + (await fs.pathExists(packageJsonPath)) + ? await fs.readJSON(packageJsonPath) + : undefined + ).toEqual(expected); + expect(report.warn).not.toHaveBeenCalled(); + } finally { + await fs.remove(root); + } + } +); + +test('warns about imports only when package.json is written', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-package-type-')); + const output = path.join(root, 'lib'); + const imports = { '#internal/*': './src/internal/*.ts' }; + + try { + await fs.mkdirp(output); + + const skipped = createReport(); + + await writePackageType({ + root, + output, + type: 'module', + pkg: { type: 'module', imports }, + report: skipped, + }); + + expect(skipped.warn).not.toHaveBeenCalled(); + + const written = createReport(); + + await writePackageType({ + root, + output, + type: 'module', + pkg: { imports }, + report: written, + }); + + expect(written.warn).toHaveBeenCalledWith( + expect.stringContaining("won't apply to the files in") + ); + } finally { + await fs.remove(root); + } +}); + +test('does not warn about an empty imports field', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-package-type-')); + const output = path.join(root, 'lib'); + const report = createReport(); + + try { + await fs.mkdirp(output); + await writePackageType({ + root, + output, + type: 'module', + pkg: { imports: {} }, + report, + }); + + expect(await fs.readJSON(path.join(output, 'package.json'))).toEqual({ + type: 'module', + }); + expect(report.warn).not.toHaveBeenCalled(); + } finally { + await fs.remove(root); + } +}); + +test('writes package.json when the output is outside the root', async () => { + const parent = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-package-type-')); + const root = path.join(parent, 'library'); + const output = path.join(parent, 'dist'); + const report = createReport(); + + try { + await fs.mkdirp(root); + await fs.mkdirp(output); + await writePackageType({ + root, + output, + type: 'module', + pkg: { + type: 'module', + imports: { '#internal/*': './src/internal/*.ts' }, + }, + report, + }); + + expect(await fs.readJSON(path.join(output, 'package.json'))).toEqual({ + type: 'module', + }); + expect(report.warn).not.toHaveBeenCalled(); + } finally { + await fs.remove(parent); + } +}); diff --git a/packages/react-native-builder-bob/src/targets/typescript.ts b/packages/react-native-builder-bob/src/targets/typescript.ts index 784f495a5..54943b90e 100644 --- a/packages/react-native-builder-bob/src/targets/typescript.ts +++ b/packages/react-native-builder-bob/src/targets/typescript.ts @@ -16,6 +16,7 @@ import { } from '../utils/resolveModuleSpecifier.ts'; import { spawn } from '../utils/spawn.ts'; import { type Replacement, updateSourceMap } from '../utils/updateSourceMap.ts'; +import { writePackageType } from '../utils/writePackageType.ts'; type Options = Input & { options?: { @@ -359,19 +360,35 @@ export default async function build({ // When ESM compatible output is enabled and commonjs build is present, we need to generate 2 builds for commonjs and esm // In this case we copy the already generated types, and add `package.json` with `type` field await fs.copy(outputs.commonjs, outputs.module); - await fs.writeJSON(path.join(outputs.commonjs, 'package.json'), { + await writePackageType({ + root, + output: outputs.commonjs, type: 'commonjs', + pkg, + report, }); - await fs.writeJSON(path.join(outputs.module, 'package.json'), { + await writePackageType({ + root, + output: outputs.module, type: 'module', + pkg, + report, }); } else if (outputs?.commonjs) { - await fs.writeJSON(path.join(outputs.commonjs, 'package.json'), { + await writePackageType({ + root, + output: outputs.commonjs, type: 'commonjs', + pkg, + report, }); } else if (outputs?.module) { - await fs.writeJSON(path.join(outputs.module, 'package.json'), { + await writePackageType({ + root, + output: outputs.module, type: 'module', + pkg, + report, }); } diff --git a/packages/react-native-builder-bob/src/utils/compile.ts b/packages/react-native-builder-bob/src/utils/compile.ts index d3955b063..ee707594d 100644 --- a/packages/react-native-builder-bob/src/utils/compile.ts +++ b/packages/react-native-builder-bob/src/utils/compile.ts @@ -6,6 +6,7 @@ import * as babel from '@babel/core'; import { glob } from 'glob'; import type { Input, Variants } from '../types.ts'; import { isCodegenSpec } from './isCodegenSpec.ts'; +import { writePackageType } from './writePackageType.ts'; const require = createRequire(import.meta.url); @@ -89,8 +90,12 @@ export default async function compile({ // So we skip generating `package.json` if `esm` is not enabled and `modules` is not `commonjs` // This means that user can't use `type: 'module'` in root `package.json` without enabling `esm` for `module` target if (esm || modules === 'commonjs') { - await fs.writeJSON(path.join(output, 'package.json'), { + await writePackageType({ + root, + output, type: modules === 'commonjs' ? 'commonjs' : 'module', + pkg, + report, }); } diff --git a/packages/react-native-builder-bob/src/utils/writePackageType.ts b/packages/react-native-builder-bob/src/utils/writePackageType.ts new file mode 100644 index 000000000..80f22ece0 --- /dev/null +++ b/packages/react-native-builder-bob/src/utils/writePackageType.ts @@ -0,0 +1,60 @@ +import path from 'node:path'; +import fs from 'fs-extra'; +import kleur from 'kleur'; +import type { Report } from '../types.ts'; + +type Options = { + root: string; + output: string; + type: 'commonjs' | 'module'; + pkg: { type?: unknown; imports?: unknown }; + report: Report; +}; + +/** + * Write a `package.json` with the `type` field to the output folder. + * + * The generated `package.json` also starts a new package scope for the files in it. + * Tools resolve fields such as `imports` from the closest `package.json`, + * so the fields in the root `package.json` don't apply to the built files anymore. + * We skip it when the root `package.json` already has the same `type` and the output is inside the root, + * since it's not needed. + */ +export async function writePackageType({ + root, + output, + type, + pkg, + report, +}: Options) { + const relativeOutput = path.relative(root, output); + const isInsideRoot = + !relativeOutput.startsWith('..') && !path.isAbsolute(relativeOutput); + + if (pkg.type === type && isInsideRoot) { + return; + } + + const hasImports = + typeof pkg.imports === 'object' && + pkg.imports != null && + Object.keys(pkg.imports).length > 0; + + if (hasImports && pkg.type !== type) { + report.warn( + `The ${kleur.blue('imports')} field in ${kleur.blue( + 'package.json' + )} won't apply to the files in ${kleur.blue( + relativeOutput + )}, since a ${kleur.blue('package.json')} with ${kleur.blue( + `"type": "${type}"` + )} is generated there. This file is skipped if the ${kleur.blue( + 'type' + )} field in your ${kleur.blue('package.json')} is set to ${kleur.blue( + `"${type}"` + )}.` + ); + } + + await fs.writeJSON(path.join(output, 'package.json'), { type }); +} From ab2c0f5ada85fc0f8e3f57dc3c3d54e019d538a2 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Wed, 21 Oct 2026 16:33:16 +0200 Subject: [PATCH 2/2] fix: add improvements to work better with root type: module --- docs/pages/esm.md | 4 +- .../templates/common/babel.config.js | 2 +- .../templates/tools/eslint/eslint.config.mjs | 2 +- .../src/__fixtures__/babel.config.js | 14 ++ .../src/__tests__/compile.test.ts | 230 ++++++++++++++++++ .../src/__tests__/typescript.test.ts | 60 +++-- .../src/__tests__/writePackageType.test.ts | 142 ----------- .../src/targets/typescript.ts | 28 +-- .../src/utils/compile.ts | 1 + .../src/utils/writePackageType.ts | 20 +- 10 files changed, 310 insertions(+), 193 deletions(-) create mode 100644 packages/react-native-builder-bob/src/__fixtures__/babel.config.js create mode 100644 packages/react-native-builder-bob/src/__tests__/compile.test.ts delete mode 100644 packages/react-native-builder-bob/src/__tests__/writePackageType.test.ts diff --git a/docs/pages/esm.md b/docs/pages/esm.md index d3c1496e9..731a10adc 100644 --- a/docs/pages/esm.md +++ b/docs/pages/esm.md @@ -142,7 +142,7 @@ So be careful when using properties from `import.meta`, as relying on properties ### Subpath imports -When `esm` is enabled, Bob adds a `package.json` file with the `type` field to the output folders (e.g. `lib/module/package.json`) so that tools know which module system the files use. Node.js, TypeScript and Metro read [subpath imports](https://nodejs.org/api/packages.html#subpath-imports) from the closest `package.json` file, so the `imports` field in your `package.json` won't apply to the files in these folders. +Bob adds a `package.json` file with the `type` field to the output folders (e.g. `lib/module/package.json`) so that tools know which module system the files use. Node.js, TypeScript and Metro read [subpath imports](https://nodejs.org/api/packages.html#subpath-imports) from the closest `package.json` file, so the `imports` field in your `package.json` won't apply to the files in these folders. Bob skips this file when the `type` field in your `package.json` has the same value. So to use the `imports` field, set `type` to `module` in your `package.json`: @@ -157,7 +157,7 @@ Bob skips this file when the `type` field in your `package.json` has the same va } ``` -Node.js will then treat the `.js` files in your project as ES modules, so CommonJS config files in the project root (e.g. `babel.config.js`) need to use the `.cjs` extension or ESM syntax. +Node.js will then treat all `.js` files in your library as ES modules, including published files such as `react-native.config.js`. So config files in the project root need to use ESM syntax (e.g. `export default` instead of `module.exports`). This is especially important for `react-native.config.js`, since React Native CLI ignores a CommonJS version without showing an error, and Expo autolinking doesn't read `react-native.config.cjs`. In a [dual package setup](#dual-package-setup), the CommonJS build still gets a `package.json` with `"type": "commonjs"`, so subpath imports won't work in the CommonJS build. diff --git a/packages/create-react-native-library/templates/common/babel.config.js b/packages/create-react-native-library/templates/common/babel.config.js index 0c05fd696..7e0a212e1 100644 --- a/packages/create-react-native-library/templates/common/babel.config.js +++ b/packages/create-react-native-library/templates/common/babel.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { overrides: [ { exclude: /\/node_modules\//, diff --git a/packages/create-react-native-library/templates/tools/eslint/eslint.config.mjs b/packages/create-react-native-library/templates/tools/eslint/eslint.config.mjs index 16b00bbcd..a2921ac74 100644 --- a/packages/create-react-native-library/templates/tools/eslint/eslint.config.mjs +++ b/packages/create-react-native-library/templates/tools/eslint/eslint.config.mjs @@ -24,6 +24,6 @@ export default defineConfig([ }, }, { - ignores: ['node_modules/', 'lib/'], + ignores: ['node_modules/', 'lib/', 'example/dist/'], }, ]); diff --git a/packages/react-native-builder-bob/src/__fixtures__/babel.config.js b/packages/react-native-builder-bob/src/__fixtures__/babel.config.js new file mode 100644 index 000000000..837d7ac24 --- /dev/null +++ b/packages/react-native-builder-bob/src/__fixtures__/babel.config.js @@ -0,0 +1,14 @@ +export default (api) => ({ + presets: [ + [ + '@babel/preset-env', + { + targets: { node: 'current' }, + modules: api.caller((caller) => caller?.supportsStaticESM) + ? false + : 'commonjs', + }, + ], + '@babel/preset-typescript', + ], +}); diff --git a/packages/react-native-builder-bob/src/__tests__/compile.test.ts b/packages/react-native-builder-bob/src/__tests__/compile.test.ts new file mode 100644 index 000000000..3118095ff --- /dev/null +++ b/packages/react-native-builder-bob/src/__tests__/compile.test.ts @@ -0,0 +1,230 @@ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'fs-extra'; +import { beforeEach, expect, test, vi } from 'vitest'; +import buildCommonJS from '../targets/commonjs.ts'; +import buildModule from '../targets/module.ts'; +import type { Report } from '../types.ts'; +import { spawn } from '../utils/spawn.ts'; + +const report: Report = { + info: vi.fn(), + warn: vi.fn(), + success: vi.fn(), + error: vi.fn(), +}; + +beforeEach(() => { + vi.mocked(report.warn).mockClear(); +}); + +const buildLibrary = async ( + root: string, + { + files, + packageJson, + targets, + output = path.join(root, 'lib'), + }: { + files: Record; + packageJson?: Record; + targets: ('commonjs' | 'module')[]; + output?: string; + } +) => { + await fs.writeJSON(path.join(root, 'package.json'), { + name: 'library', + version: '1.0.0', + ...packageJson, + }); + + await Promise.all( + Object.entries(files).map(async ([name, content]) => + fs.outputFile(path.join(root, name), content) + ) + ); + + const variants = { + commonjs: targets.includes('commonjs'), + module: targets.includes('module'), + }; + + for (const target of targets) { + const build = target === 'commonjs' ? buildCommonJS : buildModule; + + await build({ + root, + source: path.join(root, 'src'), + output: path.join(output, target), + exclude: '**/{__tests__,__fixtures__,__mocks__}/**', + options: { + esm: true, + // The default preset loads the babel plugin from the built files + // So it can't be used when running tests against the source code + configFile: path.resolve( + import.meta.dirname, + '../__fixtures__/babel.config.js' + ), + }, + variants, + report, + }); + } +}; + +const getImportsWarnings = () => + vi + .mocked(report.warn) + .mock.calls.map(([message]) => message) + .filter((message) => message.includes("won't apply to the files in")); + +const readPackageJson = async (output: string): Promise => { + const packageJsonPath = path.join(output, 'package.json'); + + return (await fs.pathExists(packageJsonPath)) + ? fs.readJSON(packageJsonPath) + : undefined; +}; + +test.each([ + ['module', undefined, { type: 'module' }], + ['module', 'commonjs', { type: 'module' }], + ['module', 'module', undefined], + ['commonjs', undefined, { type: 'commonjs' }], + ['commonjs', 'module', { type: 'commonjs' }], + ['commonjs', 'commonjs', undefined], +] as const)( + 'writes package.json for %s output when the root type is %s', + async (target, type, expected) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-compile-')); + + try { + await buildLibrary(root, { + packageJson: { type }, + files: { 'src/index.ts': 'export const foo = 42;\n' }, + targets: [target], + }); + + expect(await readPackageJson(path.join(root, 'lib', target))).toEqual( + expected + ); + } finally { + await fs.remove(root); + } + } +); + +test.each(['module', 'commonjs'] as const)( + 'resolves package imports in %s output when package.json has the same type', + async (target) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-compile-')); + + try { + await buildLibrary(root, { + packageJson: { + type: target, + imports: { + '#internal/*': `./lib/${target}/internal/*.js`, + }, + }, + files: { + 'src/index.ts': [ + "import { foo } from '#internal/foo';", + '', + 'console.log(foo);', + ].join('\n'), + 'src/internal/foo.ts': "export const foo = 'bar';\n", + }, + targets: [target], + }); + + expect(getImportsWarnings()).toEqual([]); + + const stdout = await spawn( + process.execPath, + [path.join(root, 'lib', target, 'index.js')], + { cwd: root } + ); + + expect(stdout).toBe('bar'); + } finally { + await fs.remove(root); + } + } +); + +test.each([ + [['module', 'commonjs'], 'module'], + [['commonjs'], 'commonjs'], +] as const)( + 'warns about package imports for %j targets only in %s output', + async (targets, warned) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-compile-')); + + try { + await buildLibrary(root, { + packageJson: { imports: { '#internal/*': './src/internal/*.ts' } }, + files: { 'src/index.ts': 'export const foo = 42;\n' }, + targets: [...targets], + }); + + expect(getImportsWarnings()).toEqual([ + expect.stringContaining(path.join('lib', warned)), + ]); + } finally { + await fs.remove(root); + } + } +); + +test('does not warn about an empty imports field', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-compile-')); + + try { + await buildLibrary(root, { + packageJson: { imports: {} }, + files: { 'src/index.ts': 'export const foo = 42;\n' }, + targets: ['module'], + }); + + expect(await readPackageJson(path.join(root, 'lib/module'))).toEqual({ + type: 'module', + }); + + expect(getImportsWarnings()).toEqual([]); + } finally { + await fs.remove(root); + } +}); + +test.each(['module', undefined])( + 'writes package.json without warning when the output is outside the project and the root type is %s', + async (type) => { + const parent = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-compile-')); + const root = path.join(parent, 'library'); + + try { + await fs.mkdirp(root); + + await buildLibrary(root, { + packageJson: { + type, + imports: { + '#internal/*': './src/internal/*.ts', + }, + }, + files: { 'src/index.ts': 'export const foo = 42;\n' }, + targets: ['module'], + output: path.join(parent, 'dist'), + }); + + expect(await readPackageJson(path.join(parent, 'dist/module'))).toEqual({ + type: 'module', + }); + + expect(getImportsWarnings()).toEqual([]); + } finally { + await fs.remove(parent); + } + } +); diff --git a/packages/react-native-builder-bob/src/__tests__/typescript.test.ts b/packages/react-native-builder-bob/src/__tests__/typescript.test.ts index d562655b6..87df34178 100644 --- a/packages/react-native-builder-bob/src/__tests__/typescript.test.ts +++ b/packages/react-native-builder-bob/src/__tests__/typescript.test.ts @@ -2,9 +2,9 @@ import os from 'node:os'; import path from 'node:path'; import { decode } from '@jridgewell/sourcemap-codec'; import fs from 'fs-extra'; -import { expect, test, vi } from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; import build from '../targets/typescript.ts'; -import type { Report } from '../types.ts'; +import type { Report, Variants } from '../types.ts'; import { spawn } from '../utils/spawn.ts'; const tsc = path.resolve( @@ -20,6 +20,10 @@ const report: Report = { error: vi.fn(), }; +beforeEach(() => { + vi.mocked(report.warn).mockClear(); +}); + const readDeclarationMap = async (filepath: string) => { const value: unknown = await fs.readJSON(filepath); @@ -47,10 +51,12 @@ const buildLibrary = async ( files, compilerOptions, packageJson, + variants = { module: true }, }: { files: Record; compilerOptions?: Record; packageJson?: Record; + variants?: Variants; } ) => { await fs.writeJSON(path.join(root, 'package.json'), { @@ -90,7 +96,7 @@ const buildLibrary = async ( report, options: { project: 'tsconfig.json', tsc }, esm: true, - variants: { module: true }, + variants, }); }; @@ -272,8 +278,6 @@ test('resolves package imports when package.json has the same type', async () => const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-')); try { - vi.mocked(report.warn).mockClear(); - await buildLibrary(root, { compilerOptions: { customConditions: ['library-source'] }, packageJson: { @@ -311,23 +315,12 @@ test('warns about package imports when package.json has a different type', async const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-')); try { - vi.mocked(report.warn).mockClear(); - await buildLibrary(root, { - compilerOptions: { customConditions: ['library-source'] }, packageJson: { type: undefined, - imports: { - '#internal/*': { - 'library-source': './src/internal/*.ts', - types: './lib/typescript/src/internal/*.d.ts', - }, - }, - }, - files: { - 'src/index.ts': "export { type Foo } from '#internal/foo';\n", - 'src/internal/foo.ts': 'export type Foo = { value: string };\n', + imports: { '#internal/*': './src/internal/*.ts' }, }, + files: { 'src/index.ts': 'export type Foo = string;\n' }, }); expect( @@ -341,3 +334,34 @@ test('warns about package imports when package.json has a different type', async await fs.remove(root); } }); + +test('keeps package.json for commonjs declarations in a dual package setup', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-typescript-')); + + try { + await buildLibrary(root, { + packageJson: { + exports: { + '.': { + types: './lib/typescript/module/src/index.d.ts', + }, + }, + imports: { '#internal/*': './src/internal/*.ts' }, + }, + files: { 'src/index.ts': 'export type Foo = string;\n' }, + variants: { commonjs: true, module: true }, + }); + + expect( + await fs.pathExists(path.join(root, 'lib/typescript/module/package.json')) + ).toBe(false); + + expect( + await fs.readJSON(path.join(root, 'lib/typescript/commonjs/package.json')) + ).toEqual({ type: 'commonjs' }); + + expect(report.warn).not.toHaveBeenCalled(); + } finally { + await fs.remove(root); + } +}); diff --git a/packages/react-native-builder-bob/src/__tests__/writePackageType.test.ts b/packages/react-native-builder-bob/src/__tests__/writePackageType.test.ts deleted file mode 100644 index 02dbf072a..000000000 --- a/packages/react-native-builder-bob/src/__tests__/writePackageType.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import os from 'node:os'; -import path from 'node:path'; -import fs from 'fs-extra'; -import { expect, test, vi } from 'vitest'; -import type { Report } from '../types.ts'; -import { writePackageType } from '../utils/writePackageType.ts'; - -const createReport = (): Report => ({ - info: vi.fn(), - warn: vi.fn(), - success: vi.fn(), - error: vi.fn(), -}); - -test.each([ - ['module', undefined, { type: 'module' }], - ['module', 'commonjs', { type: 'module' }], - ['module', 'module', undefined], - ['commonjs', undefined, { type: 'commonjs' }], - ['commonjs', 'module', { type: 'commonjs' }], - ['commonjs', 'commonjs', undefined], -] as const)( - 'handles %s output when the root type is %s', - async (type, rootType, expected) => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-package-type-')); - const output = path.join(root, 'lib'); - const report = createReport(); - - try { - await fs.mkdirp(output); - await writePackageType({ - root, - output, - type, - pkg: { type: rootType }, - report, - }); - - const packageJsonPath = path.join(output, 'package.json'); - - expect( - (await fs.pathExists(packageJsonPath)) - ? await fs.readJSON(packageJsonPath) - : undefined - ).toEqual(expected); - expect(report.warn).not.toHaveBeenCalled(); - } finally { - await fs.remove(root); - } - } -); - -test('warns about imports only when package.json is written', async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-package-type-')); - const output = path.join(root, 'lib'); - const imports = { '#internal/*': './src/internal/*.ts' }; - - try { - await fs.mkdirp(output); - - const skipped = createReport(); - - await writePackageType({ - root, - output, - type: 'module', - pkg: { type: 'module', imports }, - report: skipped, - }); - - expect(skipped.warn).not.toHaveBeenCalled(); - - const written = createReport(); - - await writePackageType({ - root, - output, - type: 'module', - pkg: { imports }, - report: written, - }); - - expect(written.warn).toHaveBeenCalledWith( - expect.stringContaining("won't apply to the files in") - ); - } finally { - await fs.remove(root); - } -}); - -test('does not warn about an empty imports field', async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-package-type-')); - const output = path.join(root, 'lib'); - const report = createReport(); - - try { - await fs.mkdirp(output); - await writePackageType({ - root, - output, - type: 'module', - pkg: { imports: {} }, - report, - }); - - expect(await fs.readJSON(path.join(output, 'package.json'))).toEqual({ - type: 'module', - }); - expect(report.warn).not.toHaveBeenCalled(); - } finally { - await fs.remove(root); - } -}); - -test('writes package.json when the output is outside the root', async () => { - const parent = await fs.mkdtemp(path.join(os.tmpdir(), 'bob-package-type-')); - const root = path.join(parent, 'library'); - const output = path.join(parent, 'dist'); - const report = createReport(); - - try { - await fs.mkdirp(root); - await fs.mkdirp(output); - await writePackageType({ - root, - output, - type: 'module', - pkg: { - type: 'module', - imports: { '#internal/*': './src/internal/*.ts' }, - }, - report, - }); - - expect(await fs.readJSON(path.join(output, 'package.json'))).toEqual({ - type: 'module', - }); - expect(report.warn).not.toHaveBeenCalled(); - } finally { - await fs.remove(parent); - } -}); diff --git a/packages/react-native-builder-bob/src/targets/typescript.ts b/packages/react-native-builder-bob/src/targets/typescript.ts index 54943b90e..38dcc7fdf 100644 --- a/packages/react-native-builder-bob/src/targets/typescript.ts +++ b/packages/react-native-builder-bob/src/targets/typescript.ts @@ -358,41 +358,31 @@ export default async function build({ if (esm) { if (outputs?.commonjs && outputs?.module) { // When ESM compatible output is enabled and commonjs build is present, we need to generate 2 builds for commonjs and esm - // In this case we copy the already generated types, and add `package.json` with `type` field + // In this case we copy the already generated types await fs.copy(outputs.commonjs, outputs.module); + } + + if (outputs.commonjs) { await writePackageType({ root, output: outputs.commonjs, type: 'commonjs', pkg, + variants, report, }); + } + + if (outputs.module) { await writePackageType({ root, output: outputs.module, type: 'module', pkg, + variants, report, }); - } else if (outputs?.commonjs) { - await writePackageType({ - root, - output: outputs.commonjs, - type: 'commonjs', - pkg, - report, - }); - } else if (outputs?.module) { - await writePackageType({ - root, - output: outputs.module, - type: 'module', - pkg, - report, - }); - } - if (outputs.module) { await rewriteDeclarationImports( outputs.module, source, diff --git a/packages/react-native-builder-bob/src/utils/compile.ts b/packages/react-native-builder-bob/src/utils/compile.ts index ee707594d..f221e9e8b 100644 --- a/packages/react-native-builder-bob/src/utils/compile.ts +++ b/packages/react-native-builder-bob/src/utils/compile.ts @@ -95,6 +95,7 @@ export default async function compile({ output, type: modules === 'commonjs' ? 'commonjs' : 'module', pkg, + variants, report, }); } diff --git a/packages/react-native-builder-bob/src/utils/writePackageType.ts b/packages/react-native-builder-bob/src/utils/writePackageType.ts index 80f22ece0..a9da9f894 100644 --- a/packages/react-native-builder-bob/src/utils/writePackageType.ts +++ b/packages/react-native-builder-bob/src/utils/writePackageType.ts @@ -1,30 +1,29 @@ import path from 'node:path'; import fs from 'fs-extra'; import kleur from 'kleur'; -import type { Report } from '../types.ts'; +import type { Report, Variants } from '../types.ts'; type Options = { root: string; output: string; type: 'commonjs' | 'module'; pkg: { type?: unknown; imports?: unknown }; + variants: Variants; report: Report; }; /** - * Write a `package.json` with the `type` field to the output folder. - * * The generated `package.json` also starts a new package scope for the files in it. * Tools resolve fields such as `imports` from the closest `package.json`, * so the fields in the root `package.json` don't apply to the built files anymore. - * We skip it when the root `package.json` already has the same `type` and the output is inside the root, - * since it's not needed. + * We skip it when the root `package.json` already has the same `type` and the output is inside the root. */ export async function writePackageType({ root, output, type, pkg, + variants, report, }: Options) { const relativeOutput = path.relative(root, output); @@ -35,12 +34,13 @@ export async function writePackageType({ return; } - const hasImports = - typeof pkg.imports === 'object' && - pkg.imports != null && - Object.keys(pkg.imports).length > 0; + const hasImports = Object.keys(pkg.imports ?? {}).length > 0; - if (hasImports && pkg.type !== type) { + // In a dual package setup, one of the builds always needs a `package.json` with a different `type` + // The docs recommend `"type": "module"`, so we only warn for the ESM build to avoid conflicting advice + // Suggesting `"type": "commonjs"` instead doesn't work either, since without `esm` the module build gets no `package.json` + // So the root `type` would apply to its ESM syntax and break NextJS + if (hasImports && isInsideRoot && !(type === 'commonjs' && variants.module)) { report.warn( `The ${kleur.blue('imports')} field in ${kleur.blue( 'package.json'