From d816b3c44aacf7dc54d0df4128bc0809a570445f Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sat, 25 Oct 2025 12:35:00 +0200 Subject: [PATCH 1/2] feat: autolink native peer dependencies --- docs/dependencies.md | 5 + .../cli-config/src/__tests__/index-test.ts | 48 ++++ packages/cli-config/src/loadConfig.ts | 217 ++++++++++++++---- packages/cli-config/src/schema.ts | 2 + packages/cli-types/src/index.ts | 1 + 5 files changed, 232 insertions(+), 41 deletions(-) diff --git a/docs/dependencies.md b/docs/dependencies.md index c5735c9ab..d0550c843 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` depending on `react-native-nitro-modules`) 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, From 674be22fa1aecfdf3a0063b8b10cb3d84e5b8dfe Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Sat, 25 Oct 2025 13:04:13 +0200 Subject: [PATCH 2/2] docs: update autolinkTransitiveDependencies section with links to relevant libraries --- docs/dependencies.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dependencies.md b/docs/dependencies.md index d0550c843..91d4174aa 100644 --- a/docs/dependencies.md +++ b/docs/dependencies.md @@ -40,7 +40,7 @@ A map of specific settings that can be set per platform. The exact shape is alwa ### 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` depending on `react-native-nitro-modules`) and would otherwise require users to manually add that peer. +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.