From ee3dd9c2303512121becce6e41c2c33c59120977 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Tue, 2 Jul 2024 16:14:59 +0200 Subject: [PATCH] feat: add ESM support for generated project --- .../create-react-native-library/src/index.ts | 2 +- .../templates/common/$package.json | 14 ++- .../templates/common/tsconfig.json | 8 +- .../react-native-builder-bob/babel-preset.js | 12 +- .../react-native-builder-bob/src/index.ts | 58 ++++++++-- .../src/targets/commonjs.ts | 1 - .../src/targets/module.ts | 1 - .../src/targets/typescript.ts | 77 +++++++------ .../src/utils/compile.ts | 108 +++++++++++------- 9 files changed, 182 insertions(+), 99 deletions(-) diff --git a/packages/create-react-native-library/src/index.ts b/packages/create-react-native-library/src/index.ts index c757a8eed..f5db1bff5 100644 --- a/packages/create-react-native-library/src/index.ts +++ b/packages/create-react-native-library/src/index.ts @@ -11,7 +11,7 @@ import prompts, { type PromptObject } from './utils/prompts'; import generateExampleApp from './utils/generateExampleApp'; import { spawn } from './utils/spawn'; -const FALLBACK_BOB_VERSION = '0.20.0'; +const FALLBACK_BOB_VERSION = '0.24.0'; const BINARIES = [ /(gradlew|\.(jar|keystore|png|jpg|gif))$/, diff --git a/packages/create-react-native-library/templates/common/$package.json b/packages/create-react-native-library/templates/common/$package.json index 5cf2ca060..bc2322165 100644 --- a/packages/create-react-native-library/templates/common/$package.json +++ b/packages/create-react-native-library/templates/common/$package.json @@ -2,11 +2,17 @@ "name": "<%- project.slug -%>", "version": "0.1.0", "description": "<%- project.description %>", - "main": "lib/commonjs/index", - "module": "lib/module/index", - "types": "lib/typescript/src/index.d.ts", - "react-native": "src/index", "source": "src/index", + "main": "lib/commonjs/index.cjs", + "module": "lib/module/index.mjs", + "types": "lib/typescript/src/index.d.ts", + "exports": { + ".": { + "types": "./lib/typescript/src/index.d.ts", + "import": "./lib/module/index.mjs", + "require": "./lib/commonjs/index.cjs" + } + }, "files": [ "src", "lib", diff --git a/packages/create-react-native-library/templates/common/tsconfig.json b/packages/create-react-native-library/templates/common/tsconfig.json index cd3d623ba..d76cc5f8c 100644 --- a/packages/create-react-native-library/templates/common/tsconfig.json +++ b/packages/create-react-native-library/templates/common/tsconfig.json @@ -9,9 +9,9 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "jsx": "react", - "lib": ["esnext"], - "module": "esnext", - "moduleResolution": "node", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "Bundler", "noFallthroughCasesInSwitch": true, "noImplicitReturns": true, "noImplicitUseStrict": false, @@ -22,7 +22,7 @@ "resolveJsonModule": true, "skipLibCheck": true, "strict": true, - "target": "esnext", + "target": "ESNext", "verbatimModuleSyntax": true } } diff --git a/packages/react-native-builder-bob/babel-preset.js b/packages/react-native-builder-bob/babel-preset.js index f664c15b1..7f5d51614 100644 --- a/packages/react-native-builder-bob/babel-preset.js +++ b/packages/react-native-builder-bob/babel-preset.js @@ -3,6 +3,8 @@ const browserslist = require('browserslist'); module.exports = function (api, options, cwd) { + const cjs = options.modules === 'commonjs'; + return { presets: [ [ @@ -24,12 +26,20 @@ module.exports = function (api, options, cwd) { node: '18', }, useBuiltIns: false, - modules: options.modules || false, + modules: cjs ? 'commonjs' : false, }, ], require.resolve('@babel/preset-react'), require.resolve('@babel/preset-typescript'), require.resolve('@babel/preset-flow'), ], + plugins: [ + [ + require.resolve('./lib/babel'), + { + extension: cjs ? 'cjs' : 'mjs', + }, + ], + ], }; }; diff --git a/packages/react-native-builder-bob/src/index.ts b/packages/react-native-builder-bob/src/index.ts index b66859f91..55e052a1a 100644 --- a/packages/react-native-builder-bob/src/index.ts +++ b/packages/react-native-builder-bob/src/index.ts @@ -147,16 +147,17 @@ yargs ? targets[0] : undefined; - const entries: { [key: string]: string } = { - 'main': target - ? path.join(output, target, 'index.js') + const entries: { + [key in 'source' | 'main' | 'module' | 'types']?: string; + } = { + source: path.join(source, entryFile), + main: target + ? path.join(output, target, 'index.cjs') : path.join(source, entryFile), - 'react-native': path.join(source, entryFile), - 'source': path.join(source, entryFile), }; if (targets.includes('module')) { - entries.module = path.join(output, 'module', 'index.js'); + entries.module = path.join(output, 'module', 'index.mjs'); } if (targets.includes('typescript')) { @@ -181,9 +182,9 @@ yargs esModuleInterop: true, forceConsistentCasingInFileNames: true, jsx: 'react', - lib: ['esnext'], - module: 'esnext', - moduleResolution: 'node', + lib: ['ESNext'], + module: 'ESNext', + moduleResolution: 'Bundler', noFallthroughCasesInSwitch: true, noImplicitReturns: true, noImplicitUseStrict: false, @@ -194,7 +195,7 @@ yargs resolveJsonModule: true, skipLibCheck: true, strict: true, - target: 'esnext', + target: 'ESNext', verbatimModuleSyntax: true, }, }, @@ -214,7 +215,7 @@ yargs ]; for (const key in entries) { - const entry = entries[key]; + const entry = entries[key as keyof typeof entries]; if (pkg[key] && pkg[key] !== entry) { const { replace } = await prompts({ @@ -232,6 +233,41 @@ yargs } } + if (Object.values(entries).some((entry) => entry.endsWith('.ms'))) { + let replace = false; + + if (pkg.exports) { + replace = ( + await prompts({ + type: 'confirm', + name: 'replace', + message: `Your package.json has 'exports' field set. Do you want to replace it?`, + initial: true, + }) + ).replace; + } else { + replace = true; + } + + if (replace) { + pkg.exports = { + '.': {}, + }; + + if (entries.types) { + pkg.exports['.'].types = entries.types; + } + + if (entries.module) { + pkg.exports['.'].import = entries.module; + } + + if (entries.main) { + pkg.exports['.'].require = entries.main; + } + } + } + if (pkg.scripts?.prepare && pkg.scripts.prepare !== prepare) { const { replace } = await prompts({ type: 'confirm', diff --git a/packages/react-native-builder-bob/src/targets/commonjs.ts b/packages/react-native-builder-bob/src/targets/commonjs.ts index 2092906cd..35c9de57f 100644 --- a/packages/react-native-builder-bob/src/targets/commonjs.ts +++ b/packages/react-native-builder-bob/src/targets/commonjs.ts @@ -36,6 +36,5 @@ export default async function build({ exclude, modules: 'commonjs', report, - field: 'main', }); } diff --git a/packages/react-native-builder-bob/src/targets/module.ts b/packages/react-native-builder-bob/src/targets/module.ts index e6cee46c7..5ad1534a6 100644 --- a/packages/react-native-builder-bob/src/targets/module.ts +++ b/packages/react-native-builder-bob/src/targets/module.ts @@ -36,6 +36,5 @@ export default async function build({ exclude, modules: false, report, - field: 'module', }); } diff --git a/packages/react-native-builder-bob/src/targets/typescript.ts b/packages/react-native-builder-bob/src/targets/typescript.ts index 77ec303d1..9d2d9e1a7 100644 --- a/packages/react-native-builder-bob/src/targets/typescript.ts +++ b/packages/react-native-builder-bob/src/targets/typescript.ts @@ -217,43 +217,56 @@ export default async function build({ return null; }; - if ('types' in pkg) { - const typesPath = path.join(root, pkg.types); - - if (!(await fs.pathExists(typesPath))) { - const generatedTypesPath = await getGeneratedTypesPath(); - - if (!generatedTypesPath) { - report.warn( - `Failed to detect the entry point for the generated types. Make sure you have a valid ${kleur.blue( - 'source' - )} field in your ${kleur.blue('package.json')}.` - ); - } - - report.error( - `The ${kleur.blue('types')} field in ${kleur.blue( - 'package.json' - )} points to a non-existent file: ${kleur.blue( - pkg.types - )}.\nVerify the path points to the correct file under ${kleur.blue( - path.relative(root, output) - )}${ - generatedTypesPath - ? ` (found ${kleur.blue(generatedTypesPath)}).` - : '.' - }` - ); + const fields = [ + { name: 'types', value: pkg.types }, + { name: "exports['.'].types", value: pkg.exports?.['.']?.types }, + ]; + + if (fields.some((field) => field.value)) { + await Promise.all( + fields.map(async ({ name, value }) => { + if (!value) { + return; + } - throw new Error("Found incorrect path in 'types' field."); - } + const typesPath = path.join(root, value); + + if (!(await fs.pathExists(typesPath))) { + const generatedTypesPath = await getGeneratedTypesPath(); + + if (!generatedTypesPath) { + report.warn( + `Failed to detect the entry point for the generated types. Make sure you have a valid ${kleur.blue( + 'source' + )} field in your ${kleur.blue('package.json')}.` + ); + } + + report.error( + `The ${kleur.blue(name)} field in ${kleur.blue( + 'package.json' + )} points to a non-existent file: ${kleur.blue( + value + )}.\nVerify the path points to the correct file under ${kleur.blue( + path.relative(root, output) + )}${ + generatedTypesPath + ? ` (found ${kleur.blue(generatedTypesPath)}).` + : '.' + }` + ); + + throw new Error(`Found incorrect path in '${name}' field.`); + } + }) + ); } else { const generatedTypesPath = await getGeneratedTypesPath(); report.warn( - `No ${kleur.blue('types')} field found in ${kleur.blue( - 'package.json' - )}.\nConsider ${ + `No ${kleur.blue( + fields.map((field) => field.name).join(' or ') + )} field found in ${kleur.blue('package.json')}.\nConsider ${ generatedTypesPath ? `pointing it to ${kleur.blue(generatedTypesPath)}` : 'adding it' diff --git a/packages/react-native-builder-bob/src/utils/compile.ts b/packages/react-native-builder-bob/src/utils/compile.ts index 3e0cc8e8c..52a2bd9b6 100644 --- a/packages/react-native-builder-bob/src/utils/compile.ts +++ b/packages/react-native-builder-bob/src/utils/compile.ts @@ -11,7 +11,6 @@ type Options = Input & { sourceMaps?: boolean; copyFlow?: boolean; modules: 'commonjs' | false; - field: 'main' | 'module'; exclude: string; }; @@ -26,7 +25,6 @@ export default async function compile({ copyFlow, sourceMaps = true, report, - field, }: Options) { const files = glob.sync('**/*', { cwd: source, @@ -65,11 +63,13 @@ export default async function compile({ } } + const outputExtension = modules === 'commonjs' ? '.cjs' : '.mjs'; + await Promise.all( files.map(async (filepath) => { const outputFilename = path .join(output, path.relative(source, filepath)) - .replace(/\.(jsx?|tsx?)$/, '.js'); + .replace(/\.(jsx?|tsx?)$/, outputExtension); await fs.mkdirp(path.dirname(outputFilename)); @@ -125,7 +125,8 @@ export default async function compile({ const getGeneratedEntryPath = async () => { if (pkg.source) { const indexName = - path.basename(pkg.source).replace(/\.(jsx?|tsx?)$/, '') + '.js'; + path.basename(pkg.source).replace(/\.(jsx?|tsx?)$/, '') + + outputExtension; const potentialPath = path.join( output, @@ -141,52 +142,71 @@ export default async function compile({ return null; }; - if (field in pkg) { - try { - require.resolve(path.join(root, pkg[field])); - } catch (e: unknown) { - if ( - e != null && - typeof e === 'object' && - 'code' in e && - e.code === 'MODULE_NOT_FOUND' - ) { - const generatedEntryPath = await getGeneratedEntryPath(); - - if (!generatedEntryPath) { - report.warn( - `Failed to detect the entry point for the generated files. Make sure you have a valid ${kleur.blue( - 'source' - )} field in your ${kleur.blue('package.json')}.` - ); + const fields = + modules === 'commonjs' + ? [ + { name: 'main', value: pkg.main }, + { name: "exports['.'].require", value: pkg.exports?.['.']?.require }, + ] + : [ + { name: 'module', value: pkg.module }, + { name: "exports['.'].import", value: pkg.exports?.['.']?.import }, + ]; + + if (fields.some((field) => field.value)) { + await Promise.all( + fields.map(async ({ name, value }) => { + if (!value) { + return; } - report.error( - `The ${kleur.blue(field)} field in ${kleur.blue( - 'package.json' - )} points to a non-existent file: ${kleur.blue( - pkg[field] - )}.\nVerify the path points to the correct file under ${kleur.blue( - path.relative(root, output) - )}${ - generatedEntryPath - ? ` (found ${kleur.blue(generatedEntryPath)}).` - : '.' - }` - ); - - throw new Error(`Found incorrect path in '${field}' field.`); - } - - throw e; - } + try { + require.resolve(path.join(root, value)); + } catch (e: unknown) { + if ( + e != null && + typeof e === 'object' && + 'code' in e && + e.code === 'MODULE_NOT_FOUND' + ) { + const generatedEntryPath = await getGeneratedEntryPath(); + + if (!generatedEntryPath) { + report.warn( + `Failed to detect the entry point for the generated files. Make sure you have a valid ${kleur.blue( + 'source' + )} field in your ${kleur.blue('package.json')}.` + ); + } + + report.error( + `The ${kleur.blue(name)} field in ${kleur.blue( + 'package.json' + )} points to a non-existent file: ${kleur.blue( + value + )}.\nVerify the path points to the correct file under ${kleur.blue( + path.relative(root, output) + )}${ + generatedEntryPath + ? ` (found ${kleur.blue(generatedEntryPath)}).` + : '.' + }` + ); + + throw new Error(`Found incorrect path in '${name}' field.`); + } + + throw e; + } + }) + ); } else { const generatedEntryPath = await getGeneratedEntryPath(); report.warn( - `No ${kleur.blue(field)} field found in ${kleur.blue( - 'package.json' - )}. Consider ${ + `No ${kleur.blue( + fields.map((field) => field.name).join(' or ') + )} field found in ${kleur.blue('package.json')}. Consider ${ generatedEntryPath ? `pointing it to ${kleur.blue(generatedEntryPath)}` : 'adding it'