From 5cc9f6a40e214244feccd697516a53d9f066e92b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 14 Sep 2026 20:12:08 +0200 Subject: [PATCH] feat: detect Swift Package Manager projects React Native 0.87's `react-native spm` migrates the Xcode project to Swift Package Manager and leaves the `Podfile` on disk, which left `config` without a `project.ios.sourceDir` and made `run-ios`/`build-ios` try to install pods. Look for the `.spm-injected.json` marker `react-native spm` writes inside the migrated `.xcodeproj`, expose `project.ios.buildSystem` and skip CocoaPods for those projects unless pods are explicitly requested. Closes #2856 --- __e2e__/__snapshots__/config.test.ts.snap | 3 +- docs/projects.md | 2 + .../src/__tests__/pods.test.ts | 47 ++++++++ .../config/__tests__/getProjectConfig.test.ts | 101 ++++++++++++++++++ .../src/config/findSpmProjectDir.ts | 49 +++++++++ packages/cli-config-apple/src/config/index.ts | 16 +-- packages/cli-config-apple/src/tools/pods.ts | 38 ++++++- .../src/commands/buildCommand/createBuild.ts | 5 +- .../src/commands/runCommand/createRun.ts | 5 +- packages/cli-types/src/index.ts | 2 + packages/cli-types/src/ios.ts | 7 ++ 11 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 packages/cli-config-apple/src/config/findSpmProjectDir.ts diff --git a/__e2e__/__snapshots__/config.test.ts.snap b/__e2e__/__snapshots__/config.test.ts.snap index 1d8bb1c6d..8313269ed 100644 --- a/__e2e__/__snapshots__/config.test.ts.snap +++ b/__e2e__/__snapshots__/config.test.ts.snap @@ -111,7 +111,8 @@ exports[`shows up current config without unnecessary output 1`] = ` "project": { "ios": { "sourceDir": "<>/TestProject/ios", - "assets": [] + "assets": [], + "buildSystem": "cocoapods" }, "android": { "sourceDir": "<>/TestProject/android", diff --git a/docs/projects.md b/docs/projects.md index 2c0537306..75760c205 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -107,6 +107,8 @@ If set to `true`, you can skip running `pod install` manually whenever it's need > Note: Starting from React Native 0.73, CLI's `init` command scaffolds the project with `react-native.config.js` file with this value set to `true` by default. Older projects can opt-in after migrating to 0.73. Please note that if your setup does not follow the standard React Native template, e.g. you are not using Gems to install CocoaPods, this might not work properly for you. Starting from React Native 0.79, users shouldn't install CocoaPods manually, but can still opt-out of automatic installation by setting this value to `false`. +> Note: Projects migrated to Swift Package Manager with `react-native spm` (React Native 0.87+) are detected automatically and CocoaPods is skipped for them, even when a leftover `Podfile` is still there. Pass `--force-pods` or `--only-pods` to run CocoaPods anyway, which is how pods unrelated to React Native can be kept next to Swift Package Manager. + ### project.ios.assets Array of folder paths that will be passed to the `npx react-native link-assets` command to specify the assets to be linked to iOS project. diff --git a/packages/cli-config-apple/src/__tests__/pods.test.ts b/packages/cli-config-apple/src/__tests__/pods.test.ts index 11387bbf6..b783a425d 100644 --- a/packages/cli-config-apple/src/__tests__/pods.test.ts +++ b/packages/cli-config-apple/src/__tests__/pods.test.ts @@ -147,4 +147,51 @@ describe('resolvePods', () => { expect(installPods).toHaveBeenCalled(); }); + + it('should skip CocoaPods when there is no Podfile (SwiftPM project)', async () => { + writeFiles(DIR, {'package.json': JSON.stringify(packageJson)}); + + await expect( + resolvePods(DIR, path.join(DIR, 'ios'), {}, 'ios', ''), + ).resolves.toBe(false); + + expect(installPods).not.toHaveBeenCalled(); + }); + + it('should skip CocoaPods for a Swift Package Manager project keeping a Podfile', async () => { + createTempFiles(); + + await expect( + resolvePods(DIR, path.join(DIR, 'ios'), {}, 'ios', '', { + buildSystem: 'spm', + }), + ).resolves.toBe(false); + + expect(installPods).not.toHaveBeenCalled(); + }); + + it('should install CocoaPods for a Swift Package Manager project when explicitly asked', async () => { + createTempFiles(); + + await expect( + resolvePods(DIR, path.join(DIR, 'ios'), {}, 'ios', '', { + buildSystem: 'spm', + forceInstall: true, + }), + ).resolves.toBe(true); + + expect(installPods).toHaveBeenCalled(); + }); + + it('should fail when pods were explicitly requested but there is no Podfile', async () => { + writeFiles(DIR, {'package.json': JSON.stringify(packageJson)}); + + await expect( + resolvePods(DIR, path.join(DIR, 'ios'), {}, 'ios', '', { + forceInstall: true, + }), + ).rejects.toThrow(/No Podfile found/); + + expect(installPods).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli-config-apple/src/config/__tests__/getProjectConfig.test.ts b/packages/cli-config-apple/src/config/__tests__/getProjectConfig.test.ts index 73ab1db4f..c676a8b1d 100644 --- a/packages/cli-config-apple/src/config/__tests__/getProjectConfig.test.ts +++ b/packages/cli-config-apple/src/config/__tests__/getProjectConfig.test.ts @@ -14,6 +14,13 @@ jest.mock('fs'); const fs = require('fs'); +const xcodeProject = (extra: Record = {}) => ({ + 'project.pbxproj': '', + ...extra, +}); + +// Written inside the `.xcodeproj` bundle by `react-native spm`. +const spmMarker = {'.spm-injected.json': '{}'}; describe('ios::getProjectConfig', () => { beforeAll(() => { fs.__setMockFilesystem({ @@ -34,6 +41,45 @@ describe('ios::getProjectConfig', () => { Podfile: '', }, }, + // Migrated with `react-native spm`, Podfile gone. + spm: { + ios: { + 'TestApp.xcodeproj': xcodeProject(spmMarker), + '.xcode.env': '', + }, + }, + // `react-native spm` strips React Native from the Podfile but leaves it + // on disk, so the marker has precedence. + spmWithLeftoverPodfile: { + ios: { + Podfile: '', + 'TestApp.xcodeproj': xcodeProject(spmMarker), + }, + }, + // Migrated project and leftover Podfile live in different folders. + spmAndStalePodfile: { + 'TestApp.xcodeproj': xcodeProject(spmMarker), + ios: { + Podfile: '', + }, + }, + noMarker: { + ios: { + 'TestApp.xcodeproj': xcodeProject(), + '.xcode.env': '', + }, + }, + spmOutsidePlatformDir: { + 'TestApp.xcodeproj': xcodeProject(spmMarker), + }, + customSpmLocation: { + 'ios-dev': { + 'TestApp.xcodeproj': xcodeProject(spmMarker), + }, + ios: { + 'TestApp.xcodeproj': xcodeProject(), + }, + }, }); }); @@ -45,6 +91,7 @@ describe('ios::getProjectConfig', () => { Object { "assets": Array [], "automaticPodsInstallation": undefined, + "buildSystem": "cocoapods", "sourceDir": "/flat/ios", "watchModeCommandParams": undefined, "xcodeProject": null, @@ -56,10 +103,64 @@ describe('ios::getProjectConfig', () => { Object { "assets": Array [], "automaticPodsInstallation": undefined, + "buildSystem": "cocoapods", "sourceDir": "/multiple/ios", "watchModeCommandParams": undefined, "xcodeProject": null, } `); }); + it('returns project configuration for Swift Package Manager projects without a Podfile', () => { + expect(projectConfig('/spm', {})).toMatchInlineSnapshot(` + Object { + "assets": Array [], + "automaticPodsInstallation": undefined, + "buildSystem": "spm", + "sourceDir": "/spm/ios", + "watchModeCommandParams": undefined, + "xcodeProject": Object { + "isWorkspace": false, + "name": "TestApp.xcodeproj", + "path": ".", + }, + } + `); + }); + it('uses the Swift Package Manager project when a Podfile is left behind', () => { + expect(projectConfig('/spmWithLeftoverPodfile', {})).toMatchObject({ + buildSystem: 'spm', + sourceDir: '/spmWithLeftoverPodfile/ios', + }); + }); + it('prefers the migrated project over a Podfile in another folder', () => { + expect(projectConfig('/spmAndStalePodfile', {})).toMatchObject({ + buildSystem: 'spm', + sourceDir: '/spmAndStalePodfile', + }); + }); + it('returns `null` when there is no Podfile and no migrated Xcode project', () => { + expect(projectConfig('/noMarker', {})).toBe(null); + }); + it('finds a Swift Package Manager project outside of the platform folder', () => { + expect(projectConfig('/spmOutsidePlatformDir', {}).sourceDir).toBe( + '/spmOutsidePlatformDir', + ); + }); + it('uses project.ios.sourceDir as a search location for Swift Package Manager projects', () => { + expect(projectConfig('/customSpmLocation', {sourceDir: 'ios-dev'})) + .toMatchInlineSnapshot(` + Object { + "assets": Array [], + "automaticPodsInstallation": undefined, + "buildSystem": "spm", + "sourceDir": "/customSpmLocation/ios-dev", + "watchModeCommandParams": undefined, + "xcodeProject": Object { + "isWorkspace": false, + "name": "TestApp.xcodeproj", + "path": ".", + }, + } + `); + }); }); diff --git a/packages/cli-config-apple/src/config/findSpmProjectDir.ts b/packages/cli-config-apple/src/config/findSpmProjectDir.ts new file mode 100644 index 000000000..e5f8c6529 --- /dev/null +++ b/packages/cli-config-apple/src/config/findSpmProjectDir.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import fs from 'fs'; +import path from 'path'; +import {ApplePlatform} from '../types'; + +/** + * Written by `react-native spm` (React Native 0.87+) inside the `.xcodeproj` + * bundle it migrated to Swift Package Manager, and removed by + * `react-native spm deinit`. + */ +const SPM_INJECTED_MARKER = '.spm-injected.json'; + +/** + * Returns the folder holding an Xcode project migrated to Swift Package + * Manager, or `null` when there is none. + */ +export default function findSpmProjectDir( + cwd: string, + platformName: ApplePlatform, +): string | null { + const searchDirs = + path.basename(cwd) === platformName + ? [cwd] + : [path.join(cwd, platformName), cwd]; + + return searchDirs.find(isSpmProjectDir) ?? null; +} + +function isSpmProjectDir(dir: string): boolean { + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch { + return false; + } + + return entries.some( + (entry) => + entry.endsWith('.xcodeproj') && + fs.existsSync(path.join(dir, entry, SPM_INJECTED_MARKER)), + ); +} diff --git a/packages/cli-config-apple/src/config/index.ts b/packages/cli-config-apple/src/config/index.ts index e1479c07f..b5390526e 100644 --- a/packages/cli-config-apple/src/config/index.ts +++ b/packages/cli-config-apple/src/config/index.ts @@ -9,6 +9,7 @@ import path from 'path'; import pico from 'picocolors'; import fs from 'fs'; import findPodfilePath from './findPodfilePath'; +import findSpmProjectDir from './findSpmProjectDir'; import findXcodeProject from './findXcodeProject'; import findPodspec from './findPodspec'; import findAllPodfilePaths from './findAllPodfilePaths'; @@ -33,18 +34,20 @@ export const getProjectConfig = } const src = path.join(folder, userConfig.sourceDir ?? ''); - const podfile = findPodfilePath(src, platformName); /** - * In certain repos, the Xcode project can be generated by a tool. - * The only file that we can assume to exist on disk is `Podfile`. + * `react-native spm` leaves the `Podfile` on disk, so the marker it injects + * into the migrated Xcode project takes precedence: React Native comes from + * either Swift Package Manager or CocoaPods, never both. */ - if (!podfile) { + const spmSourceDir = findSpmProjectDir(src, platformName); + const podfile = spmSourceDir ? null : findPodfilePath(src, platformName); + const sourceDir = spmSourceDir ?? (podfile && path.dirname(podfile)); + + if (!sourceDir) { return null; } - const sourceDir = path.dirname(podfile); - const xcodeProject = findXcodeProject(fs.readdirSync(sourceDir)); return { @@ -53,6 +56,7 @@ export const getProjectConfig = xcodeProject, automaticPodsInstallation: userConfig.automaticPodsInstallation, assets: userConfig.assets ?? [], + buildSystem: spmSourceDir ? 'spm' : 'cocoapods', }; }; diff --git a/packages/cli-config-apple/src/tools/pods.ts b/packages/cli-config-apple/src/tools/pods.ts index 8be71a23b..1334f8899 100644 --- a/packages/cli-config-apple/src/tools/pods.ts +++ b/packages/cli-config-apple/src/tools/pods.ts @@ -6,9 +6,11 @@ import { CLIError, cacheManager, getLoader, + logger, } from '@react-native-community/cli-tools'; import installPods from './installPods'; import { + AppleBuildSystem, DependencyConfig, IOSDependencyConfig, } from '@react-native-community/cli-types'; @@ -19,6 +21,7 @@ import execa from 'execa'; interface ResolvePodsOptions { forceInstall?: boolean; newArchEnabled?: boolean; + buildSystem?: AppleBuildSystem; } interface NativeDependencies { @@ -127,9 +130,38 @@ export default async function resolvePods( platformName: ApplePlatform, reactNativePath: string, options?: ResolvePodsOptions, -) { +): Promise { + const podfilePath = path.join(sourceDir, 'Podfile'); + + /** + * A migrated project keeps its `Podfile` on disk, with the React Native + * directives stripped from it. `--force-pods` and `--only-pods` still run + * CocoaPods, for pods that live next to Swift Package Manager. + */ + if (options?.buildSystem === 'spm' && !options.forceInstall) { + logger.debug( + 'Skipping CocoaPods installation: this project uses Swift Package Manager.', + ); + return false; + } + + if (!fs.existsSync(podfilePath)) { + if (options?.forceInstall) { + throw new CLIError( + `No Podfile found in ${pico.bold( + sourceDir, + )}, so CocoaPods cannot be installed. If this project uses Swift Package Manager (React Native 0.87+), run ${pico.bold( + 'react-native spm', + )} and drop the CocoaPods flags.`, + ); + } + logger.debug( + `Skipping CocoaPods installation: no Podfile in ${sourceDir}.`, + ); + return false; + } + const packageJson = getPackageJson(root); - const podfilePath = path.join(sourceDir, 'Podfile'); // sourceDir is calculated based on Podfile location, see getProjectConfig() const podfileLockPath = path.join(sourceDir, 'Podfile.lock'); const platformFolderPath = podfilePath @@ -215,6 +247,8 @@ export default async function resolvePods( ); } } + + return true; } export async function execaPod(args: string[], options?: execa.Options) { diff --git a/packages/cli-platform-apple/src/commands/buildCommand/createBuild.ts b/packages/cli-platform-apple/src/commands/buildCommand/createBuild.ts index eb8d8add4..00409ab7f 100644 --- a/packages/cli-platform-apple/src/commands/buildCommand/createBuild.ts +++ b/packages/cli-platform-apple/src/commands/buildCommand/createBuild.ts @@ -33,7 +33,7 @@ const createBuild = ? await getArchitecture(platformConfig.sourceDir) : undefined; - await resolvePods( + installedPods = await resolvePods( ctx.root, platformConfig.sourceDir, ctx.dependencies, @@ -42,10 +42,9 @@ const createBuild = { forceInstall: args.forcePods || args.onlyPods, newArchEnabled: isAppRunningNewArchitecture, + buildSystem: platformConfig.buildSystem, }, ); - - installedPods = true; } if (args.onlyPods) { diff --git a/packages/cli-platform-apple/src/commands/runCommand/createRun.ts b/packages/cli-platform-apple/src/commands/runCommand/createRun.ts index 4a600b1da..3c3844438 100644 --- a/packages/cli-platform-apple/src/commands/runCommand/createRun.ts +++ b/packages/cli-platform-apple/src/commands/runCommand/createRun.ts @@ -91,7 +91,7 @@ const createRun = ? await getArchitecture(platformConfig.sourceDir) : undefined; - await resolvePods( + installedPods = await resolvePods( ctx.root, platformConfig.sourceDir, ctx.dependencies, @@ -100,10 +100,9 @@ const createRun = { forceInstall: args.forcePods || args.onlyPods, newArchEnabled: isAppRunningNewArchitecture, + buildSystem: platformConfig.buildSystem, }, ); - - installedPods = true; } if (args.onlyPods) { diff --git a/packages/cli-types/src/index.ts b/packages/cli-types/src/index.ts index 1e07ae297..73d20219c 100644 --- a/packages/cli-types/src/index.ts +++ b/packages/cli-types/src/index.ts @@ -1,4 +1,5 @@ import { + AppleBuildSystem, IOSProjectConfig, IOSProjectParams, IOSDependencyConfig, @@ -150,6 +151,7 @@ export type UserDependencyConfig = { }; export { + AppleBuildSystem, IOSProjectConfig, IOSProjectParams, IOSDependencyConfig, diff --git a/packages/cli-types/src/ios.ts b/packages/cli-types/src/ios.ts index b4df9678f..7e0b48c22 100644 --- a/packages/cli-types/src/ios.ts +++ b/packages/cli-types/src/ios.ts @@ -16,12 +16,19 @@ export type IOSProjectInfo = { isWorkspace: boolean; }; +/** + * Where React Native comes from: `cocoapods` by default, `spm` once + * `react-native spm` has migrated the Xcode project. + */ +export type AppleBuildSystem = 'cocoapods' | 'spm'; + export interface IOSProjectConfig { sourceDir: string; xcodeProject: IOSProjectInfo | null; watchModeCommandParams?: string[]; automaticPodsInstallation?: boolean; assets: string[]; + buildSystem?: AppleBuildSystem; } export interface IOSDependencyConfig {