diff --git a/docs/dependencies.md b/docs/dependencies.md index c5735c9ab..91d4174aa 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -23,6 +23,7 @@ The following type describes the configuration of a dependency that can be set u ```ts type DependencyConfig = { + autolinkTransitiveDependencies?: boolean; platforms: { android?: AndroidDependencyParams; ios?: IOSDependencyParams; @@ -37,6 +38,10 @@ type DependencyConfig = { A map of specific settings that can be set per platform. The exact shape is always defined by the package that provides given platform. +### autolinkTransitiveDependencies + +When set to `true`, the CLI will inspect the dependency's `peerDependencies` and attempt to autolink any peers that are also React Native native modules. The CLI does not install those peers for the user, but they will be linked automatically whenever they are present in `node_modules`. Use this if your library relies on a native peer dependency (for example, [`react-native-nitro-text`](https://github.com/patrickkabwe/react-native-nitro-text) depending on [`react-native-nitro-modules`](https://github.com/mrousavy/nitro)) and would otherwise require users to manually add that peer. + In most cases, as a library author, you should not need to define any of these. The following settings are available on iOS and Android: diff --git a/packages/cli-config/src/__tests__/index-test.ts b/packages/cli-config/src/__tests__/index-test.ts index 3740e8d1c..82f3926b3 100644 --- a/packages/cli-config/src/__tests__/index-test.ts +++ b/packages/cli-config/src/__tests__/index-test.ts @@ -358,6 +358,54 @@ module.exports = { `); }); +test('autolinks transitive peer dependencies when enabled by a library', async () => { + DIR = getTempDirectory('config_test_transitive_peers'); + writeFiles(DIR, { + ...REACT_NATIVE_MOCK, + 'package.json': `{ + "dependencies": { + "react-native": "0.0.1", + "react-native-nitro-text": "0.0.1" + } + }`, + 'node_modules/react-native-nitro-text/package.json': `{ + "name": "react-native-nitro-text", + "peerDependencies": { + "react-native-nitro-modules": "1.0.0" + } + }`, + 'node_modules/react-native-nitro-text/react-native.config.js': `module.exports = { + dependency: { + autolinkTransitiveDependencies: true, + }, + };`, + 'node_modules/react-native-nitro-modules/package.json': `{ + "name": "react-native-nitro-modules", + "version": "1.0.0" + }`, + 'node_modules/react-native-nitro-modules/ReactNativeNitroModules.podspec': + '', + 'node_modules/react-native-nitro-modules/react-native.config.js': `module.exports = { + dependency: { + platforms: { + ios: { + podspecPath: "./ReactNativeNitroModules.podspec", + }, + }, + }, + };`, + }); + + const config = await loadConfigAsync({projectRoot: DIR}); + expect(Object.keys(config.dependencies)).toEqual( + expect.arrayContaining([ + 'react-native', + 'react-native-nitro-text', + 'react-native-nitro-modules', + ]), + ); +}); + test('should apply build types from dependency config', async () => { DIR = getTempDirectory('config_test_apply_dependency_config'); writeFiles(DIR, { diff --git a/packages/cli-config/src/loadConfig.ts b/packages/cli-config/src/loadConfig.ts index b01c2d03b..41923849c 100644 --- a/packages/cli-config/src/loadConfig.ts +++ b/packages/cli-config/src/loadConfig.ts @@ -1,3 +1,5 @@ +import fs from 'fs'; +import {promises as fsPromises} from 'fs'; import path from 'path'; import { UserDependencyConfig, @@ -31,10 +33,15 @@ function getDependencyConfig( config: UserDependencyConfig, userConfig: UserConfig, ): DependencyConfig { + const {autolinkTransitiveDependencies} = config.dependency; + return merge( { root, name: dependencyName, + ...(autolinkTransitiveDependencies !== undefined + ? {autolinkTransitiveDependencies} + : {}), platforms: Object.keys(finalConfig.platforms).reduce( (dependency, platform) => { const platformConfig = finalConfig.platforms[platform]; @@ -84,6 +91,62 @@ const removeDuplicateCommands = (commands: Command[]) => { return Array.from(uniqueCommandsMap.values()); }; +const getUserAutolinkOverride = ( + dependencyName: string, + userConfig: UserConfig, +) => { + const userDependencyConfig = userConfig.dependencies[dependencyName]; + if ( + userDependencyConfig && + typeof userDependencyConfig === 'object' && + Object.prototype.hasOwnProperty.call( + userDependencyConfig, + 'autolinkTransitiveDependencies', + ) + ) { + const value = userDependencyConfig.autolinkTransitiveDependencies; + if (typeof value === 'boolean') { + return value; + } + } + return undefined; +}; + +const shouldAutolinkTransitiveDependencies = ( + dependencyName: string, + dependencyConfig: UserDependencyConfig, + userConfig: UserConfig, +) => { + const override = getUserAutolinkOverride(dependencyName, userConfig); + if (typeof override === 'boolean') { + return override; + } + + return dependencyConfig.dependency.autolinkTransitiveDependencies === true; +}; + +const getPeerDependenciesSync = (dependencyRoot: string) => { + try { + const packageJsonPath = path.join(dependencyRoot, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return Object.keys(packageJson.peerDependencies || {}); + } catch { + return []; + } +}; + +const getPeerDependenciesAsync = async (dependencyRoot: string) => { + try { + const packageJsonPath = path.join(dependencyRoot, 'package.json'); + const packageJson = JSON.parse( + await fsPromises.readFile(packageJsonPath, 'utf8'), + ); + return Object.keys(packageJson.peerDependencies || {}); + } catch { + return []; + } +}; + /** * Loads CLI configuration */ @@ -132,51 +195,89 @@ export default function loadConfig({ }, }; - const finalConfig = Array.from( - new Set([ - ...Object.keys(userConfig.dependencies), - ...findDependencies(projectRoot), - ]), - ).reduce((acc: Config, dependencyName) => { + const queuedDependencies = new Set([ + ...Object.keys(userConfig.dependencies), + ...findDependencies(projectRoot), + ]); + const queue = Array.from(queuedDependencies); + const processedDependencies = new Set(); + + let finalConfig: Config = initialConfig; + + while (queue.length > 0) { + const dependencyName = queue.shift() as string; + + if (processedDependencies.has(dependencyName)) { + continue; + } + + const currentConfig = finalConfig; + + processedDependencies.add(dependencyName); + const localDependencyRoot = userConfig.dependencies[dependencyName] && userConfig.dependencies[dependencyName].root; try { - let root = + const root = localDependencyRoot || resolveNodeModuleDir(projectRoot, dependencyName); - let config = readDependencyConfigFromDisk(root, dependencyName); + const dependencyConfig = readDependencyConfigFromDisk( + root, + dependencyName, + ); - return assign({}, acc, { - dependencies: assign({}, acc.dependencies, { + const nextConfig = assign({}, currentConfig, { + dependencies: assign({}, currentConfig.dependencies, { get [dependencyName](): DependencyConfig { return getDependencyConfig( root, dependencyName, finalConfig, - config, + dependencyConfig, userConfig, ); }, }), commands: removeDuplicateCommands([ - ...config.commands, - ...acc.commands, + ...dependencyConfig.commands, + ...currentConfig.commands, ]), platforms: { - ...acc.platforms, - ...(selectedPlatform && config.platforms[selectedPlatform] - ? {[selectedPlatform]: config.platforms[selectedPlatform]} + ...currentConfig.platforms, + ...(selectedPlatform && dependencyConfig.platforms[selectedPlatform] + ? {[selectedPlatform]: dependencyConfig.platforms[selectedPlatform]} : !selectedPlatform - ? config.platforms + ? dependencyConfig.platforms : undefined), }, - healthChecks: [...acc.healthChecks, ...config.healthChecks], + healthChecks: [ + ...currentConfig.healthChecks, + ...dependencyConfig.healthChecks, + ], }) as Config; + + finalConfig = nextConfig; + + if ( + shouldAutolinkTransitiveDependencies( + dependencyName, + dependencyConfig, + userConfig, + ) + ) { + const peerDependencies = getPeerDependenciesSync(root); + for (const peerDependency of peerDependencies) { + if (!queuedDependencies.has(peerDependency)) { + queuedDependencies.add(peerDependency); + queue.push(peerDependency); + } + } + } } catch { - return acc; + continue; } - }, initialConfig); + } return finalConfig; } @@ -230,55 +331,89 @@ export async function loadConfigAsync({ }, }; - const finalConfig = await Array.from( - new Set([ - ...Object.keys(userConfig.dependencies), - ...findDependencies(projectRoot), - ]), - ).reduce(async (accPromise: Promise, dependencyName) => { - const acc = await accPromise; + const queuedDependencies = new Set([ + ...Object.keys(userConfig.dependencies), + ...findDependencies(projectRoot), + ]); + const queue = Array.from(queuedDependencies); + const processedDependencies = new Set(); + + let finalConfig: Config = initialConfig; + + while (queue.length > 0) { + const dependencyName = queue.shift() as string; + + if (processedDependencies.has(dependencyName)) { + continue; + } + + const currentConfig = finalConfig; + + processedDependencies.add(dependencyName); + const localDependencyRoot = userConfig.dependencies[dependencyName] && userConfig.dependencies[dependencyName].root; try { - let root = + const root = localDependencyRoot || resolveNodeModuleDir(projectRoot, dependencyName); - let config = await readDependencyConfigFromDiskAsync( + const dependencyConfig = await readDependencyConfigFromDiskAsync( root, dependencyName, ); - return assign({}, acc, { - dependencies: assign({}, acc.dependencies, { + const nextConfig = assign({}, currentConfig, { + dependencies: assign({}, currentConfig.dependencies, { get [dependencyName](): DependencyConfig { return getDependencyConfig( root, dependencyName, finalConfig, - config, + dependencyConfig, userConfig, ); }, }), commands: removeDuplicateCommands([ - ...config.commands, - ...acc.commands, + ...dependencyConfig.commands, + ...currentConfig.commands, ]), platforms: { - ...acc.platforms, - ...(selectedPlatform && config.platforms[selectedPlatform] - ? {[selectedPlatform]: config.platforms[selectedPlatform]} + ...currentConfig.platforms, + ...(selectedPlatform && dependencyConfig.platforms[selectedPlatform] + ? {[selectedPlatform]: dependencyConfig.platforms[selectedPlatform]} : !selectedPlatform - ? config.platforms + ? dependencyConfig.platforms : undefined), }, - healthChecks: [...acc.healthChecks, ...config.healthChecks], + healthChecks: [ + ...currentConfig.healthChecks, + ...dependencyConfig.healthChecks, + ], }) as Config; + + finalConfig = nextConfig; + + if ( + shouldAutolinkTransitiveDependencies( + dependencyName, + dependencyConfig, + userConfig, + ) + ) { + const peerDependencies = await getPeerDependenciesAsync(root); + for (const peerDependency of peerDependencies) { + if (!queuedDependencies.has(peerDependency)) { + queuedDependencies.add(peerDependency); + queue.push(peerDependency); + } + } + } } catch { - return acc; + continue; } - }, Promise.resolve(initialConfig)); + } return finalConfig; } diff --git a/packages/cli-config/src/schema.ts b/packages/cli-config/src/schema.ts index 0eb8e5013..bdb675063 100644 --- a/packages/cli-config/src/schema.ts +++ b/packages/cli-config/src/schema.ts @@ -64,6 +64,7 @@ export const dependencyConfig = t .object({ dependency: t .object({ + autolinkTransitiveDependencies: t.boolean(), platforms: map(t.string(), t.any()) .keys({ ios: t @@ -120,6 +121,7 @@ export const projectConfig = t t .object({ root: t.string(), + autolinkTransitiveDependencies: t.boolean(), platforms: map(t.string(), t.any()).keys({ ios: t // IOSDependencyConfig diff --git a/packages/cli-types/src/index.ts b/packages/cli-types/src/index.ts index 1e07ae297..00a2d972d 100644 --- a/packages/cli-types/src/index.ts +++ b/packages/cli-types/src/index.ts @@ -99,6 +99,7 @@ export type ProjectConfig = { export interface DependencyConfig { name: string; root: string; + autolinkTransitiveDependencies?: boolean; platforms: { android?: Exclude< ReturnType,