diff --git a/__e2e__/config.test.ts b/__e2e__/config.test.ts index ecd954409..322b63829 100644 --- a/__e2e__/config.test.ts +++ b/__e2e__/config.test.ts @@ -35,7 +35,14 @@ function createCorruptedSetupEnvScript() { }; } -beforeAll(() => { +const modifyPackageJson = (dir: string, key: string, value: string) => { + const packageJsonPath = path.join(dir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + packageJson[key] = value; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)); +}; + +beforeEach(() => { // Clean up folder and re-create a new project cleanup(DIR); writeFiles(DIR, {}); @@ -122,6 +129,34 @@ module.exports = { }; `; +const USER_CONFIG_TS = ` +export default { + commands: [ + { + name: 'test-command-ts', + description: 'test command', + func: () => { + console.log('test-command-ts'); + }, + }, + ], +}; +`; + +const USER_CONFIG_ESM = ` +export default { + commands: [ + { + name: 'test-command-esm', + description: 'test command', + func: () => { + console.log('test-command-esm'); + }, + }, + ], +}; +`; + test('should read user config from react-native.config.js', () => { writeFiles(path.join(DIR, 'TestProject'), { 'react-native.config.js': USER_CONFIG, @@ -133,9 +168,110 @@ test('should read user config from react-native.config.js', () => { test('should read user config from react-native.config.ts', () => { writeFiles(path.join(DIR, 'TestProject'), { - 'react-native.config.ts': USER_CONFIG, + 'react-native.config.ts': USER_CONFIG_TS, + }); + + const {stdout} = runCLI(path.join(DIR, 'TestProject'), ['test-command-ts']); + expect(stdout).toBe('test-command-ts'); +}); + +test('should read user config from react-native.config.mjs', () => { + writeFiles(path.join(DIR, 'TestProject'), { + 'react-native.config.mjs': USER_CONFIG_ESM, + }); + + const {stdout} = runCLI(path.join(DIR, 'TestProject'), ['test-command-esm']); + expect(stdout).toBe('test-command-esm'); +}); + +test('should fail if using require() in ES module in react-native.config.mjs', () => { + writeFiles(path.join(DIR, 'TestProject'), { + 'react-native.config.mjs': ` + const packageJSON = require('./package.json'); + ${USER_CONFIG_ESM} + `, + }); + + const {stderr, stdout} = runCLI(path.join(DIR, 'TestProject'), [ + 'test-command-esm', + ]); + expect(stderr).toMatch('error Failed to load configuration of your project'); + expect(stdout).toMatch( + 'ReferenceError: require is not defined in ES module scope, you can use import instead', + ); +}); + +test('should fail if using require() in ES module with "type": "module" in package.json', () => { + writeFiles(path.join(DIR, 'TestProject'), { + 'react-native.config.js': ` + const packageJSON = require('./package.json'); + ${USER_CONFIG_ESM} + `, }); + modifyPackageJson(path.join(DIR, 'TestProject'), 'type', 'module'); + + const {stderr} = runCLI(path.join(DIR, 'TestProject'), ['test-command-esm']); + console.log(stderr); + expect(stderr).toMatch('error Failed to load configuration of your project'); +}); + +test('should read config if using createRequire() helper in react-native.config.js with "type": "module" in package.json', () => { + writeFiles(path.join(DIR, 'TestProject'), { + 'react-native.config.js': ` + import { createRequire } from 'node:module'; + const require = createRequire(import.meta.url); + const packageJSON = require('./package.json'); + + ${USER_CONFIG_ESM} + `, + }); + + modifyPackageJson(path.join(DIR, 'TestProject'), 'type', 'module'); + + const {stdout} = runCLI(path.join(DIR, 'TestProject'), ['test-command-esm']); + expect(stdout).toBe('test-command-esm'); +}); + +test('should read config if using require() in react-native.config.cjs with "type": "module" in package.json', () => { + writeFiles(path.join(DIR, 'TestProject'), { + 'react-native.config.cjs': ` + const packageJSON = require('./package.json'); + ${USER_CONFIG} + `, + }); + + modifyPackageJson(path.join(DIR, 'TestProject'), 'type', 'module'); + const {stdout} = runCLI(path.join(DIR, 'TestProject'), ['test-command']); - expect(stdout).toBe('test-command'); + expect(stdout).toMatch('test-command'); +}); + +test('should read config if using import/export in react-native.config.js with "type": "module" package.json', () => { + writeFiles(path.join(DIR, 'TestProject'), { + 'react-native.config.js': ` + import {} from 'react'; + ${USER_CONFIG_ESM} + `, + }); + + modifyPackageJson(path.join(DIR, 'TestProject'), 'type', 'module'); + + const {stdout} = runCLI(path.join(DIR, 'TestProject'), ['test-command-esm']); + expect(stdout).toMatch('test-command-esm'); +}); + +test('should read config if using import/export in react-native.config.mjs with "type": "commonjs" package.json', () => { + writeFiles(path.join(DIR, 'TestProject'), { + 'react-native.config.mjs': ` + import {} from 'react'; + + ${USER_CONFIG_ESM} + `, + }); + + modifyPackageJson(path.join(DIR, 'TestProject'), 'type', 'commonjs'); + + const {stdout} = runCLI(path.join(DIR, 'TestProject'), ['test-command-esm']); + expect(stdout).toMatch('test-command-esm'); }); diff --git a/packages/cli-config/src/__tests__/index-test.ts b/packages/cli-config/src/__tests__/index-test.ts index e1c1c1c1b..c11f6e877 100644 --- a/packages/cli-config/src/__tests__/index-test.ts +++ b/packages/cli-config/src/__tests__/index-test.ts @@ -1,6 +1,6 @@ import path from 'path'; import slash from 'slash'; -import loadConfig from '..'; +import {loadConfigAsync} from '..'; import {cleanup, writeFiles, getTempDirectory} from '../../../../jest/helpers'; let DIR = getTempDirectory('config_test'); @@ -59,18 +59,18 @@ beforeEach(async () => { afterEach(() => cleanup(DIR)); -test('should have a valid structure by default', () => { +test('should have a valid structure by default', async () => { DIR = getTempDirectory('config_test_structure'); writeFiles(DIR, { 'react-native.config.js': `module.exports = { reactNativePath: "." }`, }); - const config = loadConfig({projectRoot: DIR}); + const config = await loadConfigAsync({projectRoot: DIR}); expect(removeString(config, DIR)).toMatchSnapshot(); }); -test('should return dependencies from package.json', () => { +test('should return dependencies from package.json', async () => { DIR = getTempDirectory('config_test_deps'); writeFiles(DIR, { ...REACT_NATIVE_MOCK, @@ -83,11 +83,11 @@ test('should return dependencies from package.json', () => { } }`, }); - const {dependencies} = loadConfig({projectRoot: DIR}); + const {dependencies} = await loadConfigAsync({projectRoot: DIR}); expect(removeString(dependencies, DIR)).toMatchSnapshot(); }); -test('should read a config of a dependency and use it to load other settings', () => { +test('should read a config of a dependency and use it to load other settings', async () => { DIR = getTempDirectory('config_test_settings'); writeFiles(DIR, { ...REACT_NATIVE_MOCK, @@ -122,13 +122,13 @@ test('should read a config of a dependency and use it to load other settings', ( } }`, }); - const {dependencies} = loadConfig({projectRoot: DIR}); + const {dependencies} = await loadConfigAsync({projectRoot: DIR}); expect( removeString(dependencies['react-native-test'], DIR), ).toMatchSnapshot(); }); -test('command specified in root config should overwrite command in "react-native-foo" and "react-native-bar" packages', () => { +test('command specified in root config should overwrite command in "react-native-foo" and "react-native-bar" packages', async () => { DIR = getTempDirectory('config_test_packages'); writeFiles(DIR, { 'node_modules/react-native-foo/package.json': '{}', @@ -173,7 +173,7 @@ test('command specified in root config should overwrite command in "react-native ], };`, }); - const {commands} = loadConfig({projectRoot: DIR}); + const {commands} = await loadConfigAsync({projectRoot: DIR}); const commandsNames = commands.map(({name}) => name); const commandIndex = commandsNames.indexOf('foo-command'); @@ -181,7 +181,7 @@ test('command specified in root config should overwrite command in "react-native expect(commands[commandIndex]).toMatchSnapshot(); }); -test('should merge project configuration with default values', () => { +test('should merge project configuration with default values', async () => { DIR = getTempDirectory('config_test_merge'); writeFiles(DIR, { ...REACT_NATIVE_MOCK, @@ -206,13 +206,13 @@ test('should merge project configuration with default values', () => { } }`, }); - const {dependencies} = loadConfig({projectRoot: DIR}); + const {dependencies} = await loadConfigAsync({projectRoot: DIR}); expect(removeString(dependencies['react-native-test'], DIR)).toMatchSnapshot( 'snapshoting `react-native-test` config', ); }); -test('should load commands from "react-native-foo" and "react-native-bar" packages', () => { +test('should load commands from "react-native-foo" and "react-native-bar" packages', async () => { DIR = getTempDirectory('config_test_packages'); writeFiles(DIR, { 'react-native.config.js': 'module.exports = { reactNativePath: "." }', @@ -241,11 +241,11 @@ test('should load commands from "react-native-foo" and "react-native-bar" packag } }`, }); - const {commands} = loadConfig({projectRoot: DIR}); + const {commands} = await loadConfigAsync({projectRoot: DIR}); expect(commands).toMatchSnapshot(); }); -test('should not skip packages that have invalid configuration (to avoid breaking users)', () => { +test('should not skip packages that have invalid configuration (to avoid breaking users)', async () => { process.env.FORCE_COLOR = '0'; // To disable chalk DIR = getTempDirectory('config_test_skip'); writeFiles(DIR, { @@ -261,14 +261,14 @@ test('should not skip packages that have invalid configuration (to avoid breakin } }`, }); - const {dependencies} = loadConfig({projectRoot: DIR}); + const {dependencies} = await loadConfigAsync({projectRoot: DIR}); expect(removeString(dependencies, DIR)).toMatchSnapshot( 'dependencies config', ); expect(spy.mock.calls[0][0]).toMatchSnapshot('logged warning'); }); -test('does not use restricted "react-native" key to resolve config from package.json', () => { +test('does not use restricted "react-native" key to resolve config from package.json', async () => { DIR = getTempDirectory('config_test_restricted'); writeFiles(DIR, { 'node_modules/react-native-netinfo/package.json': `{ @@ -281,12 +281,12 @@ test('does not use restricted "react-native" key to resolve config from package. } }`, }); - const {dependencies} = loadConfig({projectRoot: DIR}); + const {dependencies} = await loadConfigAsync({projectRoot: DIR}); expect(dependencies).toHaveProperty('react-native-netinfo'); expect(spy).not.toHaveBeenCalled(); }); -test('supports dependencies from user configuration with custom root and properties', () => { +test('supports dependencies from user configuration with custom root and properties', async () => { DIR = getTempDirectory('config_test_custom_root'); const escapePathSeparator = (value: string) => path.sep === '\\' ? value.replace(/(\/|\\)/g, '\\\\') : value; @@ -327,7 +327,7 @@ module.exports = { }`, }); - const {dependencies} = loadConfig({projectRoot: DIR}); + const {dependencies} = await loadConfigAsync({projectRoot: DIR}); expect(removeString(dependencies['local-lib'], DIR)).toMatchInlineSnapshot(` Object { "name": "local-lib", @@ -345,7 +345,7 @@ module.exports = { `); }); -test('should apply build types from dependency config', () => { +test('should apply build types from dependency config', async () => { DIR = getTempDirectory('config_test_apply_dependency_config'); writeFiles(DIR, { ...REACT_NATIVE_MOCK, @@ -367,13 +367,13 @@ test('should apply build types from dependency config', () => { } }`, }); - const {dependencies} = loadConfig({projectRoot: DIR}); + const {dependencies} = await loadConfigAsync({projectRoot: DIR}); expect( removeString(dependencies['react-native-test'], DIR), ).toMatchSnapshot(); }); -test('supports dependencies from user configuration with custom build type', () => { +test('supports dependencies from user configuration with custom build type', async () => { DIR = getTempDirectory('config_test_apply_custom_build_config'); writeFiles(DIR, { ...REACT_NATIVE_MOCK, @@ -400,13 +400,13 @@ test('supports dependencies from user configuration with custom build type', () }`, }); - const {dependencies} = loadConfig({projectRoot: DIR}); + const {dependencies} = await loadConfigAsync({projectRoot: DIR}); expect( removeString(dependencies['react-native-test'], DIR), ).toMatchSnapshot(); }); -test('supports disabling dependency for ios platform', () => { +test('supports disabling dependency for ios platform', async () => { DIR = getTempDirectory('config_test_disable_dependency_platform'); writeFiles(DIR, { ...REACT_NATIVE_MOCK, @@ -429,13 +429,13 @@ test('supports disabling dependency for ios platform', () => { }`, }); - const {dependencies} = loadConfig({projectRoot: DIR}); + const {dependencies} = await loadConfigAsync({projectRoot: DIR}); expect( removeString(dependencies['react-native-test'], DIR), ).toMatchSnapshot(); }); -test('should convert project sourceDir relative path to absolute', () => { +test('should convert project sourceDir relative path to absolute', async () => { DIR = getTempDirectory('config_test_absolute_project_source_dir'); const iosProjectDir = './ios2'; const androidProjectDir = './android2'; @@ -494,7 +494,7 @@ test('should convert project sourceDir relative path to absolute', () => { `, }); - const config = loadConfig({projectRoot: DIR}); + const config = await loadConfigAsync({projectRoot: DIR}); expect(config.project.ios?.sourceDir).toBe(path.join(DIR, iosProjectDir)); expect(config.project.android?.sourceDir).toBe( diff --git a/packages/cli-config/src/index.ts b/packages/cli-config/src/index.ts index f12851649..6c806cad8 100644 --- a/packages/cli-config/src/index.ts +++ b/packages/cli-config/src/index.ts @@ -1,5 +1,6 @@ import config from './commands/config'; export {default} from './loadConfig'; +export {loadConfigAsync} from './loadConfig'; export const commands = [config]; diff --git a/packages/cli-config/src/loadConfig.ts b/packages/cli-config/src/loadConfig.ts index d0a9ceac8..9ebc71ee6 100644 --- a/packages/cli-config/src/loadConfig.ts +++ b/packages/cli-config/src/loadConfig.ts @@ -17,7 +17,9 @@ import findDependencies from './findDependencies'; import resolveReactNativePath from './resolveReactNativePath'; import { readConfigFromDisk, + readConfigFromDiskAsync, readDependencyConfigFromDisk, + readDependencyConfigFromDiskAsync, } from './readConfigFromDisk'; import assign from './assign'; import merge from './merge'; @@ -85,7 +87,7 @@ const removeDuplicateCommands = (commands: Command[]) => { /** * Loads CLI configuration */ -function loadConfig({ +export default function loadConfig({ projectRoot = findProjectRoot(), selectedPlatform, }: { @@ -177,4 +179,102 @@ function loadConfig({ return finalConfig; } -export default loadConfig; +/** + * Load CLI configuration asynchronously, which supports reading ESM modules. + */ + +export async function loadConfigAsync({ + projectRoot = findProjectRoot(), + selectedPlatform, +}: { + projectRoot?: string; + selectedPlatform?: string; +}): Promise { + let lazyProject: ProjectConfig; + const userConfig = await readConfigFromDiskAsync(projectRoot); + + const initialConfig: Config = { + root: projectRoot, + get reactNativePath() { + return userConfig.reactNativePath + ? path.resolve(projectRoot, userConfig.reactNativePath) + : resolveReactNativePath(projectRoot); + }, + get reactNativeVersion() { + return getReactNativeVersion(initialConfig.reactNativePath); + }, + dependencies: userConfig.dependencies, + commands: userConfig.commands, + healthChecks: userConfig.healthChecks || [], + platforms: userConfig.platforms, + assets: userConfig.assets, + get project() { + if (lazyProject) { + return lazyProject; + } + + lazyProject = {}; + for (const platform in finalConfig.platforms) { + const platformConfig = finalConfig.platforms[platform]; + if (platformConfig) { + lazyProject[platform] = platformConfig.projectConfig( + projectRoot, + userConfig.project[platform] || {}, + ); + } + } + + return lazyProject; + }, + }; + + const finalConfig = await Array.from( + new Set([ + ...Object.keys(userConfig.dependencies), + ...findDependencies(projectRoot), + ]), + ).reduce(async (accPromise: Promise, dependencyName) => { + const acc = await accPromise; + const localDependencyRoot = + userConfig.dependencies[dependencyName] && + userConfig.dependencies[dependencyName].root; + try { + let root = + localDependencyRoot || + resolveNodeModuleDir(projectRoot, dependencyName); + let config = await readDependencyConfigFromDiskAsync( + root, + dependencyName, + ); + + return assign({}, acc, { + dependencies: assign({}, acc.dependencies, { + get [dependencyName](): DependencyConfig { + return getDependencyConfig( + root, + dependencyName, + finalConfig, + config, + userConfig, + ); + }, + }), + commands: removeDuplicateCommands([ + ...config.commands, + ...acc.commands, + ]), + platforms: { + ...acc.platforms, + ...(selectedPlatform && config.platforms[selectedPlatform] + ? {[selectedPlatform]: config.platforms[selectedPlatform]} + : config.platforms), + }, + healthChecks: [...acc.healthChecks, ...config.healthChecks], + }) as Config; + } catch { + return acc; + } + }, Promise.resolve(initialConfig)); + + return finalConfig; +} diff --git a/packages/cli-config/src/readConfigFromDisk.ts b/packages/cli-config/src/readConfigFromDisk.ts index db47c74fd..0379dce4e 100644 --- a/packages/cli-config/src/readConfigFromDisk.ts +++ b/packages/cli-config/src/readConfigFromDisk.ts @@ -1,4 +1,4 @@ -import {cosmiconfigSync} from 'cosmiconfig'; +import {cosmiconfig, cosmiconfigSync} from 'cosmiconfig'; import {JoiError} from './errors'; import * as schema from './schema'; import { @@ -11,12 +11,43 @@ import chalk from 'chalk'; /** * Places to look for the configuration file. */ -const searchPlaces = ['react-native.config.js', 'react-native.config.ts']; +const searchPlaces = [ + 'react-native.config.js', + 'react-native.config.cjs', + 'react-native.config.mjs', + 'react-native.config.ts', + 'react-native.config.mjs', +]; /** * Reads a project configuration as defined by the user in the current * workspace. */ +export async function readConfigFromDiskAsync( + rootFolder: string, +): Promise { + const explorer = cosmiconfig('react-native', { + stopDir: rootFolder, + searchPlaces, + }); + + const searchResult = await explorer.search(rootFolder); + + const config = searchResult ? searchResult.config : undefined; + const result = schema.projectConfig.validate(config); + + if (result.error) { + throw new JoiError(result.error); + } + + return result.value as UserConfig; +} + +/** + * Reads a project configuration as defined by the user in the current + * workspace synchronously. + */ + export function readConfigFromDisk(rootFolder: string): UserConfig { const explorer = cosmiconfigSync('react-native', { stopDir: rootFolder, @@ -24,6 +55,7 @@ export function readConfigFromDisk(rootFolder: string): UserConfig { }); const searchResult = explorer.search(rootFolder); + const config = searchResult ? searchResult.config : undefined; const result = schema.projectConfig.validate(config); @@ -38,6 +70,42 @@ export function readConfigFromDisk(rootFolder: string): UserConfig { * Reads a dependency configuration as defined by the developer * inside `node_modules`. */ +export async function readDependencyConfigFromDiskAsync( + rootFolder: string, + dependencyName: string, +): Promise { + const explorer = cosmiconfig('react-native', { + stopDir: rootFolder, + searchPlaces, + }); + + const searchResult = await explorer.search(rootFolder); + const config = searchResult ? searchResult.config : emptyDependencyConfig; + + const result = schema.dependencyConfig.validate(config, {abortEarly: false}); + + if (result.error) { + const validationError = new JoiError(result.error); + logger.warn( + inlineString(` + Package ${chalk.bold( + dependencyName, + )} contains invalid configuration: ${chalk.bold( + validationError.message, + )}. + + Please verify it's properly linked using "npx react-native config" command and contact the package maintainers about this.`), + ); + } + + return result.value as UserDependencyConfig; +} + +/** + * Reads a dependency configuration as defined by the developer + * inside `node_modules` synchronously. + */ + export function readDependencyConfigFromDisk( rootFolder: string, dependencyName: string, diff --git a/packages/cli-doctor/src/commands/doctor.ts b/packages/cli-doctor/src/commands/doctor.ts index a8a2162c3..472768e3b 100644 --- a/packages/cli-doctor/src/commands/doctor.ts +++ b/packages/cli-doctor/src/commands/doctor.ts @@ -170,7 +170,7 @@ const doctorCommand = (async (_, options, config) => { Promise.all(categories.map(iterateOverHealthChecks)); const healthchecksPerCategory = await iterateOverCategories( - Object.values(getHealthchecks(options)).filter( + Object.values(await getHealthchecks(options)).filter( (category) => category !== undefined, ) as HealthCheckCategory[], ); diff --git a/packages/cli-doctor/src/tools/healthchecks/index.ts b/packages/cli-doctor/src/tools/healthchecks/index.ts index 2dc9f64b7..52da92141 100644 --- a/packages/cli-doctor/src/tools/healthchecks/index.ts +++ b/packages/cli-doctor/src/tools/healthchecks/index.ts @@ -12,7 +12,7 @@ import xcode from './xcode'; import cocoaPods from './cocoaPods'; import iosDeploy from './iosDeploy'; import {Healthchecks, HealthCheckCategory} from '../../types'; -import loadConfig from '@react-native-community/cli-config'; +import {loadConfigAsync} from '@react-native-community/cli-config'; import xcodeEnv from './xcodeEnv'; import packager from './packager'; import gradle from './gradle'; @@ -29,14 +29,16 @@ type Options = { contributor: boolean | void; }; -export const getHealthchecks = ({contributor}: Options): Healthchecks => { +export const getHealthchecks = async ({ + contributor, +}: Options): Promise => { let additionalChecks: HealthCheckCategory[] = []; let projectSpecificHealthchecks = {}; let config; // Doctor can run in a detached mode, where there isn't a config so this can fail try { - config = loadConfig({}); + config = await loadConfigAsync({}); additionalChecks = config.healthChecks; if (config.reactNativePath) { diff --git a/packages/cli-types/src/index.ts b/packages/cli-types/src/index.ts index f518e8ebf..1e07ae297 100644 --- a/packages/cli-types/src/index.ts +++ b/packages/cli-types/src/index.ts @@ -59,7 +59,7 @@ export type Command = { export type DetachedCommand = Command; -interface PlatformConfig< +export interface PlatformConfig< ProjectConfig, ProjectParams, DependencyConfig, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d9905c40b..4c6e33bc5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,4 @@ -import loadConfig from '@react-native-community/cli-config'; +import loadConfig, {loadConfigAsync} from '@react-native-community/cli-config'; import {CLIError, logger} from '@react-native-community/cli-tools'; import type { Command, @@ -193,7 +193,7 @@ async function setupAndRun(platformName?: string) { } } - config = loadConfig({ + config = await loadConfigAsync({ selectedPlatform, }); @@ -243,4 +243,4 @@ async function setupAndRun(platformName?: string) { const bin = require.resolve('./bin'); -export {run, bin, loadConfig}; +export {run, bin, loadConfig, loadConfigAsync};