Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/pages/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module.exports = {
export default {
overrides: [
{
exclude: /\/node_modules\//,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ export default defineConfig([
},
},
{
ignores: ['node_modules/', 'lib/'],
ignores: ['node_modules/', 'lib/', 'example/dist/'],
},
]);
14 changes: 14 additions & 0 deletions packages/react-native-builder-bob/src/__fixtures__/babel.config.js
Original file line number Diff line number Diff line change
@@ -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',
],
});
230 changes: 230 additions & 0 deletions packages/react-native-builder-bob/src/__tests__/compile.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
packageJson?: Record<string, unknown>;
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<unknown> => {
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);
}
}
);
Loading
Loading