diff --git a/docs/pages/esm.md b/docs/pages/esm.md index 57e1c978f..731a10adc 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 + +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 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. + ## 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/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 5891e0eb3..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, }); }; @@ -267,3 +273,95 @@ 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 { + 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 { + await buildLibrary(root, { + packageJson: { + type: undefined, + imports: { '#internal/*': './src/internal/*.ts' }, + }, + files: { 'src/index.ts': 'export type Foo = 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); + } +}); + +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/targets/typescript.ts b/packages/react-native-builder-bob/src/targets/typescript.ts index 784f495a5..38dcc7fdf 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?: { @@ -357,25 +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); - await fs.writeJSON(path.join(outputs.commonjs, 'package.json'), { - type: 'commonjs', - }); - await fs.writeJSON(path.join(outputs.module, 'package.json'), { - type: 'module', - }); - } else if (outputs?.commonjs) { - await fs.writeJSON(path.join(outputs.commonjs, 'package.json'), { + } + + if (outputs.commonjs) { + await writePackageType({ + root, + output: outputs.commonjs, type: 'commonjs', - }); - } else if (outputs?.module) { - await fs.writeJSON(path.join(outputs.module, 'package.json'), { - type: 'module', + pkg, + variants, + report, }); } if (outputs.module) { + await writePackageType({ + root, + output: outputs.module, + type: 'module', + pkg, + variants, + report, + }); + 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 d3955b063..f221e9e8b 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,13 @@ 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, + variants, + 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..a9da9f894 --- /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, Variants } from '../types.ts'; + +type Options = { + root: string; + output: string; + type: 'commonjs' | 'module'; + pkg: { type?: unknown; imports?: unknown }; + variants: Variants; + report: Report; +}; + +/** + * 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. + */ +export async function writePackageType({ + root, + output, + type, + pkg, + variants, + report, +}: Options) { + const relativeOutput = path.relative(root, output); + const isInsideRoot = + !relativeOutput.startsWith('..') && !path.isAbsolute(relativeOutput); + + if (pkg.type === type && isInsideRoot) { + return; + } + + const hasImports = Object.keys(pkg.imports ?? {}).length > 0; + + // 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' + )} 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 }); +}