From 9c31c24299f8fe5c5b94ce3bf2a82c152df35ca5 Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Wed, 14 Oct 2026 15:27:23 +0200 Subject: [PATCH] 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 }); +}