From 73e2c1d9d55dba22f23cd062ccd19d9e5cdf3e7d Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Thu, 22 Oct 2026 18:26:33 +0200 Subject: [PATCH 1/4] Let `spm deinit` take an app back to CocoaPods Converting an app to SwiftPM with `--deintegrate` now moves the Podfile to Podfile.bak instead of deleting React Native lines from it (that edit left invalid Ruby for multi-line calls). `spm deinit` restores the Podfile, undoes the .gitignore lines the conversion wrote, removes empty ReactNativeSPM folders, and says to run `pod install`. The conversion refuses, before changing anything, when the Podfile has pods that are not React Native or when Podfile.bak already exists. --- .../react-native/scripts/setup-apple-spm.js | 298 +++++-- .../scripts/spm/__docs__/README.md | 10 + .../scripts/spm/__docs__/spm-scripts.md | 84 +- .../__tests__/remove-spm-injection-test.js | 122 +++ .../spm/__tests__/setup-apple-spm-test.js | 736 +++++++++++++++++- .../scripts/spm/generate-spm-xcodeproj.js | 32 +- .../react-native/scripts/spm/spm-types.js | 11 +- 7 files changed, 1177 insertions(+), 116 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index b445bb2969b1..a94492b36f8b 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -10,7 +10,11 @@ 'use strict'; -/*:: import type {CliConfigJson, SetupArgs} from './spm/spm-types'; */ +/*:: import type { + CliConfigJson, + GitignoreRecord, + SetupArgs, +} from './spm/spm-types'; */ /** * setup-apple-spm.js – Entry point for setting up Swift Package Manager support @@ -25,13 +29,14 @@ * settings, the Sync build phase) into your * existing .xcodeproj, in place. Idempotent. * Default on first run. `--deintegrate` first - * runs `pod deintegrate` + strips React Native - * from the Podfile (CocoaPods → SPM migration). + * runs `pod deintegrate` + moves the Podfile to + * Podfile.bak (CocoaPods → SPM migration). * update Re-run the pipeline and refresh the existing * injection. Default once a project is injected. * deinit The exact inverse of `add`: surgically remove * only what `add` injected (recorded in * .spm-injected.json) and drop the marker. + * Restores Podfile.bak to Podfile. * scaffold Generate Package.swift for community deps that * lack SPM support. * sync Re-run the build-time autolinking sync and its @@ -52,8 +57,8 @@ * --yes Skip the dirty-pbxproj confirmation prompt. * [add] --xcodeproj Which .xcodeproj to inject into (when several). * [add] --product-name Which app target to inject into (when several). - * [add] --deintegrate Run `pod deintegrate` + strip RN from the - * Podfile before injecting. + * [add] --deintegrate Run `pod deintegrate` + move the Podfile to + * Podfile.bak before injecting. * [advanced] --artifacts Local two-flavor artifact root; * must contain debug/ and release/ cache slots. * [advanced] --download Artifact policy (default: auto). @@ -197,7 +202,7 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { type: 'boolean', default: false, describe: - '[add] Run `pod deintegrate` + strip React Native from the Podfile before injecting (CocoaPods → SPM migration).', + '[add] Run `pod deintegrate` + move the Podfile to Podfile.bak before injecting (CocoaPods → SPM migration).', }) .option('artifacts', { type: 'string', @@ -266,36 +271,100 @@ const SPM_GITIGNORE_ENTRIES = [ 'build/xcframeworks/', '.build/', ]; +const SPM_GITIGNORE_HEADER = + '# SPM – auto-generated at build time (do not commit)'; /** * Ensure the project's .gitignore contains entries for SPM-generated - * directories. Called during init so that generated artifacts are not - * accidentally committed. + * directories, so generated artifacts are not accidentally committed. Returns + * `previous` (the record in the marker, if any) extended with what this run + * wrote, for `spm deinit` to undo. */ -function ensureGitignoreSpmEntries(appRoot /*: string */) { +function ensureGitignoreSpmEntries( + appRoot /*: string */, + previous /*: ?GitignoreRecord */, +) /*: GitignoreRecord */ { const gitignorePath = path.join(appRoot, '.gitignore'); - let content = ''; - if (fs.existsSync(gitignorePath)) { - content = fs.readFileSync(gitignorePath, 'utf8'); - } + const exists = fs.existsSync(gitignorePath); + const content = exists ? fs.readFileSync(gitignorePath, 'utf8') : ''; + const created = previous?.created === true; + const lines = previous?.lines ?? []; const existingEntries = new Set(content.split('\n').map(l => l.trim())); const missing = SPM_GITIGNORE_ENTRIES.filter(e => !existingEntries.has(e)); if (missing.length === 0) { - return; + return {created, lines}; } - const block = [ - '', - '# SPM – auto-generated at build time (do not commit)', - ...missing, - ].join('\n'); + const block = ['', SPM_GITIGNORE_HEADER, ...missing].join('\n'); // Append, ensuring we start on a fresh line const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; fs.writeFileSync(gitignorePath, content + separator + block + '\n', 'utf8'); log(`Updated .gitignore with SPM entries: ${missing.join(', ')}`); + return { + created: created || !exists, + lines: [...lines, SPM_GITIGNORE_HEADER, ...missing], + }; +} + +/** + * The inverse of ensureGitignoreSpmEntries: removes the lines `record` says + * `add` appended (a header with the blank line written before it), and the + * file when `add` created it and nothing else is left. Without a record, as + * in a marker from before it existed, .gitignore is left untouched. + */ +function removeGitignoreSpmEntries( + appRoot /*: string */, + record /*: ?GitignoreRecord */, +) { + const gitignorePath = path.join(appRoot, '.gitignore'); + if ( + record == null || + record.lines.length === 0 || + !fs.existsSync(gitignorePath) + ) { + return; + } + const lines = fs.readFileSync(gitignorePath, 'utf8').split('\n'); + let changedByUser = 0; + // Last occurrence first: `add` only ever appends. + for (const recorded of [...record.lines].reverse()) { + const index = lines.lastIndexOf(recorded); + if (index === -1) { + changedByUser++; + } else if ( + recorded === SPM_GITIGNORE_HEADER && + index > 0 && + lines[index - 1] === '' + ) { + lines.splice(index - 1, 2); + } else { + lines.splice(index, 1); + } + } + const remaining = lines.join('\n'); + if (record.created && remaining.trim() === '') { + fs.rmSync(gitignorePath); + log('Removed .gitignore, which `spm add` created.'); + return; + } + fs.writeFileSync(gitignorePath, remaining, 'utf8'); + if (changedByUser > 0) { + log( + `.gitignore: ${changedByUser} SPM line(s) changed or removed after ` + + '`spm add` were left as they are. Remove any SPM entries you no ' + + 'longer need.', + ); + } else if (record.created) { + log( + 'Removed the SPM entries from .gitignore. Kept the file, which ' + + '`spm add` created, because it now has lines of your own.', + ); + } else { + log('Removed the SPM entries from .gitignore.'); + } } /** @@ -681,6 +750,21 @@ function pbxprojUsesCocoaPods(xcodeprojPath /*: string */) /*: boolean */ { } } +// A web link, because npm does not ship the __docs__ directories. +const KEEPING_NON_RN_PODS_DOCS_URL = + 'https://github.com/facebook/react-native/blob/main/packages/react-native/' + + 'scripts/spm/__docs__/spm-scripts.md#keeping-non-rn-pods'; + +function cocoaPodsIntegratedMessage(xcodeprojPath /*: string */) /*: string */ { + return ( + `${path.basename(xcodeprojPath)} is CocoaPods-integrated. If your ` + + 'Podfile declares only React Native, re-run `spm add --deintegrate` ' + + '(runs `pod deintegrate` and moves the Podfile to Podfile.bak). If it ' + + 'declares other pods, convert by hand as described in "Keeping non-RN ' + + `pods": ${KEEPING_NON_RN_PODS_DOCS_URL}` + ); +} + // True when the Podfile still declares React Native integration — a latent // landmine even after `pod deintegrate` (a future `pod install` re-breaks the // SPM graph). Warned about (not refused) once the pbxproj itself is clean. @@ -694,13 +778,16 @@ function podfileHasRnIntegration(appRoot /*: string */) /*: boolean */ { ); } -// True when the Podfile declares any explicit `pod '...'` (third-party pods). +// True when the Podfile declares any explicit `pod '...'` or `pod('...')` +// (third-party pods), at the start of a line or after a `;`. function podfileHasThirdPartyPods(appRoot /*: string */) /*: boolean */ { const podfilePath = path.join(appRoot, 'Podfile'); if (!fs.existsSync(podfilePath)) { return false; } - return /^\s*pod\s+['"]/m.test(fs.readFileSync(podfilePath, 'utf8')); + return /^(?:[^#\n]*;)?\s*pod\s*\(?\s*['"]/m.test( + fs.readFileSync(podfilePath, 'utf8'), + ); } // The zero-arg "fresh project" safe-gate: auto-`add --deintegrate` ONLY when it @@ -718,7 +805,7 @@ function shouldAutoDeintegrate( if (xcodeprojPath == null || !pbxprojUsesCocoaPods(xcodeprojPath)) { return false; } - if (podfileHasThirdPartyPods(appRoot)) { + if (podfileHasThirdPartyPods(appRoot) || hasPodfileBackup(appRoot)) { return false; } const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -735,10 +822,17 @@ function shouldAutoDeintegrate( return true; } -// Run `pod deintegrate` then strip React Native from the Podfile (leaving any -// non-RN pods). Requires CocoaPods on PATH (fail-loud otherwise). Flag-gated ⇒ -// no prompt ⇒ CI-safe. Does NOT touch the .xcworkspace. +// Run `pod deintegrate` then move the Podfile aside as Podfile.bak, unedited, +// so `spm deinit` can put CocoaPods back exactly as it was. Requires CocoaPods +// on PATH (fail-loud otherwise). Flag-gated ⇒ no prompt ⇒ CI-safe. Does NOT +// touch the .xcworkspace. function runDeintegrate(appRoot /*: string */) /*: void */ { + const podfilePath = path.join(appRoot, 'Podfile'); + const backupPath = path.join(appRoot, 'Podfile.bak'); + if (refuseToOverwritePodfileBackup(appRoot)) { + process.exitCode = 1; + throw new Error('Podfile.bak already exists'); + } try { execFileSync('pod', ['--version'], {stdio: 'ignore'}); } catch { @@ -752,23 +846,62 @@ function runDeintegrate(appRoot /*: string */) /*: void */ { log('Running `pod deintegrate`...'); execFileSync('pod', ['deintegrate'], {cwd: appRoot, stdio: 'inherit'}); - const podfilePath = path.join(appRoot, 'Podfile'); - if (fs.existsSync(podfilePath)) { - const orig = fs.readFileSync(podfilePath, 'utf8'); - const stripped = orig - .split('\n') - .filter( - l => - !/use_react_native!|use_native_modules!|prepare_react_native_project!/.test( - l, - ), - ) - .join('\n'); - if (stripped !== orig) { - fs.writeFileSync(podfilePath, stripped, 'utf8'); - log('Stripped React Native integration from Podfile.'); - } + if (entryExists(podfilePath)) { + fs.renameSync(podfilePath, backupPath); + log('Moved Podfile to Podfile.bak. `spm deinit` restores it.'); + } +} + +// Unlike existsSync, true for a dangling symlink too, which a rename onto +// that path would silently replace. +function entryExists(filePath /*: string */) /*: boolean */ { + try { + fs.lstatSync(filePath); + return true; + } catch { + return false; + } +} + +function hasPodfileBackup(appRoot /*: string */) /*: boolean */ { + return entryExists(path.join(appRoot, 'Podfile.bak')); +} + +// True (after logging) when runDeintegrate would have to overwrite +// Podfile.bak, which may hold the only copy of an earlier Podfile. +function refuseToOverwritePodfileBackup(appRoot /*: string */) /*: boolean */ { + if (!hasPodfileBackup(appRoot)) { + return false; + } + logError( + 'Podfile.bak already exists, and `--deintegrate` will not overwrite it. ' + + 'Move or delete Podfile.bak, then run the command again. ' + + 'Nothing was changed.', + ); + return true; +} + +// The inverse of runDeintegrate's Podfile move. Returns false (after logging) +// when both files exist, so the caller stops before changing anything. +function checkPodfileRestorable(appRoot /*: string */) /*: boolean */ { + if (hasPodfileBackup(appRoot) && entryExists(path.join(appRoot, 'Podfile'))) { + logError( + 'Both Podfile and Podfile.bak exist, so `spm deinit` cannot restore ' + + 'Podfile.bak without overwriting Podfile. Keep the one you want, ' + + 'delete the other, then run `spm deinit` again.', + ); + return false; } + return true; +} + +function restorePodfile(appRoot /*: string */) /*: void */ { + const backupPath = path.join(appRoot, 'Podfile.bak'); + if (!entryExists(backupPath)) { + return; + } + fs.renameSync(backupPath, path.join(appRoot, 'Podfile')); + log('Restored Podfile from Podfile.bak. Next step: run `pod install`.'); } // Pick the .xcodeproj to inject into: --xcodeproj override > a prior in-place @@ -852,6 +985,7 @@ async function setupXcodeproj( reactNativeRoot /*: string */, action /*: string */, cxxApiDefines /*: ReadonlyArray */, + gitignore /*: ?GitignoreRecord */, ) /*: Promise */ { const target = resolveInjectionTarget(args, appRoot); if (target.error != null) { @@ -879,12 +1013,7 @@ async function setupXcodeproj( // Preflight: a still-CocoaPods-integrated pbxproj is the real build-breaker. if (pbxprojUsesCocoaPods(xcodeprojPath)) { - logError( - `${path.basename(xcodeprojPath)} is CocoaPods-integrated. Re-run ` + - '`spm add --deintegrate` to convert it (runs `pod deintegrate` + ' + - 'strips React Native from the Podfile), or run `pod deintegrate` ' + - 'yourself first. Side-by-side non-RN pods are fine.', - ); + logError(cocoaPodsIntegratedMessage(xcodeprojPath)); process.exitCode = 1; throw new Error('CocoaPods-integrated project'); } @@ -930,6 +1059,7 @@ async function setupXcodeproj( artifactsVersionOverride: args.version ?? null, configCommand: resolveConfigCommandToPin(args), cxxApiDefines, + gitignore, }); if (result.status !== 'injected') { logError(`SPM injection failed: ${result.reason}`); @@ -1122,7 +1252,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { ) { log( 'Detected a freshly-scaffolded CocoaPods project — converting to SwiftPM ' + - '(running `pod deintegrate`). Revert with `git` or `spm deinit`.', + '(running `pod deintegrate`). `spm deinit` restores CocoaPods.', ); args.deintegrate = true; } @@ -1137,16 +1267,46 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { args.xcodeprojPath != null ? path.resolve(appRoot, args.xcodeprojPath) : findInjectedXcodeproj(appRoot); - if (xcodeprojPath == null) { - log('No SPM injection found — nothing to remove.'); + if (!checkPodfileRestorable(appRoot)) { + process.exitCode = 1; return; } - const result = removeSpmInjection({appRoot, xcodeprojPath}); - log( - result.status === 'removed' - ? `Removed SPM packages from ${path.basename(xcodeprojPath)}.` - : 'No SPM injection found — nothing to remove.', + // removeSpmInjection drops the marker, and the record with it. + const gitignoreRecord = + xcodeprojPath != null ? readMarker(xcodeprojPath)?.gitignore : null; + if ( + xcodeprojPath != null && + removeSpmInjection({appRoot, xcodeprojPath}).status === 'removed' + ) { + log(`Removed SPM packages from ${path.basename(xcodeprojPath)}.`); + } else if (hasPodfileBackup(appRoot)) { + log('No SwiftPM injection found.'); + } else { + log('No SPM injection found — nothing to remove.'); + } + try { + removeGitignoreSpmEntries(appRoot, gitignoreRecord); + } catch (e) { + logError( + `Could not remove the SPM entries from .gitignore: ${e.message}. ` + + 'Remove them by hand.', + ); + } + restorePodfile(appRoot); + return; + } + + if (args.deintegrate && podfileHasThirdPartyPods(appRoot)) { + logError( + 'Your Podfile declares pods that are not part of React Native. They ' + + 'would stop working under SwiftPM, so the conversion is refused. ' + + 'Nothing was changed.', ); + process.exitCode = 1; + return; + } + if (args.deintegrate && refuseToOverwritePodfileBackup(appRoot)) { + process.exitCode = 1; return; } @@ -1157,12 +1317,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { if ((action === 'add' || action === 'update') && !args.deintegrate) { const target = resolveInjectionTarget(args, appRoot); if (target.path != null && pbxprojUsesCocoaPods(target.path)) { - logError( - `${path.basename(target.path)} is CocoaPods-integrated. Re-run ` + - '`spm add --deintegrate` to convert it (runs `pod deintegrate` + ' + - 'strips React Native from the Podfile), or run `pod deintegrate` ' + - 'yourself first. Side-by-side non-RN pods are fine.', - ); + logError(cocoaPodsIntegratedMessage(target.path)); process.exitCode = 1; return; } @@ -1379,15 +1534,28 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { // so no path-locator JSON is written. buildPerAppHeaderTree(appRoot, {log}); - // First-time setup only adds the gitignore entries on `add`. + // First-time setup only adds the gitignore entries on `add`. The record + // reaches the marker through setupXcodeproj, which writes it. + let gitignoreRecord = null; if (action === 'add') { - ensureGitignoreSpmEntries(appRoot); + const injected = resolveInjectionTarget(args, appRoot).path; + gitignoreRecord = ensureGitignoreSpmEntries( + appRoot, + injected != null ? readMarker(injected)?.gitignore : null, + ); } // Xcodeproj setup: in-place injection into the existing project (the only // strategy — no rename, no from-scratch; git is the safety net). try { - await setupXcodeproj(args, appRoot, reactNativeRoot, action, cxxApiDefines); + await setupXcodeproj( + args, + appRoot, + reactNativeRoot, + action, + cxxApiDefines, + gitignoreRecord, + ); } catch (e) { logError(`xcodeproj setup failed: ${e.message}`); if (process.exitCode == null) { @@ -1409,6 +1577,7 @@ module.exports = { main, detectStandardRnLayoutRedirect, determineVersion, + ensureGitignoreSpmEntries, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, parseArgs, @@ -1417,6 +1586,7 @@ module.exports = { resolveConfigCommandToPin, resolveExplicitConfigCommand, resolveCxxApiDefines, + runDeintegrate, shouldAutoDeintegrate, ensureBothArtifactFlavors, }; diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md index b04da44fc81f..d9053787019c 100644 --- a/packages/react-native/scripts/spm/__docs__/README.md +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -23,6 +23,16 @@ cd ios npx react-native spm # add on first run, update thereafter ``` +On a fresh CocoaPods app, this converts the app to SwiftPM. It runs +`pod deintegrate` and moves the `Podfile` to `Podfile.bak`. A Podfile with pods +that are not part of React Native is refused. Without a Podfile, the CLI needs +`@react-native-community/cli` 21 or newer to find the iOS project. + +To go back to CocoaPods, run `npx react-native spm deinit`, then `pod install` +in `ios/`. If your Podfile does not call `react_native_post_install`, check +`REACT_NATIVE_PATH`; see +[Removing / resetting](./spm-scripts.md#removing--resetting). + **If an autolinked dependency ships no `Package.swift`, setup stops with `error: Package.swift is missing for library ""` and exit code 2.** Run `npx react-native spm scaffold`, persist the manifest with `patch-package`, and diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 4a8460d1a3a7..0a268cb64201 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -42,28 +42,35 @@ dependency changes. **On a fresh clone or CI checkout, run CocoaPods app it fails loud and points you at `--deintegrate`, which: 1. runs `pod deintegrate`. This removes CocoaPods integration from the - `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). Your - `Podfile` is left on disk. -2. strips **only** the React Native directives (`use_react_native!`, - `use_native_modules!`, `prepare_react_native_project!`) from the Podfile. - Every other line, **including your own `pod '…'` entries, is preserved**. The - strip is line-based: it deletes each line that contains one of those names, - and nothing else. The argument lines of a multi-line `use_react_native!(` - call and any `react_native_post_install(...)` call remain, so remove them by - hand before you run `pod install`. + `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). +2. moves the `Podfile` to `Podfile.bak`, unchanged. `spm deinit` moves it back. + If `Podfile.bak` already exists, the command stops before it changes + anything. 3. injects SwiftPM into the `.xcodeproj`. -React Native now comes from SwiftPM. No pods are linked yet, because deintegrate -removed the integration. +The zero-arg conversion of a fresh app does the same steps. It does not start +when `Podfile.bak` exists; the command then fails as a plain `add` does. + +If the Podfile declares pods that are not part of React Native, `--deintegrate` +refuses and changes nothing. Those pods would stop working under SwiftPM. See +[Keeping non-RN pods](#keeping-non-rn-pods). + +After the conversion, the app has no Podfile. The CLI then finds the iOS project +through the `.spm-injected.json` marker. This needs +`@react-native-community/cli` 21 or newer. An older CLI needs a Podfile to find +the iOS project. ### Keeping non-RN pods -Non-RN pods can stay side-by-side. Your Podfile still lists them. Remove the -leftover React Native lines (see step 2 above), then re-integrate the pods with -a normal install: +`--deintegrate` does not convert an app whose Podfile has pods that are not part +of React Native. To keep those pods side-by-side, convert by hand: ```bash -pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace +pod deintegrate # removes the CocoaPods integration from the .xcodeproj +# remove use_react_native!, use_native_modules!, prepare_react_native_project! +# and react_native_post_install from the Podfile; keep your own pods +npx react-native spm add +pod install # re-integrates the remaining (non-RN) pods; (re)creates the .xcworkspace ``` Then **open the `.xcworkspace`** (not the `.xcodeproj`). The workspace includes @@ -115,9 +122,9 @@ redirects into `ios/` and prints a banner. | Action | Description | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `add` | Inject SwiftPM packages (package refs, the base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig`, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` + strips React Native from the Podfile. | +| `add` | Inject SwiftPM packages (package refs, the base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig`, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` and moves the Podfile to `Podfile.bak`. | | `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | -| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | +| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Moves `Podfile.bak` back to `Podfile`. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | | `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | | `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | | `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | @@ -133,7 +140,7 @@ Flags below use the `react-native spm` (camelCase) form. | `--yes` | Skip the dirty-pbxproj confirmation prompt | | `--xcodeproj ` | [add, update, scaffold, deinit] Which `.xcodeproj` to inject into or remove from (when several exist) | | `--productName ` | [add, update, scaffold] Which app target to inject into (when several exist) | -| `--deintegrate` | [add] Run `pod deintegrate` + strip React Native from the Podfile before injecting | +| `--deintegrate` | [add] Run `pod deintegrate` and move the Podfile to `Podfile.bak` before injecting. Refused if the Podfile has pods that are not part of React Native | | `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | | `--download ` | [advanced] Artifact download policy (default: auto) | | `--skipCodegen` | [advanced] Skip the codegen step | @@ -287,16 +294,16 @@ Paths are relative to the Xcode project directory (`ios/`) unless noted. ### In your repo — committed -| Path | Written by | What happens | Undone by `deinit`? | -| --------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MyApp.xcodeproj/project.pbxproj` | `add`, `update`, `scaffold` | SwiftPM package refs, a reference to `ReactNativeSPM/ReactNativeSPM.xcconfig` set as the base configuration of each app configuration, the Sync build phase, and the flavored-framework embed phase are added. The two phases run their scripts from `ReactNativeSPM/Scripts/`. A re-run is a no-op. A `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` is removed, so the xcconfig's SwiftPM path applies. `add` also removes the template's unused `JavaScriptCore.framework` reference, and `add --deintegrate` removes an empty `Pods` group. | Yes — exactly what was injected, per the marker. The removed `REACT_NATIVE_PATH`, `JavaScriptCore.framework` reference, and `Pods` group are **not** restored. | -| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update`, `scaffold` | Created. Three roles: it records every edit made — including which configurations it based on the generated xcconfig — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | -| `ReactNativeSPM/ReactNativeSPM.xcconfig` | `add`, `update`, `scaffold` | Regenerated on each run and written when its content changes. Holds the React build settings: search paths, linker flags, `REACT_NATIVE_PATH`, the C++ standard, each configuration's flavor (`RN_SPM_FLAVOR`), and `DEBUG` for Swift on debug configurations (see [Build settings](#build-settings)). **Commit it.** Xcode reads it before any build phase runs, so a build cannot regenerate it. If it is missing, run `npx react-native spm update` and commit it. | Yes — deleted, unless a configuration is based on an xcconfig of your own, which may `#include` it. Then the file is kept, and `deinit` names those configurations. | -| `ReactNativeSPM/Scripts/` | `add`, `update`, `scaffold` | Regenerated on each run and written when their content changes: `sync-autolinking.sh` and `embed-flavored-frameworks.sh`, the scripts the Sync and embed phases run. Plugin script phases keep their scripts in `project.pbxproj`. **Commit it.** When a script is missing, its phase fails the build with `error: is missing. Run 'npx react-native spm update' to generate it.` | Yes — both scripts are deleted | -| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | -| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | **No** — the block is left behind | -| `Podfile` | `add --deintegrate` | Only the lines containing the React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. The argument lines of a multi-line `use_react_native!(` call and `react_native_post_install(...)` remain — remove them by hand before `pod install`. | **No** — re-add the directives yourself to go back to CocoaPods | -| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | +| Path | Written by | What happens | Undone by `deinit`? | +| --------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `MyApp.xcodeproj/project.pbxproj` | `add`, `update`, `scaffold` | SwiftPM package refs, a reference to `ReactNativeSPM/ReactNativeSPM.xcconfig` set as the base configuration of each app configuration, the Sync build phase, and the flavored-framework embed phase are added. The two phases run their scripts from `ReactNativeSPM/Scripts/`. A re-run is a no-op. A `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` is removed, so the xcconfig's SwiftPM path applies. `add` also removes the template's unused `JavaScriptCore.framework` reference, and `add --deintegrate` removes an empty `Pods` group. | Yes — exactly what was injected, per the marker. The removed `REACT_NATIVE_PATH`, `JavaScriptCore.framework` reference, and `Pods` group are **not** restored. | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update`, `scaffold` | Created. Three roles: it records every edit made — including which configurations it based on the generated xcconfig — so removal is surgical and re-runs stay idempotent; it records the frameworks the project links (`linkedFrameworks`), which the build-time sync compares with the frameworks plugins provide; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `ReactNativeSPM/ReactNativeSPM.xcconfig` | `add`, `update`, `scaffold` | Regenerated on each run and written when its content changes. Holds the React build settings: search paths, linker flags, `REACT_NATIVE_PATH`, the C++ standard, each configuration's flavor (`RN_SPM_FLAVOR`), and `DEBUG` for Swift on debug configurations (see [Build settings](#build-settings)). **Commit it.** Xcode reads it before any build phase runs, so a build cannot regenerate it. If it is missing, run `npx react-native spm update` and commit it. | Yes — deleted, unless a configuration is based on an xcconfig of your own, which may `#include` it. Then the file is kept, and `deinit` names those configurations. | +| `ReactNativeSPM/Scripts/` | `add`, `update`, `scaffold` | Regenerated on each run and written when their content changes: `sync-autolinking.sh` and `embed-flavored-frameworks.sh`, the scripts the Sync and embed phases run. Plugin script phases keep their scripts in `project.pbxproj`. **Commit it.** When a script is missing, its phase fails the build with `error: is missing. Run 'npx react-native spm update' to generate it.` | Yes — both scripts are deleted, then each `ReactNativeSPM/` directory left empty | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | +| `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | Yes — only the lines `add` wrote, as recorded in the marker, and the file too if `add` created it and nothing else is left. A line you changed is kept, and `deinit` says so. A marker from an older version has no record, so `.gitignore` is left as it is | +| `Podfile` | `add --deintegrate` | Moved to `Podfile.bak`, unchanged. Refused, before any change, if the Podfile has pods that are not part of React Native or `Podfile.bak` already exists. | Yes — `Podfile.bak` is moved back to `Podfile`. If both files exist, `deinit` stops before it changes anything | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | The two pins are the `--version` pin (`artifactsVersionOverride`, see [Pinning the React Native version](#pinning-the-react-native-version)) and the @@ -684,10 +691,21 @@ the context/return contract, lifecycle, and failure behavior. To remove SwiftPM entirely, use `deinit` (the inverse of `add`): ```bash -react-native spm deinit # surgically removes everything `add` injected -pod install # then, to restore CocoaPods +npx react-native spm deinit # surgically removes everything `add` injected +pod install # then, to restore CocoaPods ``` +`deinit` also moves `Podfile.bak` back to `Podfile` and removes the `.gitignore` +lines that `add` recorded in the marker. + +The conversion (`spm add`) removed the app target's `${PODS_ROOT}`-anchored +`REACT_NATIVE_PATH` and the unused `JavaScriptCore.framework` reference, and +`deinit` does not put them back. `pod install` sets `REACT_NATIVE_PATH` again at +project level, through `react_native_post_install`, and the app target inherits +it. Check it by hand only if your Podfile does not call +`react_native_post_install`, or if the target's own value differed from the +project's. + `deinit` removes each configuration's base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig` and deletes the generated scripts. It deletes the xcconfig too, unless a configuration is based on an xcconfig of your @@ -712,7 +730,7 @@ across apps; refresh it with `react-native spm update --download force`. | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | | Build fails: `error: …/ReactNativeSPM/Scripts/.sh is missing` | `ReactNativeSPM/` was not committed. Run `npx react-native spm update`, then commit `ReactNativeSPM/` | -| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` + strips RN from the Podfile), or `pod deintegrate` yourself first. | +| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` and moves the Podfile to `Podfile.bak`). If the Podfile has pods that are not part of React Native, convert by hand (see [Keeping non-RN pods](#keeping-non-rn-pods)). | | `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | | `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--productName ` if multiple app targets). | | `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | @@ -726,7 +744,7 @@ across apps; refresh it with `react-native spm update --download force`. | "Sync SPM Autolinking" build phase fails: `'npx --no-install @react-native-community/cli config' exited with status 1` | This app replaces `@react-native-community/cli` autolinking (e.g. an Expo app). Re-run `spm add`/`update` with `RCT_SPM_AUTOLINKING_CONFIG_COMMAND` exported (or the raw script with `--config-command`) so the working command is pinned for the build phase to reuse — see [The autolinking config command is remembered](#the-autolinking-config-command-is-remembered). | | Autolinking not updating on build | Touch `package.json` to force a sync, or delete `build/generated/autolinking/.spm-sync-stamp` | | Stale SwiftPM state or corrupted build | `rm -rf build/ .build/`, then `react-native spm update`, then reopen Xcode | -| Want to revert to CocoaPods | `react-native spm deinit`, then `pod install` | +| Want to revert to CocoaPods | `npx react-native spm deinit`, then `pod install` in `ios/`. Without `react_native_post_install` in the Podfile, check `REACT_NATIVE_PATH` (see [Removing / resetting](#removing--resetting)) | --- @@ -754,7 +772,7 @@ my-app/ios/ ReactNativeSPM/ <-- committed, rewritten by add/update ReactNativeSPM.xcconfig <-- React build settings; base configuration of the app configurations Scripts/ <-- scripts the Sync and embed phases run - Podfile <-- kept; `--deintegrate` only strips the React Native lines (CocoaPods coexistence is best-effort) + Podfile.bak <-- the Podfile, moved aside by `--deintegrate`; `deinit` moves it back build/ generated/ autolinking/ <-- gitignored (regenerated at build time) diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 0297e5e2eb36..3afa46d0938c 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -567,6 +567,83 @@ describe('removeSpmInjection — the surgical inverse of add', () => { expect(pbxprojOf(xcodeprojPath)).toBe(before); }); + it('removes the ReactNativeSPM directories its file removal leaves empty', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + removeSpmInjection({appRoot, xcodeprojPath}); + + expect(fs.existsSync(path.join(appRoot, 'ReactNativeSPM'))).toBe(false); + }); + + it('keeps a ReactNativeSPM directory that still holds a file of the user', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const userScript = path.join(appRoot, 'ReactNativeSPM', 'Scripts', 'my.sh'); + fs.writeFileSync(userScript, 'echo mine\n', 'utf8'); + + removeSpmInjection({appRoot, xcodeprojPath}); + + expect(fs.readFileSync(userScript, 'utf8')).toBe('echo mine\n'); + expect( + generatedFileContents(appRoot).map(content => content != null), + ).toEqual(GENERATED_FILES.map(() => false)); + }); + + it('prunes no directory outside the app through a symlinked ReactNativeSPM', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-outside-')); + scaffoldedAppRoots.push(outside); + fs.mkdirSync(path.join(outside, 'Scripts')); + fs.symlinkSync(outside, path.join(appRoot, 'ReactNativeSPM'), 'dir'); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + + removeSpmInjection({appRoot, xcodeprojPath}); + + expect(fs.statSync(path.join(outside, 'Scripts')).isDirectory()).toBe(true); + expect( + fs.lstatSync(path.join(appRoot, 'ReactNativeSPM')).isSymbolicLink(), + ).toBe(true); + }); + + it('warns and carries on when an empty directory cannot be removed', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const rmdirSpy = jest.spyOn(fs, 'rmdirSync').mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + try { + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe( + 'removed', + ); + expect(fs.existsSync(path.join(xcodeprojPath, SPM_INJECTED_MARKER))).toBe( + false, + ); + expect(warnSpy.mock.calls.join('\n')).toContain('EACCES'); + } finally { + rmdirSpy.mockRestore(); + warnSpy.mockRestore(); + } + }); + it('round-trips WITH a generated-sources manifest (add then deinit is byte-identical)', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); writeManifest(appRoot, [PROVIDER_REL]); @@ -1855,3 +1932,48 @@ describe("a list setting of the project's own that keeps $(inherited)", () => { expect(after).not.toContain(SCALAR); }); }); + +// --------------------------------------------------------------------------- +// gitignore — the marker field recording exactly what `spm add` wrote to the +// app's .gitignore, so `deinit` (setup-apple-spm.js) undoes only that. +// --------------------------------------------------------------------------- +describe('gitignore marker field', () => { + const RECORD = {created: true, lines: ['# SPM', 'Package.resolved']}; + + it('records the .gitignore record add passes in', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + gitignore: RECORD, + }); + expect(readMarker(xcodeprojPath).gitignore).toEqual(RECORD); + }); + + it('preserves the record on a later run that passes none (update)', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + gitignore: RECORD, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).gitignore).toEqual(RECORD); + }); + + it('writes no record when add never passed one', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath)).not.toHaveProperty('gitignore'); + }); +}); diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 02ece867a323..73ae9634f09d 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -10,18 +10,79 @@ 'use strict'; +// `pod` is faked so the CocoaPods conversion tests need no CocoaPods install, +// and the autolinking config command (spawnSync → npx) fails fast instead of +// reaching the network. A test that sets mockAutolinkingSourceDir lets the +// config command succeed instead, so `main` gets far enough to write +// build/generated/ — what "refuses before any change" has to rule out. git and +// everything else run for real. +const mockPodCalls = []; +let mockAutolinkingSourceDir = null; +jest.mock('node:child_process', () => { + const actual = jest.requireActual('node:child_process'); + return { + ...actual, + execFileSync: (command, args, options) => { + if (command === 'pod') { + mockPodCalls.push(args); + return ''; + } + return actual.execFileSync(command, args, options); + }, + spawnSync: () => + mockAutolinkingSourceDir == null + ? {status: 1, stdout: '', stderr: 'stubbed in test'} + : { + status: 0, + stdout: JSON.stringify({ + project: {ios: {sourceDir: mockAutolinkingSourceDir}}, + dependencies: {}, + }), + stderr: '', + }, + }; +}); + +// A test that sets mockInjectorCalls to an array runs `add` to the end: the +// xcframeworks package (which needs real XCFramework slices) is skipped, and +// the injector (which needs that package's output) only records its options. +let mockInjectorCalls = null; +jest.mock('../generate-spm-package', () => { + const actual = jest.requireActual('../generate-spm-package'); + return { + ...actual, + main: argv => (mockInjectorCalls == null ? actual.main(argv) : undefined), + }; +}); +jest.mock('../generate-spm-xcodeproj', () => { + const actual = jest.requireActual('../generate-spm-xcodeproj'); + return { + ...actual, + injectSpmIntoExistingXcodeproj: opts => { + if (mockInjectorCalls == null) { + return actual.injectSpmIntoExistingXcodeproj(opts); + } + mockInjectorCalls.push(opts); + return {status: 'injected', target: 'MyApp'}; + }, + }; +}); + const { detectStandardRnLayoutRedirect, determineVersion, ensureBothArtifactFlavors, + ensureGitignoreSpmEntries, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, + main, parseArgs, resolveAction, resolveAppIosDeploymentTarget, resolveConfigCommandToPin, resolveCxxApiDefines, resolveExplicitConfigCommand, + runDeintegrate, shouldAutoDeintegrate, } = require('../../setup-apple-spm'); const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); @@ -63,6 +124,26 @@ function mkXcodeproj(appRoot, name, {cocoapods = false} = {}) { return dir; } +// A Debug or Release artifact slot that passes validation: every required +// XCFramework directory, listed in artifacts.json. +function writeCompleteArtifactSlot(root, flavor) { + const slot = path.join(root, flavor); + fs.mkdirSync(path.join(slot, 'hermes-headers', 'hermes'), { + recursive: true, + }); + const artifacts = {}; + for (const name of REQUIRED_ARTIFACTS) { + const xcframeworkPath = path.join(slot, `${name}.xcframework`); + fs.mkdirSync(xcframeworkPath, {recursive: true}); + artifacts[name] = {xcframeworkPath}; + } + fs.writeFileSync( + path.join(slot, 'artifacts.json'), + JSON.stringify(artifacts), + ); + return slot; +} + function gitInitAndCommit(dir) { const opts = {cwd: dir, stdio: 'ignore'}; execFileSync('git', ['init'], opts); @@ -461,21 +542,7 @@ describe('dual-flavor artifact input', () => { afterEach(() => fs.rmSync(root, {recursive: true, force: true})); function writeCompleteSlot(flavor) { - const slot = path.join(root, flavor); - fs.mkdirSync(path.join(slot, 'hermes-headers', 'hermes'), { - recursive: true, - }); - const artifacts = {}; - for (const name of REQUIRED_ARTIFACTS) { - const xcframeworkPath = path.join(slot, `${name}.xcframework`); - fs.mkdirSync(xcframeworkPath, {recursive: true}); - artifacts[name] = {xcframeworkPath}; - } - fs.writeFileSync( - path.join(slot, 'artifacts.json'), - JSON.stringify(artifacts), - ); - return slot; + return writeCompleteArtifactSlot(root, flavor); } function args() { @@ -618,6 +685,645 @@ describe('shouldAutoDeintegrate', () => { fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '{}'); expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true); }); + + it('false when Podfile.bak exists, which the conversion would overwrite', () => { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync( + path.join(tempDir, 'Podfile'), + "target 'MyApp' do\n use_react_native!\nend\n", + ); + fs.writeFileSync( + path.join(tempDir, 'Podfile.bak'), + "target 'Old' do\nend\n", + ); + gitInitAndCommit(tempDir); + expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); + }); + + describe('third-party pod detection', () => { + function podfileWith(line) { + return `target 'MyApp' do\n use_react_native!\n${line}\nend\n`; + } + + function autoDeintegrates(podfile) { + const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync(path.join(tempDir, 'Podfile'), podfile); + gitInitAndCommit(tempDir); + return shouldAutoDeintegrate(tempDir, xcodeproj); + } + + it.each([ + [" pod 'MBProgressHUD'"], + [' pod "MBProgressHUD"'], + [" pod('Firebase/Analytics')"], + [' pod ("Firebase/Analytics")'], + [" pod( 'Firebase/Analytics', '~> 10.0')"], + ["target 'App' do; pod('Foo'); end"], + [" use_frameworks!; pod 'Foo'"], + ])('detects %s', line => { + expect(autoDeintegrates(podfileWith(line))).toBe(false); + }); + + it.each([ + [" # pod 'MBProgressHUD'"], + [" podspec :path => '../MyLib.podspec'"], + [" pods_root = 'Pods'"], + [" pod_targets = ['MyApp']"], + [" # use_frameworks!; pod 'MBProgressHUD'"], + [" use_frameworks!; podspec :path => '../MyLib.podspec'"], + ])('ignores %s', line => { + expect(autoDeintegrates(podfileWith(line))).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// CocoaPods conversion is reversible: `--deintegrate` moves the Podfile aside +// as Podfile.bak instead of editing it, and `deinit` moves it back. +// --------------------------------------------------------------------------- + +describe('Podfile backup and restore', () => { + // Same shape as private/helloworld/ios/Podfile: a multi-line + // use_react_native! call and a post_install hook, which line-based + // stripping would leave as invalid Ruby. + const STOCK_PODFILE = [ + 'require_relative "../node_modules/react-native/scripts/react_native_pods"', + '', + 'platform :ios, min_ios_version_supported', + 'prepare_react_native_project!', + '', + "target 'MyApp' do", + ' config = use_native_modules!', + '', + ' use_react_native!(', + ' :path => config[:reactNativePath],', + ' :app_path => "#{Pod::Config.instance.installation_root}/.."', + ' )', + '', + ' post_install do |installer|', + ' react_native_post_install(', + ' installer,', + ' config[:reactNativePath],', + ' :mac_catalyst_enabled => false', + ' )', + ' end', + 'end', + '', + ].join('\n'); + const PODFILE_WITH_THIRD_PARTY_POD = STOCK_PODFILE.replace( + ' config = use_native_modules!\n', + " config = use_native_modules!\n pod 'Foo'\n", + ); + + let appRoot; + let podfilePath; + let backupPath; + let logSpy; + let warnSpy; + let errorSpy; + + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-podfile-backup-')); + podfilePath = path.join(appRoot, 'Podfile'); + backupPath = path.join(appRoot, 'Podfile.bak'); + mockPodCalls.length = 0; + jest.spyOn(process, 'cwd').mockReturnValue(appRoot); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + process.exitCode = undefined; + mockAutolinkingSourceDir = null; + mockInjectorCalls = null; + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + // `spm add` that runs to the end offline: a successful config command, + // complete local artifact slots, and a recording injector. + function runAdd(...flags) { + const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-artifacts-')); + writeCompleteArtifactSlot(artifacts, 'debug'); + writeCompleteArtifactSlot(artifacts, 'release'); + mockAutolinkingSourceDir = appRoot; + mockInjectorCalls = []; + return main([ + 'add', + ...flags, + '--skip-codegen', + '--version', + '0.85.0', + '--download', + 'skip', + '--artifacts', + artifacts, + ]).finally(() => fs.rmSync(artifacts, {recursive: true, force: true})); + } + + function expectCocoaPodsAdvice() { + expect(errors()).toContain('CocoaPods-integrated'); + expect(errors()).toMatch(/only React Native[^\n]*`spm add --deintegrate`/); + expect(errors()).toContain('Keeping non-RN pods'); + expect(errors()).not.toContain('non-RN pods are fine'); + } + + function expectNoPipelineOutput(xcodeproj) { + expect(mockPodCalls).not.toContainEqual(['deintegrate']); + expect(fs.existsSync(path.join(appRoot, '.gitignore'))).toBe(false); + expect(fs.existsSync(path.join(appRoot, 'build'))).toBe(false); + expect(fs.existsSync(path.join(xcodeproj, SPM_INJECTED_MARKER))).toBe( + false, + ); + } + + function output(spy) { + return spy.mock.calls.map(call => call.join(' ')).join('\n'); + } + + function errors() { + return output(warnSpy) + '\n' + output(errorSpy); + } + + function mkInjectedProject(markerFields = {}) { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + mkInjectedXcodeproj(appRoot, 'MyApp.xcodeproj', markerFields); + return xcodeproj; + } + + describe('add --deintegrate', () => { + it('moves a stock Podfile aside as Podfile.bak, byte-identical and unedited', () => { + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + runDeintegrate(appRoot); + + expect(mockPodCalls).toContainEqual(['deintegrate']); + expect(fs.existsSync(podfilePath)).toBe(false); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(STOCK_PODFILE); + }); + + it('fails loud and changes nothing when Podfile.bak already exists', () => { + const olderBackup = "target 'Old' do\nend\n"; + fs.writeFileSync(podfilePath, STOCK_PODFILE); + fs.writeFileSync(backupPath, olderBackup); + + expect(() => runDeintegrate(appRoot)).toThrow(); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('Podfile.bak already exists'); + expect(mockPodCalls).not.toContainEqual(['deintegrate']); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(olderBackup); + }); + + it('refuses before any change when the Podfile has non-React-Native pods', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + const pbxprojPath = path.join(xcodeproj, 'project.pbxproj'); + const pbxprojBefore = fs.readFileSync(pbxprojPath, 'utf8'); + fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_POD); + mockAutolinkingSourceDir = appRoot; + + await main(['add', '--deintegrate']); + + expect(process.exitCode).toBe(1); + expect(errors()).toMatch(/pods?\b[^]*SwiftPM/i); + expect(errors()).toMatch(/stop working/i); + expectNoPipelineOutput(xcodeproj); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe( + PODFILE_WITH_THIRD_PARTY_POD, + ); + expect(fs.existsSync(backupPath)).toBe(false); + expect(fs.readFileSync(pbxprojPath, 'utf8')).toBe(pbxprojBefore); + }); + + it('refuses before any change when Podfile.bak already exists', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + const olderBackup = "target 'Old' do\nend\n"; + fs.writeFileSync(podfilePath, STOCK_PODFILE); + fs.writeFileSync(backupPath, olderBackup); + mockAutolinkingSourceDir = appRoot; + + await main(['add', '--deintegrate']); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('Podfile.bak already exists'); + expectNoPipelineOutput(xcodeproj); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(olderBackup); + }); + + it('does not auto-convert on the zero-arg path when Podfile.bak already exists', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + const olderBackup = "target 'Old' do\nend\n"; + fs.writeFileSync(podfilePath, STOCK_PODFILE); + fs.writeFileSync(backupPath, olderBackup); + gitInitAndCommit(appRoot); + mockAutolinkingSourceDir = appRoot; + + await main([]); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('CocoaPods-integrated'); + expectNoPipelineOutput(xcodeproj); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(olderBackup); + }); + + it('refuses before any change when Podfile.bak is a dangling symlink', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + fs.symlinkSync('missing-podfile', backupPath); + + await runAdd('--deintegrate'); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('Podfile.bak already exists'); + expectNoPipelineOutput(xcodeproj); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readlinkSync(backupPath)).toBe('missing-podfile'); + }); + + // Shows that expectNoPipelineOutput can fail: this run gets past every + // refusal and writes what the refusal tests rule out. + it('runs the pipeline when nothing refuses the conversion', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + await runAdd('--deintegrate'); + + expect(mockPodCalls).toContainEqual(['deintegrate']); + expect(fs.existsSync(path.join(appRoot, 'build'))).toBe(true); + expect(fs.existsSync(path.join(appRoot, '.gitignore'))).toBe(true); + }); + + it('says when --deintegrate applies if the project is still CocoaPods-integrated after it', async () => { + // The faked `pod deintegrate` leaves the pbxproj integrated. + mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + await runAdd('--deintegrate'); + + expect(process.exitCode).toBe(1); + expectCocoaPodsAdvice(); + }); + }); + + it('says when --deintegrate applies on an explicit `spm add` of a CocoaPods project', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_POD); + + await runAdd(); + + expect(process.exitCode).toBe(1); + expectCocoaPodsAdvice(); + expectNoPipelineOutput(xcodeproj); + }); + + describe('deinit', () => { + it('restores Podfile.bak to Podfile, byte-identical, and says to run `pod install`', async () => { + mkInjectedProject(); + fs.writeFileSync(backupPath, STOCK_PODFILE); + + await main(['deinit']); + + expect(process.exitCode).toBeUndefined(); + expect(fs.existsSync(backupPath)).toBe(false); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(output(logSpy)).toContain('pod install'); + }); + + it('leaves the Podfile untouched when there is no Podfile.bak', async () => { + mkInjectedProject(); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + await main(['deinit']); + + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.existsSync(backupPath)).toBe(false); + }); + + it('fails loud and keeps both files when Podfile and Podfile.bak both exist', async () => { + mkInjectedProject(); + const current = "target 'MyApp' do\nend\n"; + fs.writeFileSync(podfilePath, current); + fs.writeFileSync(backupPath, STOCK_PODFILE); + + await main(['deinit']); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('Podfile.bak'); + expect(errors()).toMatch(/\bPodfile\b(?!\.bak)/); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(current); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(STOCK_PODFILE); + }); + + it('fails loud and changes nothing when Podfile is a dangling symlink next to Podfile.bak', async () => { + const xcodeproj = mkInjectedProject(); + const markerPath = path.join(xcodeproj, SPM_INJECTED_MARKER); + const markerBefore = fs.readFileSync(markerPath, 'utf8'); + fs.symlinkSync('missing-podfile', podfilePath); + fs.writeFileSync(backupPath, STOCK_PODFILE); + + await main(['deinit']); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('Both Podfile and Podfile.bak exist'); + expect(fs.readlinkSync(podfilePath)).toBe('missing-podfile'); + expect(fs.readFileSync(backupPath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readFileSync(markerPath, 'utf8')).toBe(markerBefore); + }); + + it('restores Podfile.bak even when no SPM injection is found', async () => { + fs.writeFileSync(backupPath, STOCK_PODFILE); + + await main(['deinit']); + + expect(process.exitCode).toBeUndefined(); + expect(fs.existsSync(backupPath)).toBe(false); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(output(logSpy)).toContain('pod install'); + }); + + it('does not claim there is nothing to do when it restores Podfile.bak', async () => { + fs.writeFileSync(backupPath, STOCK_PODFILE); + + await main(['deinit']); + + expect(output(logSpy)).toContain('No SwiftPM injection found.'); + expect(output(logSpy)).not.toContain('nothing to remove'); + }); + + it('says there is nothing to remove when there is nothing to do', async () => { + await main(['deinit']); + + expect(output(logSpy)).toContain( + 'No SPM injection found — nothing to remove.', + ); + }); + + it('restores Podfile.bak even when removing an empty directory fails', async () => { + mkInjectedProject(); + fs.mkdirSync(path.join(appRoot, 'ReactNativeSPM', 'Scripts'), { + recursive: true, + }); + fs.writeFileSync(backupPath, STOCK_PODFILE); + jest.spyOn(fs, 'rmdirSync').mockImplementation(() => { + throw new Error('EACCES: permission denied'); + }); + + await main(['deinit']); + + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.existsSync(backupPath)).toBe(false); + expect(errors()).toContain('EACCES'); + }); + }); + + describe('deinit undoes exactly the .gitignore lines add recorded', () => { + const HEADER = '# SPM – auto-generated at build time (do not commit)'; + const ENTRIES = [ + 'Package.resolved', + 'build/generated/', + 'build/xcframeworks/', + '.build/', + ]; + let gitignorePath; + + beforeEach(() => { + gitignorePath = path.join(appRoot, '.gitignore'); + }); + + // What `add` does to .gitignore, with its record stored in the marker. + function add(previous = null) { + const record = ensureGitignoreSpmEntries(appRoot, previous); + mkInjectedProject({gitignore: record}); + return record; + } + + // Only deinit's output counts as a note; add's own log line does not. + async function deinit() { + jest.clearAllMocks(); + await main(['deinit']); + } + + function readGitignore() { + return fs.readFileSync(gitignorePath, 'utf8'); + } + + function gitignoreNotes() { + return [output(logSpy), errors()] + .join('\n') + .split('\n') + .filter(line => line.includes('.gitignore')); + } + + it('records the lines it appended, and none the file already had', () => { + fs.writeFileSync(gitignorePath, 'node_modules/\nPackage.resolved\n'); + + expect(add()).toEqual({ + created: false, + lines: [HEADER, 'build/generated/', 'build/xcframeworks/', '.build/'], + }); + }); + + it('records that it created the file', () => { + expect(add()).toEqual({created: true, lines: [HEADER, ...ENTRIES]}); + }); + + it('keeps the record unchanged on a later run that appends nothing', () => { + const first = add(); + + expect(ensureGitignoreSpmEntries(appRoot, first)).toEqual(first); + }); + + it('deletes a .gitignore that add created and nobody changed', async () => { + add(); + + await deinit(); + + expect(fs.existsSync(gitignorePath)).toBe(false); + }); + + it('removes only the lines add appended to an existing .gitignore', async () => { + const userContent = 'node_modules/\n*.log\n'; + fs.writeFileSync(gitignorePath, userContent); + add(); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('keeps the entries the user already had', async () => { + const userContent = 'node_modules/\nPackage.resolved\n'; + fs.writeFileSync(gitignorePath, userContent); + add(); + expect(readGitignore()).not.toBe(userContent); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('leaves a .gitignore that already had every entry untouched', async () => { + const userContent = ['node_modules/', HEADER, ...ENTRIES, ''].join('\n'); + fs.writeFileSync(gitignorePath, userContent); + add(); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + expect(gitignoreNotes()).toEqual([]); + }); + + it('keeps an empty .gitignore the user had', async () => { + fs.writeFileSync(gitignorePath, ''); + add(); + + await deinit(); + + expect(readGitignore()).toBe(''); + }); + + it('keeps lines the user added to a .gitignore add created, and says so in one line', async () => { + add(); + fs.appendFileSync(gitignorePath, '\n# mine\nsecrets/\n'); + + await deinit(); + + expect(readGitignore()).toBe('\n# mine\nsecrets/\n'); + expect(gitignoreNotes()).toHaveLength(1); + }); + + it('keeps a recorded line the user edited, and says so in one line', async () => { + add(); + fs.writeFileSync( + gitignorePath, + readGitignore().replace('build/generated/\n', 'build/generated/**\n'), + ); + + await deinit(); + + expect(readGitignore()).toBe('build/generated/**\n'); + expect(gitignoreNotes()).toHaveLength(1); + expect(process.exitCode).toBeUndefined(); + }); + + it('leaves .gitignore alone when the marker has no record (an older add)', async () => { + ensureGitignoreSpmEntries(appRoot); + mkInjectedProject(); + const before = readGitignore(); + + await deinit(); + + expect(readGitignore()).toBe(before); + expect(gitignoreNotes()).toEqual([]); + }); + + it('leaves .gitignore alone when there is no marker', async () => { + const userContent = ['node_modules/', '', HEADER, ...ENTRIES, ''].join( + '\n', + ); + fs.writeFileSync(gitignorePath, userContent); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('also removes an entry that a later add appended', async () => { + const userContent = 'node_modules/\n'; + const olderLines = [HEADER, ...ENTRIES.slice(0, 3)]; + fs.writeFileSync( + gitignorePath, + userContent + ['', ...olderLines, ''].join('\n'), + ); + const record = add({created: false, lines: olderLines}); + expect(record.lines).toEqual([...olderLines, HEADER, '.build/']); + + await deinit(); + + expect(readGitignore()).toBe(userContent); + }); + + it('restores Podfile.bak even when the .gitignore cleanup fails', async () => { + add(); + fs.writeFileSync(backupPath, STOCK_PODFILE); + const readFileSync = fs.readFileSync; + jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...rest) => { + if (file === gitignorePath) { + throw new Error('EIO: i/o error'); + } + return readFileSync(file, ...rest); + }); + + await deinit(); + + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.existsSync(backupPath)).toBe(false); + expect(gitignoreNotes().join('\n')).toContain('EIO'); + }); + + describe('add hands the injector the record it wrote', () => { + it('records a .gitignore that add created', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + + await runAdd(); + + expect(process.exitCode).toBeUndefined(); + expect(mockInjectorCalls).toHaveLength(1); + expect(mockInjectorCalls[0].gitignore).toEqual({ + created: true, + lines: [HEADER, ...ENTRIES], + }); + }); + + it('extends the record of an earlier add from the marker', async () => { + const olderLines = [HEADER, ...ENTRIES.slice(0, 3)]; + fs.writeFileSync(gitignorePath, ['', ...olderLines, ''].join('\n')); + mkInjectedProject({gitignore: {created: true, lines: olderLines}}); + + await runAdd(); + + expect(mockInjectorCalls[0].gitignore).toEqual({ + created: true, + lines: [...olderLines, HEADER, '.build/'], + }); + }); + }); + }); + + it('tells the user on the zero-arg conversion that `spm deinit` restores CocoaPods', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + gitInitAndCommit(appRoot); + + await main([]); + + const conversionMessage = output(logSpy) + .split('\n') + .find(line => line.includes('freshly-scaffolded CocoaPods project')); + expect(conversionMessage).toContain('spm deinit'); + expect(conversionMessage).toMatch(/restor/i); + expect(conversionMessage).not.toContain( + 'Revert with `git` or `spm deinit`', + ); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index aa37bb05cec4..3775e8a59cb7 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -73,11 +73,12 @@ const path = require('node:path'); /*:: import type { FlavoredFrameworkManifestEntry, + GitignoreRecord, PluginScriptPhase, XcframeworkSlice, } from './spm-types'; */ -const {log} = makeLogger('generate-spm-xcodeproj'); +const {log, warn} = makeLogger('generate-spm-xcodeproj'); // Sidecar inside a USER-OWNED xcodeproj that SPM packages were injected into in // place. Records the host project's root UUID + every edit so `spm deinit` @@ -2467,7 +2468,7 @@ function readScriptPhasesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, linkedFrameworks?: Array<{id: string, frameworkName: string}>, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: unknown, buildSettingChanges?: Array, xcconfig?: XcconfigRecord, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { +) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, linkedFrameworks?: Array<{id: string, frameworkName: string}>, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: unknown, buildSettingChanges?: Array, xcconfig?: XcconfigRecord, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, gitignore?: GitignoreRecord, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -2803,7 +2804,7 @@ function linkedReactNativeRoot( * when the project can't be safely edited (caller surfaces it; fail-loud). */ function injectSpmIntoExistingXcodeproj( - opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: ReadonlyArray} */, + opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: ReadonlyArray, gitignore?: ?GitignoreRecord} */, ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { const {appRoot, reactNativeRoot, xcodeprojPath} = opts; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -3004,6 +3005,10 @@ function injectSpmIntoExistingXcodeproj( schemeResult.status === 'created' || prevMarker?.scheme?.created === true, }, + // Passed by `add` with the previous record already folded in; + // `update` passes none and keeps it. Without one (an older marker), + // deinit leaves .gitignore untouched. + gitignore: opts.gitignore ?? prevMarker?.gitignore, }, null, 2, @@ -3281,6 +3286,7 @@ function removeSpmInjection( EMBED_FRAMEWORKS_SCRIPT_PATH, ]) { fs.rmSync(path.join(appRoot, relPath), {force: true}); + removeEmptyDirsUpTo(path.dirname(path.join(appRoot, relPath)), appRoot); } // 3. Scheme: delete it if injection created it AND still owns its contents, @@ -3316,6 +3322,26 @@ function removeSpmInjection( return {status: 'removed', target: marker.target}; } +// Never follows a symlink, which could lead outside `stopAt`. A failure only +// warns: deinit has more to undo after this. +function removeEmptyDirsUpTo(dir /*: string */, stopAt /*: string */) { + let current = dir; + try { + const root = fs.realpathSync(stopAt); + while ( + fs.existsSync(current) && + fs.lstatSync(current).isDirectory() && + fs.realpathSync(current).startsWith(root + path.sep) && + fs.readdirSync(current).length === 0 + ) { + fs.rmdirSync(current); + current = path.dirname(current); + } + } catch (e) { + warn(`Could not remove the empty directory ${current}: ${e.message}`); + } +} + module.exports = { generateXcscheme, buildSyncAutolinkingScript, diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index d9af84f39652..8a263f4484e2 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -26,7 +26,8 @@ export type SetupArgs = { // `add` target selection: which app target (when several) and which project. productName: string | null, xcodeprojPath: string | null, - // `add`: run `pod deintegrate` + strip RN from the Podfile before injecting. + // `add`: run `pod deintegrate` + move the Podfile to Podfile.bak before + // injecting. // Also implied on the zero-arg path when resolveAction's safe-gate detects a // freshly-scaffolded CocoaPods project (see setup-apple-spm.js). deintegrate: boolean, @@ -35,6 +36,14 @@ export type SetupArgs = { yes: boolean, }; +// What `spm add` wrote to the app's .gitignore, kept in the injection marker +// so `spm deinit` removes exactly that: whether it created the file, and every +// line it appended (header included), in order, across all `add` runs. +export type GitignoreRecord = { + created: boolean, + lines: Array, +}; + export type DownloadArgs = { version: string | null, flavor: string, From dda80a2cf86cf5c49d65cc48c46a2ecd22cca8ac Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Mon, 26 Oct 2026 10:07:27 +0100 Subject: [PATCH 2/4] Warn instead of refusing `--deintegrate` with other pods The Podfile is moved to Podfile.bak, so no pod is lost and `spm deinit` restores it. An explicit `--deintegrate` now lists the other pods, says CocoaPods will not install them while the app uses SwiftPM unless SwiftPM provides them, and continues. RNTester needs this: its Podfile has local example pods that SwiftPM provides. The automatic conversion of a new app still skips such Podfiles. --- .../react-native/scripts/setup-apple-spm.js | 64 ++++++---- .../scripts/spm/__docs__/README.md | 6 +- .../scripts/spm/__docs__/spm-scripts.md | 17 ++- .../spm/__tests__/setup-apple-spm-test.js | 114 +++++++++++++++--- 4 files changed, 152 insertions(+), 49 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index a94492b36f8b..5550e73e249c 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -750,18 +750,13 @@ function pbxprojUsesCocoaPods(xcodeprojPath /*: string */) /*: boolean */ { } } -// A web link, because npm does not ship the __docs__ directories. -const KEEPING_NON_RN_PODS_DOCS_URL = - 'https://github.com/facebook/react-native/blob/main/packages/react-native/' + - 'scripts/spm/__docs__/spm-scripts.md#keeping-non-rn-pods'; - function cocoaPodsIntegratedMessage(xcodeprojPath /*: string */) /*: string */ { return ( - `${path.basename(xcodeprojPath)} is CocoaPods-integrated. If your ` + - 'Podfile declares only React Native, re-run `spm add --deintegrate` ' + - '(runs `pod deintegrate` and moves the Podfile to Podfile.bak). If it ' + - 'declares other pods, convert by hand as described in "Keeping non-RN ' + - `pods": ${KEEPING_NON_RN_PODS_DOCS_URL}` + `${path.basename(xcodeprojPath)} is CocoaPods-integrated. Re-run ` + + '`spm add --deintegrate`. It runs `pod deintegrate` and moves the Podfile ' + + 'to Podfile.bak. Pods other than React Native are not installed while ' + + 'the app uses SwiftPM, unless SwiftPM provides them. `spm deinit` ' + + 'restores the Podfile.' ); } @@ -778,16 +773,29 @@ function podfileHasRnIntegration(appRoot /*: string */) /*: boolean */ { ); } -// True when the Podfile declares any explicit `pod '...'` or `pod('...')` -// (third-party pods), at the start of a line or after a `;`. -function podfileHasThirdPartyPods(appRoot /*: string */) /*: boolean */ { +// The names of the pods the Podfile declares explicitly (`pod '...'` or +// `pod('...')`), deduplicated, in order. React Native itself comes in through +// use_react_native!, so every name here is a pod that is not part of it. +function podfilePodNames(appRoot /*: string */) /*: Array */ { const podfilePath = path.join(appRoot, 'Podfile'); if (!fs.existsSync(podfilePath)) { - return false; + return []; + } + const names /*: Set */ = new Set(); + for (const line of fs.readFileSync(podfilePath, 'utf8').split('\n')) { + // `#{` is Ruby string interpolation, not a comment. + for (const statement of line.split(/#(?!\{)/)[0].split(';')) { + const match = /^\s*pod\s*\(?\s*(['"])(.+?)\1/.exec(statement); + if (match != null) { + names.add(match[2]); + } + } } - return /^(?:[^#\n]*;)?\s*pod\s*\(?\s*['"]/m.test( - fs.readFileSync(podfilePath, 'utf8'), - ); + return [...names]; +} + +function podfileHasThirdPartyPods(appRoot /*: string */) /*: boolean */ { + return podfilePodNames(appRoot).length > 0; } // The zero-arg "fresh project" safe-gate: auto-`add --deintegrate` ONLY when it @@ -1296,19 +1304,22 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { return; } - if (args.deintegrate && podfileHasThirdPartyPods(appRoot)) { - logError( - 'Your Podfile declares pods that are not part of React Native. They ' + - 'would stop working under SwiftPM, so the conversion is refused. ' + - 'Nothing was changed.', - ); - process.exitCode = 1; - return; - } if (args.deintegrate && refuseToOverwritePodfileBackup(appRoot)) { process.exitCode = 1; return; } + if (args.deintegrate) { + const podNames = podfilePodNames(appRoot); + if (podNames.length > 0) { + logError( + 'Your Podfile declares pods that are not part of React Native: ' + + `${podNames.join(', ')}. CocoaPods will not install them while ` + + 'the app uses SwiftPM, unless SwiftPM provides them. They are kept ' + + 'in Podfile.bak, and `npx react-native spm deinit` restores the ' + + 'Podfile.', + ); + } + } // Fail fast on a CocoaPods-integrated target BEFORE the (expensive) pipeline, // so the user isn't made to wait through codegen + artifact download only to @@ -1581,6 +1592,7 @@ module.exports = { findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, parseArgs, + podfilePodNames, resolveAction, resolveAppIosDeploymentTarget, resolveConfigCommandToPin, diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md index d9053787019c..5417fdf0859a 100644 --- a/packages/react-native/scripts/spm/__docs__/README.md +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -25,8 +25,10 @@ npx react-native spm # add on first run, update thereafter On a fresh CocoaPods app, this converts the app to SwiftPM. It runs `pod deintegrate` and moves the `Podfile` to `Podfile.bak`. A Podfile with pods -that are not part of React Native is refused. Without a Podfile, the CLI needs -`@react-native-community/cli` 21 or newer to find the iOS project. +that are not part of React Native is not converted automatically; +`spm add --deintegrate` converts it and warns that those pods are not installed. +Without a Podfile, the CLI needs `@react-native-community/cli` 21 or newer to +find the iOS project. To go back to CocoaPods, run `npx react-native spm deinit`, then `pod install` in `ios/`. If your Podfile does not call `react_native_post_install`, check diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 0a268cb64201..29cfe9c754f8 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -52,7 +52,7 @@ The zero-arg conversion of a fresh app does the same steps. It does not start when `Podfile.bak` exists; the command then fails as a plain `add` does. If the Podfile declares pods that are not part of React Native, `--deintegrate` -refuses and changes nothing. Those pods would stop working under SwiftPM. See +converts the app anyway and names those pods in a warning. See [Keeping non-RN pods](#keeping-non-rn-pods). After the conversion, the app has no Podfile. The CLI then finds the iOS project @@ -62,8 +62,13 @@ the iOS project. ### Keeping non-RN pods -`--deintegrate` does not convert an app whose Podfile has pods that are not part -of React Native. To keep those pods side-by-side, convert by hand: +`--deintegrate` converts an app whose Podfile has pods that are not part of +React Native, and warns. While the app uses SwiftPM, CocoaPods does not install +those pods, unless SwiftPM provides them as an autolinked library or a +[local native module](#local-native-modules). The pods stay in `Podfile.bak`, +and `spm deinit` restores the Podfile. + +To keep a CocoaPods-only pod next to SwiftPM, convert by hand: ```bash pod deintegrate # removes the CocoaPods integration from the .xcodeproj @@ -140,7 +145,7 @@ Flags below use the `react-native spm` (camelCase) form. | `--yes` | Skip the dirty-pbxproj confirmation prompt | | `--xcodeproj ` | [add, update, scaffold, deinit] Which `.xcodeproj` to inject into or remove from (when several exist) | | `--productName ` | [add, update, scaffold] Which app target to inject into (when several exist) | -| `--deintegrate` | [add] Run `pod deintegrate` and move the Podfile to `Podfile.bak` before injecting. Refused if the Podfile has pods that are not part of React Native | +| `--deintegrate` | [add] Run `pod deintegrate` and move the Podfile to `Podfile.bak` before injecting. Warns about pods that are not part of React Native; see [Keeping non-RN pods](#keeping-non-rn-pods) | | `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | | `--download ` | [advanced] Artifact download policy (default: auto) | | `--skipCodegen` | [advanced] Skip the codegen step | @@ -302,7 +307,7 @@ Paths are relative to the Xcode project directory (`ios/`) unless noted. | `ReactNativeSPM/Scripts/` | `add`, `update`, `scaffold` | Regenerated on each run and written when their content changes: `sync-autolinking.sh` and `embed-flavored-frameworks.sh`, the scripts the Sync and embed phases run. Plugin script phases keep their scripts in `project.pbxproj`. **Commit it.** When a script is missing, its phase fails the build with `error: is missing. Run 'npx react-native spm update' to generate it.` | Yes — both scripts are deleted, then each `ReactNativeSPM/` directory left empty | | `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | | `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | Yes — only the lines `add` wrote, as recorded in the marker, and the file too if `add` created it and nothing else is left. A line you changed is kept, and `deinit` says so. A marker from an older version has no record, so `.gitignore` is left as it is | -| `Podfile` | `add --deintegrate` | Moved to `Podfile.bak`, unchanged. Refused, before any change, if the Podfile has pods that are not part of React Native or `Podfile.bak` already exists. | Yes — `Podfile.bak` is moved back to `Podfile`. If both files exist, `deinit` stops before it changes anything | +| `Podfile` | `add --deintegrate` | Moved to `Podfile.bak`, unchanged. Refused, before any change, if `Podfile.bak` already exists. | Yes — `Podfile.bak` is moved back to `Podfile`. If both files exist, `deinit` stops before it changes anything | | `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | The two pins are the `--version` pin (`artifactsVersionOverride`, see @@ -730,7 +735,7 @@ across apps; refresh it with `react-native spm update --download force`. | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | | Build fails: `error: …/ReactNativeSPM/Scripts/.sh is missing` | `ReactNativeSPM/` was not committed. Run `npx react-native spm update`, then commit `ReactNativeSPM/` | -| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` and moves the Podfile to `Podfile.bak`). If the Podfile has pods that are not part of React Native, convert by hand (see [Keeping non-RN pods](#keeping-non-rn-pods)). | +| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` and moves the Podfile to `Podfile.bak`). Pods that are not part of React Native are not installed while the app uses SwiftPM (see [Keeping non-RN pods](#keeping-non-rn-pods)). | | `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | | `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--productName ` if multiple app targets). | | `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 73ae9634f09d..8473a5312c42 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -77,6 +77,7 @@ const { generateAutolinkingConfigOrFailClosed, main, parseArgs, + podfilePodNames, resolveAction, resolveAppIosDeploymentTarget, resolveConfigCommandToPin, @@ -741,6 +742,60 @@ describe('shouldAutoDeintegrate', () => { }); }); +describe('podfilePodNames', () => { + let tempDir; + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-pod-names-')); + }); + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + function namesIn(lines) { + fs.writeFileSync(path.join(tempDir, 'Podfile'), lines.join('\n')); + return podfilePodNames(tempDir); + } + + it('lists each declared pod once, in declaration order', () => { + expect( + namesIn([ + "target 'MyApp' do", + ' use_react_native!', + " pod 'A'", + " pod('B', :path => 'x')", + ' target \'MyAppTests\' do; pod "C"; end', + " pod 'A', :path => 'y'", + 'end', + ]), + ).toEqual(['A', 'B', 'C']); + }); + + it('ignores commented-out pods and pod-like words', () => { + expect( + namesIn([ + "# pod 'Commented'", + " use_frameworks! # pod 'Trailing'", + " use_frameworks!; # pod 'AfterSemicolon'", + " podspec :path => '../MyLib.podspec'", + " pods_root = 'Pods'", + ]), + ).toEqual([]); + }); + + it('keeps pods whose names use Ruby interpolation', () => { + expect( + namesIn([ + ' pod "#{prefix}Core"', + ' pod "Foo#{suffix}", :path => \'x\' # comment', + ]), + ).toEqual(['#{prefix}Core', 'Foo#{suffix}']); + }); + + it('is empty without a Podfile', () => { + expect(podfilePodNames(tempDir)).toEqual([]); + }); +}); + // --------------------------------------------------------------------------- // CocoaPods conversion is reversible: `--deintegrate` moves the Podfile aside // as Podfile.bak instead of editing it, and `deinit` moves it back. @@ -774,9 +829,19 @@ describe('Podfile backup and restore', () => { 'end', '', ].join('\n'); - const PODFILE_WITH_THIRD_PARTY_POD = STOCK_PODFILE.replace( + // Shaped like packages/rn-tester/Podfile: local example pods, then a test + // pod in a nested target. + const PODFILE_WITH_THIRD_PARTY_PODS = STOCK_PODFILE.replace( ' config = use_native_modules!\n', - " config = use_native_modules!\n pod 'Foo'\n", + [ + ' config = use_native_modules!', + " pod 'ScreenshotManager', :path => 'NativeModuleExample'", + " pod 'MyNativeView', :path => 'NativeComponentExample'", + " target 'MyAppTests' do", + " pod 'OCMock', '~> 3.9.1'", + ' end', + '', + ].join('\n'), ); let appRoot; @@ -828,9 +893,12 @@ describe('Podfile backup and restore', () => { function expectCocoaPodsAdvice() { expect(errors()).toContain('CocoaPods-integrated'); - expect(errors()).toMatch(/only React Native[^\n]*`spm add --deintegrate`/); - expect(errors()).toContain('Keeping non-RN pods'); - expect(errors()).not.toContain('non-RN pods are fine'); + expect(errors()).toContain('`spm add --deintegrate`'); + expect(errors()).toContain('Podfile.bak'); + expect(errors()).toMatch(/other than React Native[^]*SwiftPM provides/); + expect(errors()).toContain('`spm deinit`'); + expect(errors()).not.toContain('Keeping non-RN pods'); + expect(errors()).not.toContain('https://'); } function expectNoPipelineOutput(xcodeproj) { @@ -881,26 +949,42 @@ describe('Podfile backup and restore', () => { expect(fs.readFileSync(backupPath, 'utf8')).toBe(olderBackup); }); - it('refuses before any change when the Podfile has non-React-Native pods', async () => { + it('converts anyway and names the pods it will not install when the Podfile has non-React-Native pods', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); + fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_PODS); + + await runAdd('--deintegrate'); + + const warning = errors(); + expect(warning).toContain('ScreenshotManager, MyNativeView, OCMock'); + expect(warning).toContain('Podfile.bak'); + expect(warning).toContain('spm deinit'); + expect(warning).not.toMatch(/refused/i); + expect(mockPodCalls).toContainEqual(['deintegrate']); + expect(fs.existsSync(podfilePath)).toBe(false); + expect(fs.readFileSync(backupPath, 'utf8')).toBe( + PODFILE_WITH_THIRD_PARTY_PODS, + ); + expect(fs.existsSync(path.join(appRoot, 'build'))).toBe(true); + }); + + it('does not auto-convert on the zero-arg path when the Podfile has non-React-Native pods', async () => { const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { cocoapods: true, }); - const pbxprojPath = path.join(xcodeproj, 'project.pbxproj'); - const pbxprojBefore = fs.readFileSync(pbxprojPath, 'utf8'); - fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_POD); + fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_PODS); + gitInitAndCommit(appRoot); mockAutolinkingSourceDir = appRoot; - await main(['add', '--deintegrate']); + await main([]); expect(process.exitCode).toBe(1); - expect(errors()).toMatch(/pods?\b[^]*SwiftPM/i); - expect(errors()).toMatch(/stop working/i); + expect(errors()).toContain('CocoaPods-integrated'); expectNoPipelineOutput(xcodeproj); expect(fs.readFileSync(podfilePath, 'utf8')).toBe( - PODFILE_WITH_THIRD_PARTY_POD, + PODFILE_WITH_THIRD_PARTY_PODS, ); expect(fs.existsSync(backupPath)).toBe(false); - expect(fs.readFileSync(pbxprojPath, 'utf8')).toBe(pbxprojBefore); }); it('refuses before any change when Podfile.bak already exists', async () => { @@ -985,7 +1069,7 @@ describe('Podfile backup and restore', () => { const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { cocoapods: true, }); - fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_POD); + fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_PODS); await runAdd(); From 0640f089ad0dddb4f3bff5e27b5907066b735e18 Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Fri, 30 Oct 2026 09:04:18 +0100 Subject: [PATCH 3/4] Keep the Podfile and stop `pod install` on a SwiftPM app Review feedback: `spm add --deintegrate` no longer moves the Podfile to Podfile.bak. It runs `pod deintegrate` and keeps the Podfile unchanged, so `spm deinit` and then `pod install` go back to CocoaPods. - Plain `spm` no longer converts a CocoaPods app; it points to `spm add --deintegrate`. - The React Native CocoaPods scripts stop `pod install` before any write when the .xcodeproj next to the Podfile has .spm-injected.json. Otherwise `pod install` rewrites the generated Package.swift with a wrong path and Xcode fails to resolve packages before any build phase runs. - The uncommitted-changes prompt comes before `pod deintegrate`. - .gitignore is written only after a successful injection. - Pods sourced from node_modules/react-native count as React Native pods, and the other-pods message is a warning. - The SwiftPM iOS workflows check for `Pods-` references in the pbxproj instead of the Podfile content. --- .github/workflows/test-ios-spm-helloworld.yml | 6 +- .github/workflows/test-ios-spm-newapp.yml | 6 +- .github/workflows/test-ios-spm-rntester.yml | 6 +- .../scripts/cocoapods/autolinking.rb | 1 + .../react-native/scripts/cocoapods/utils.rb | 18 + .../react-native/scripts/react_native_pods.rb | 3 + .../react-native/scripts/setup-apple-spm.js | 366 ++++-------- .../scripts/spm/__docs__/README.md | 18 +- .../scripts/spm/__docs__/spm-scripts.md | 62 +- .../__tests__/remove-spm-injection-test.js | 24 +- .../spm/__tests__/setup-apple-spm-test.js | 545 +++++++----------- .../scripts/spm/generate-spm-xcodeproj.js | 19 +- .../react-native/scripts/spm/spm-types.js | 5 +- 13 files changed, 432 insertions(+), 647 deletions(-) diff --git a/.github/workflows/test-ios-spm-helloworld.yml b/.github/workflows/test-ios-spm-helloworld.yml index bf835a8f0fa3..12713e06dc9f 100644 --- a/.github/workflows/test-ios-spm-helloworld.yml +++ b/.github/workflows/test-ios-spm-helloworld.yml @@ -94,11 +94,11 @@ jobs: echo "::error::spm add did not inject SwiftPM: $XCODE_PROJECT/.spm-injected.json is missing" exit 1 fi - if [[ -f Podfile ]] && grep -q 'use_react_native!' Podfile; then - echo "::error::spm add --deintegrate left use_react_native! in the Podfile" + if grep -q 'Pods-' "$XCODE_PROJECT/project.pbxproj"; then + echo "::error::spm add --deintegrate left CocoaPods references in $XCODE_PROJECT" exit 1 fi - echo "SwiftPM injected in place; Podfile de-integrated." + echo "SwiftPM injected in place; CocoaPods de-integrated." - name: Build ${{ env.XCODE_SCHEME }} (${{ matrix.flavor }}) shell: bash working-directory: ${{ env.APP_IOS_DIR }} diff --git a/.github/workflows/test-ios-spm-newapp.yml b/.github/workflows/test-ios-spm-newapp.yml index 62e66395bae1..8fafbf48fa55 100644 --- a/.github/workflows/test-ios-spm-newapp.yml +++ b/.github/workflows/test-ios-spm-newapp.yml @@ -105,11 +105,11 @@ jobs: echo "::error::spm add did not inject SwiftPM: $XCODE_PROJECT/.spm-injected.json is missing" exit 1 fi - if [[ -f Podfile ]] && grep -q 'use_react_native!' Podfile; then - echo "::error::spm add --deintegrate left use_react_native! in the Podfile" + if grep -q 'Pods-' "$XCODE_PROJECT/project.pbxproj"; then + echo "::error::spm add --deintegrate left CocoaPods references in $XCODE_PROJECT" exit 1 fi - echo "SwiftPM injected in place; Podfile de-integrated." + echo "SwiftPM injected in place; CocoaPods de-integrated." - name: Build ${{ env.XCODE_SCHEME }} (${{ matrix.flavor }}) shell: bash working-directory: ${{ env.APP_IOS_DIR }} diff --git a/.github/workflows/test-ios-spm-rntester.yml b/.github/workflows/test-ios-spm-rntester.yml index 60bfbf0d941e..cce98b0ba3d4 100644 --- a/.github/workflows/test-ios-spm-rntester.yml +++ b/.github/workflows/test-ios-spm-rntester.yml @@ -94,11 +94,11 @@ jobs: echo "::error::spm add did not inject SwiftPM: $XCODE_PROJECT/.spm-injected.json is missing" exit 1 fi - if [[ -f Podfile ]] && grep -q 'use_react_native!' Podfile; then - echo "::error::spm add --deintegrate left use_react_native! in the Podfile" + if grep -q 'Pods-' "$XCODE_PROJECT/project.pbxproj"; then + echo "::error::spm add --deintegrate left CocoaPods references in $XCODE_PROJECT" exit 1 fi - echo "SwiftPM injected in place; Podfile de-integrated." + echo "SwiftPM injected in place; CocoaPods de-integrated." - name: Build ${{ env.XCODE_SCHEME }} (${{ matrix.flavor }}) shell: bash working-directory: ${{ env.APP_IOS_DIR }} diff --git a/packages/react-native/scripts/cocoapods/autolinking.rb b/packages/react-native/scripts/cocoapods/autolinking.rb index d8e9c7cec2f5..1d15545e497e 100644 --- a/packages/react-native/scripts/cocoapods/autolinking.rb +++ b/packages/react-native/scripts/cocoapods/autolinking.rb @@ -208,5 +208,6 @@ def link_native_modules!(config) # - config_command: the command to run to get the application's current config, e.g. ['npx', '@react-native-community/cli', 'config'], # you can override this if you'd like to avoid the dependency. e.g. ['cat', 'your_config.json'] def use_native_modules!(config_command = $default_command) + ReactNativePodsUtils.error_if_spm_injected return link_native_modules!(list_native_modules!(config_command)) end diff --git a/packages/react-native/scripts/cocoapods/utils.rb b/packages/react-native/scripts/cocoapods/utils.rb index 6473278a2e7e..84c24e88b8ee 100644 --- a/packages/react-native/scripts/cocoapods/utils.rb +++ b/packages/react-native/scripts/cocoapods/utils.rb @@ -316,6 +316,24 @@ def self.create_xcode_env_if_missing(file_manager: File) end end + # Stops `pod install` on an app that `npx react-native spm add` converted to + # Swift Package Manager. Codegen would rewrite the app's generated + # Package.swift, and Xcode resolves packages before any build phase can + # report it, so this has to run before React Native writes any file. + # prepare_react_native_project!, use_native_modules! and use_react_native! + # all call it, because a Podfile need not call the first. + def self.error_if_spm_injected + podfile_dir = Pod::Config.instance.installation_root.to_s + xcodeproj = Dir.glob('*.xcodeproj', base: podfile_dir).sort.find do |name| + File.exist?(File.join(podfile_dir, name, '.spm-injected.json')) + end + return if xcodeproj.nil? + + raise Pod::Informative, "This app uses Swift Package Manager for React Native " + + "(#{xcodeproj} has .spm-injected.json), so `pod install` would break the Xcode project. " + + "To go back to CocoaPods, run `npx react-native spm deinit` first, then `pod install`." + end + # It examines the target_definition property and sets the appropriate value for # ENV['USE_FRAMEWORKS'] variable. # diff --git a/packages/react-native/scripts/react_native_pods.rb b/packages/react-native/scripts/react_native_pods.rb index f8e3f7d729d7..41388454bc25 100644 --- a/packages/react-native/scripts/react_native_pods.rb +++ b/packages/react-native/scripts/react_native_pods.rb @@ -47,6 +47,8 @@ def min_supported_versions # This function prepares the project for React Native, before processing # all the target exposed by the framework. def prepare_react_native_project! + ReactNativePodsUtils.error_if_spm_injected + # Temporary solution to suppress duplicated GUID error & master specs repo warning. # Can be removed once we move to generate files outside pod install. install! 'cocoapods', :deterministic_uuids => false, :warn_for_unused_master_specs_repo => false @@ -96,6 +98,7 @@ def use_react_native! ( config_file_dir: '', privacy_file_aggregation_enabled: true ) + ReactNativePodsUtils.error_if_spm_injected error_if_try_to_use_jsc_from_core() warn_if_new_arch_disabled() diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 5550e73e249c..e03d782464ca 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -29,14 +29,14 @@ * settings, the Sync build phase) into your * existing .xcodeproj, in place. Idempotent. * Default on first run. `--deintegrate` first - * runs `pod deintegrate` + moves the Podfile to - * Podfile.bak (CocoaPods → SPM migration). + * runs `pod deintegrate` and keeps the Podfile + * (CocoaPods → SPM migration). * update Re-run the pipeline and refresh the existing * injection. Default once a project is injected. * deinit The exact inverse of `add`: surgically remove * only what `add` injected (recorded in - * .spm-injected.json) and drop the marker. - * Restores Podfile.bak to Podfile. + * .spm-injected.json) and drop the marker. Then + * `pod install` goes back to CocoaPods. * scaffold Generate Package.swift for community deps that * lack SPM support. * sync Re-run the build-time autolinking sync and its @@ -44,10 +44,9 @@ * it; run it by hand after precompiling a module. * codegen / download Advanced/internal: run a single pipeline step. * - * Zero-arg `npx react-native spm` auto-detects: a freshly-scaffolded CocoaPods - * project (clean tree, stock Podfile) → `add --deintegrate`; an injected - * project → `update`; otherwise → `add` (fails loud on a CocoaPods project, - * directing you to `--deintegrate`). + * Zero-arg `npx react-native spm` auto-detects: an injected project → `update`; + * otherwise → `add` (fails loud on a CocoaPods project, directing you to + * `--deintegrate`). * * Options: * --version React Native version. Pinned into @@ -57,8 +56,8 @@ * --yes Skip the dirty-pbxproj confirmation prompt. * [add] --xcodeproj Which .xcodeproj to inject into (when several). * [add] --product-name Which app target to inject into (when several). - * [add] --deintegrate Run `pod deintegrate` + move the Podfile to - * Podfile.bak before injecting. + * [add] --deintegrate Run `pod deintegrate` before injecting. The + * Podfile is kept. * [advanced] --artifacts Local two-flavor artifact root; * must contain debug/ and release/ cache slots. * [advanced] --download Artifact policy (default: auto). @@ -202,7 +201,7 @@ function parseArgs(argv /*: Array */) /*: SetupArgs */ { type: 'boolean', default: false, describe: - '[add] Run `pod deintegrate` + move the Podfile to Podfile.bak before injecting (CocoaPods → SPM migration).', + '[add] Run `pod deintegrate` before injecting (CocoaPods → SPM migration). The Podfile is kept.', }) .option('artifacts', { type: 'string', @@ -275,42 +274,43 @@ const SPM_GITIGNORE_HEADER = '# SPM – auto-generated at build time (do not commit)'; /** - * Ensure the project's .gitignore contains entries for SPM-generated - * directories, so generated artifacts are not accidentally committed. Returns - * `previous` (the record in the marker, if any) extended with what this run - * wrote, for `spm deinit` to undo. + * The lines `add` appends to the project's .gitignore so SPM-generated + * directories are not committed, and whether that creates the file; null when + * every entry is already there. The injector records them in the marker for + * `spm deinit` to undo. */ -function ensureGitignoreSpmEntries( +function missingGitignoreSpmEntries( appRoot /*: string */, - previous /*: ?GitignoreRecord */, -) /*: GitignoreRecord */ { +) /*: ?GitignoreRecord */ { const gitignorePath = path.join(appRoot, '.gitignore'); const exists = fs.existsSync(gitignorePath); const content = exists ? fs.readFileSync(gitignorePath, 'utf8') : ''; - const created = previous?.created === true; - const lines = previous?.lines ?? []; - const existingEntries = new Set(content.split('\n').map(l => l.trim())); const missing = SPM_GITIGNORE_ENTRIES.filter(e => !existingEntries.has(e)); + return missing.length === 0 + ? null + : {created: !exists, lines: [SPM_GITIGNORE_HEADER, ...missing]}; +} - if (missing.length === 0) { - return {created, lines}; - } - - const block = ['', SPM_GITIGNORE_HEADER, ...missing].join('\n'); - - // Append, ensuring we start on a fresh line +function appendGitignoreSpmEntries( + appRoot /*: string */, + appended /*: GitignoreRecord */, +) /*: void */ { + const gitignorePath = path.join(appRoot, '.gitignore'); + const content = fs.existsSync(gitignorePath) + ? fs.readFileSync(gitignorePath, 'utf8') + : ''; const separator = content.length > 0 && !content.endsWith('\n') ? '\n' : ''; + // The blank line before the header is what removeGitignoreSpmEntries + // removes with it. + const block = ['', ...appended.lines].join('\n'); fs.writeFileSync(gitignorePath, content + separator + block + '\n', 'utf8'); - log(`Updated .gitignore with SPM entries: ${missing.join(', ')}`); - return { - created: created || !exists, - lines: [...lines, SPM_GITIGNORE_HEADER, ...missing], - }; + const entries = appended.lines.filter(line => line !== SPM_GITIGNORE_HEADER); + log(`Updated .gitignore with SPM entries: ${entries.join(', ')}`); } /** - * The inverse of ensureGitignoreSpmEntries: removes the lines `record` says + * The inverse of appendGitignoreSpmEntries: removes the lines `record` says * `add` appended (a header with the blank line written before it), and the * file when `add` created it and nothing else is left. Without a record, as * in a marker from before it existed, .gitignore is left untouched. @@ -403,9 +403,8 @@ function resolveAction( return requestedAction; } // Zero-arg default. Once SPM has been injected, default to `update` (regen + - // refresh). Otherwise it's a first run → `add`. Whether `add` should imply - // `--deintegrate` (fresh CocoaPods project) is decided by the safe-gate in - // main(), which only applies on this implicit path. + // refresh). Otherwise it's a first run → `add`, which fails loud on a + // CocoaPods project: converting one takes an explicit `add --deintegrate`. return findInjectedXcodeproj(appRoot) != null ? 'update' : 'add'; } @@ -752,95 +751,57 @@ function pbxprojUsesCocoaPods(xcodeprojPath /*: string */) /*: boolean */ { function cocoaPodsIntegratedMessage(xcodeprojPath /*: string */) /*: string */ { return ( - `${path.basename(xcodeprojPath)} is CocoaPods-integrated. Re-run ` + - '`spm add --deintegrate`. It runs `pod deintegrate` and moves the Podfile ' + - 'to Podfile.bak. Pods other than React Native are not installed while ' + - 'the app uses SwiftPM, unless SwiftPM provides them. `spm deinit` ' + - 'restores the Podfile.' + `${path.basename(xcodeprojPath)} is CocoaPods-integrated. To move it to ` + + 'SwiftPM, run `npx react-native spm add --deintegrate`. It runs ' + + '`pod deintegrate` and keeps the Podfile, so ' + + '`npx react-native spm deinit` and then `pod install` go back to ' + + 'CocoaPods. Pods other than React Native are not installed while the app ' + + 'uses SwiftPM, unless SwiftPM provides them.' ); } -// True when the Podfile still declares React Native integration — a latent -// landmine even after `pod deintegrate` (a future `pod install` re-breaks the -// SPM graph). Warned about (not refused) once the pbxproj itself is clean. -function podfileHasRnIntegration(appRoot /*: string */) /*: boolean */ { - const podfilePath = path.join(appRoot, 'Podfile'); - if (!fs.existsSync(podfilePath)) { - return false; - } - return /use_react_native!|use_native_modules!|prepare_react_native_project!/.test( - fs.readFileSync(podfilePath, 'utf8'), - ); -} +// A `:path` or `:podspec` value, in either Ruby hash syntax, that points into +// the react-native package itself. +const REACT_NATIVE_POD_SOURCE = + /(?::(?:path|podspec)\s*=>|\b(?:path|podspec):)\s*(['"])(?:[^'"]*\/)?node_modules\/react-native(?:\/[^'"]*)?\1/; // The names of the pods the Podfile declares explicitly (`pod '...'` or -// `pod('...')`), deduplicated, in order. React Native itself comes in through -// use_react_native!, so every name here is a pod that is not part of it. +// `pod('...')`), deduplicated, in order. A pod sourced from the react-native +// package is part of React Native and left out, as is everything +// use_react_native! brings in. function podfilePodNames(appRoot /*: string */) /*: Array */ { const podfilePath = path.join(appRoot, 'Podfile'); if (!fs.existsSync(podfilePath)) { return []; } - const names /*: Set */ = new Set(); + const statements /*: Array */ = []; + let continued = ''; for (const line of fs.readFileSync(podfilePath, 'utf8').split('\n')) { - // `#{` is Ruby string interpolation, not a comment. - for (const statement of line.split(/#(?!\{)/)[0].split(';')) { - const match = /^\s*pod\s*\(?\s*(['"])(.+?)\1/.exec(statement); - if (match != null) { - names.add(match[2]); - } + // `#{` is Ruby string interpolation, not a comment. A line that ends in + // `,` continues on the next one. + const code = continued + line.split(/#(?!\{)/)[0].trimEnd(); + if (code.endsWith(',')) { + continued = code + ' '; + } else { + continued = ''; + statements.push(...code.split(';')); } } - return [...names]; -} - -function podfileHasThirdPartyPods(appRoot /*: string */) /*: boolean */ { - return podfilePodNames(appRoot).length > 0; -} - -// The zero-arg "fresh project" safe-gate: auto-`add --deintegrate` ONLY when it -// is provably safe — a first-run CocoaPods RN project with a stock Podfile (no -// third-party pods) whose pbxproj AND Podfile are git-tracked and clean, so the -// conversion is fully revertible. We check only the two files `deintegrate` -// mutates (not the whole tree) — a fresh app typically has a dirty -// node_modules/lockfile/patches after `npm install` + `spm scaffold`, none of -// which affect the revertibility of the CocoaPods → SwiftPM conversion. -// Otherwise false → strict `add` (which fails loud on a CocoaPods project). -function shouldAutoDeintegrate( - appRoot /*: string */, - xcodeprojPath /*: string | null */, -) /*: boolean */ { - if (xcodeprojPath == null || !pbxprojUsesCocoaPods(xcodeprojPath)) { - return false; - } - if (podfileHasThirdPartyPods(appRoot) || hasPodfileBackup(appRoot)) { - return false; - } - const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); - if (gitTrackedAndClean(appRoot, pbxprojPath) !== true) { - return false; - } - const podfilePath = path.join(appRoot, 'Podfile'); - if ( - fs.existsSync(podfilePath) && - gitTrackedAndClean(appRoot, podfilePath) !== true - ) { - return false; + statements.push(continued); + const names /*: Set */ = new Set(); + for (const statement of statements) { + const match = /^\s*pod\s*\(?\s*(['"])(.+?)\1/.exec(statement); + if (match != null && !REACT_NATIVE_POD_SOURCE.test(statement)) { + names.add(match[2]); + } } - return true; + return [...names]; } -// Run `pod deintegrate` then move the Podfile aside as Podfile.bak, unedited, -// so `spm deinit` can put CocoaPods back exactly as it was. Requires CocoaPods -// on PATH (fail-loud otherwise). Flag-gated ⇒ no prompt ⇒ CI-safe. Does NOT -// touch the .xcworkspace. +// Run `pod deintegrate` and leave the Podfile as it is, so `spm deinit` and +// then `pod install` go back to CocoaPods. Requires CocoaPods on PATH +// (fail-loud otherwise). Does NOT touch the .xcworkspace. function runDeintegrate(appRoot /*: string */) /*: void */ { - const podfilePath = path.join(appRoot, 'Podfile'); - const backupPath = path.join(appRoot, 'Podfile.bak'); - if (refuseToOverwritePodfileBackup(appRoot)) { - process.exitCode = 1; - throw new Error('Podfile.bak already exists'); - } try { execFileSync('pod', ['--version'], {stdio: 'ignore'}); } catch { @@ -853,63 +814,6 @@ function runDeintegrate(appRoot /*: string */) /*: void */ { } log('Running `pod deintegrate`...'); execFileSync('pod', ['deintegrate'], {cwd: appRoot, stdio: 'inherit'}); - - if (entryExists(podfilePath)) { - fs.renameSync(podfilePath, backupPath); - log('Moved Podfile to Podfile.bak. `spm deinit` restores it.'); - } -} - -// Unlike existsSync, true for a dangling symlink too, which a rename onto -// that path would silently replace. -function entryExists(filePath /*: string */) /*: boolean */ { - try { - fs.lstatSync(filePath); - return true; - } catch { - return false; - } -} - -function hasPodfileBackup(appRoot /*: string */) /*: boolean */ { - return entryExists(path.join(appRoot, 'Podfile.bak')); -} - -// True (after logging) when runDeintegrate would have to overwrite -// Podfile.bak, which may hold the only copy of an earlier Podfile. -function refuseToOverwritePodfileBackup(appRoot /*: string */) /*: boolean */ { - if (!hasPodfileBackup(appRoot)) { - return false; - } - logError( - 'Podfile.bak already exists, and `--deintegrate` will not overwrite it. ' + - 'Move or delete Podfile.bak, then run the command again. ' + - 'Nothing was changed.', - ); - return true; -} - -// The inverse of runDeintegrate's Podfile move. Returns false (after logging) -// when both files exist, so the caller stops before changing anything. -function checkPodfileRestorable(appRoot /*: string */) /*: boolean */ { - if (hasPodfileBackup(appRoot) && entryExists(path.join(appRoot, 'Podfile'))) { - logError( - 'Both Podfile and Podfile.bak exist, so `spm deinit` cannot restore ' + - 'Podfile.bak without overwriting Podfile. Keep the one you want, ' + - 'delete the other, then run `spm deinit` again.', - ); - return false; - } - return true; -} - -function restorePodfile(appRoot /*: string */) /*: void */ { - const backupPath = path.join(appRoot, 'Podfile.bak'); - if (!entryExists(backupPath)) { - return; - } - fs.renameSync(backupPath, path.join(appRoot, 'Podfile')); - log('Restored Podfile from Podfile.bak. Next step: run `pod install`.'); } // Pick the .xcodeproj to inject into: --xcodeproj override > a prior in-place @@ -993,7 +897,7 @@ async function setupXcodeproj( reactNativeRoot /*: string */, action /*: string */, cxxApiDefines /*: ReadonlyArray */, - gitignore /*: ?GitignoreRecord */, + gitignoreAppended /*: ?GitignoreRecord */, ) /*: Promise */ { const target = resolveInjectionTarget(args, appRoot); if (target.error != null) { @@ -1004,39 +908,10 @@ async function setupXcodeproj( const xcodeprojPath = target.path; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); - // Snapshot the pbxproj's git state BEFORE deintegrate — `pod deintegrate` - // rewrites it (removing the Pods xcconfig layering), so checking afterward - // would always look dirty and trigger a spurious confirmation prompt. - const cleanBeforeEdits = gitTrackedAndClean(appRoot, pbxprojPath); - - if (args.deintegrate) { - runDeintegrate(appRoot); - // `pod deintegrate` strips the build integration but can leave an empty - // `Pods` group in the navigator — remove it so the converted project is - // visually clean. - if (cleanupLeftoverPodsGroup(xcodeprojPath)) { - log('Removed the leftover empty `Pods` group from the project.'); - } - } - - // Preflight: a still-CocoaPods-integrated pbxproj is the real build-breaker. - if (pbxprojUsesCocoaPods(xcodeprojPath)) { - logError(cocoaPodsIntegratedMessage(xcodeprojPath)); - process.exitCode = 1; - throw new Error('CocoaPods-integrated project'); - } - if (podfileHasRnIntegration(appRoot)) { - log( - '\x1b[33mNote: your Podfile still declares React Native integration. ' + - 'Remove it and avoid `pod install`, or it will re-break the SPM ' + - 'package graph.\x1b[0m', - ); - } - // No backup is made — git is the safety net. Refuse on a dirty/untracked - // pbxproj (as it was BEFORE any deintegrate edits) unless --yes, so a bad - // inject is always `git checkout`-able. - const clean = cleanBeforeEdits; + // pbxproj unless --yes, so a bad inject is always `git checkout`-able. Asked + // before `pod deintegrate` edits the pbxproj, so "no" changes nothing. + const clean = gitTrackedAndClean(appRoot, pbxprojPath); if (clean === false && !args.yes) { const proceed = await promptYesNo( `${path.basename(xcodeprojPath)} has uncommitted changes and no ` + @@ -1055,6 +930,23 @@ async function setupXcodeproj( ); } + if (args.deintegrate) { + runDeintegrate(appRoot); + // `pod deintegrate` strips the build integration but can leave an empty + // `Pods` group in the navigator — remove it so the converted project is + // visually clean. + if (cleanupLeftoverPodsGroup(xcodeprojPath)) { + log('Removed the leftover empty `Pods` group from the project.'); + } + } + + // Preflight: a still-CocoaPods-integrated pbxproj is the real build-breaker. + if (pbxprojUsesCocoaPods(xcodeprojPath)) { + logError(cocoaPodsIntegratedMessage(xcodeprojPath)); + process.exitCode = 1; + throw new Error('CocoaPods-integrated project'); + } + const result = injectSpmIntoExistingXcodeproj({ appRoot, reactNativeRoot, @@ -1067,13 +959,17 @@ async function setupXcodeproj( artifactsVersionOverride: args.version ?? null, configCommand: resolveConfigCommandToPin(args), cxxApiDefines, - gitignore, + gitignoreAppended, }); if (result.status !== 'injected') { logError(`SPM injection failed: ${result.reason}`); process.exitCode = 1; throw new Error(result.reason); } + // Written at once: the marker now records these lines, whatever fails next. + if (gitignoreAppended != null) { + appendGitignoreSpmEntries(appRoot, gitignoreAppended); + } // The community template has carried a dangling `JavaScriptCore.framework` // reference since RN 0.60 (navigator-only, unlinked, meaningless under @@ -1245,26 +1141,6 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { const action = resolveAction(args.action, appRoot); - // Zero-arg safe-gate: when `add` was resolved implicitly (no action typed) - // on a freshly-scaffolded CocoaPods project, imply `--deintegrate` so the - // common "new app → SPM" path is one command. Never on an explicit `spm add` - // (that stays strict and fails loud on CocoaPods). - if ( - action === 'add' && - args.action == null && - !args.deintegrate && - shouldAutoDeintegrate( - appRoot, - resolveInjectionTarget(args, appRoot).path ?? null, - ) - ) { - log( - 'Detected a freshly-scaffolded CocoaPods project — converting to SwiftPM ' + - '(running `pod deintegrate`). `spm deinit` restores CocoaPods.', - ); - args.deintegrate = true; - } - log(`Running SPM ${action} in: ${displayPath(appRoot)}`); if (projectRoot !== appRoot) { log(`Project root (package.json): ${displayPath(projectRoot)}`); @@ -1275,10 +1151,6 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { args.xcodeprojPath != null ? path.resolve(appRoot, args.xcodeprojPath) : findInjectedXcodeproj(appRoot); - if (!checkPodfileRestorable(appRoot)) { - process.exitCode = 1; - return; - } // removeSpmInjection drops the marker, and the record with it. const gitignoreRecord = xcodeprojPath != null ? readMarker(xcodeprojPath)?.gitignore : null; @@ -1287,8 +1159,6 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { removeSpmInjection({appRoot, xcodeprojPath}).status === 'removed' ) { log(`Removed SPM packages from ${path.basename(xcodeprojPath)}.`); - } else if (hasPodfileBackup(appRoot)) { - log('No SwiftPM injection found.'); } else { log('No SPM injection found — nothing to remove.'); } @@ -1300,31 +1170,29 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { 'Remove them by hand.', ); } - restorePodfile(appRoot); + if (fs.existsSync(path.join(appRoot, 'Podfile'))) { + log('To go back to CocoaPods, run `pod install`.'); + } return; } - if (args.deintegrate && refuseToOverwritePodfileBackup(appRoot)) { - process.exitCode = 1; - return; - } if (args.deintegrate) { const podNames = podfilePodNames(appRoot); if (podNames.length > 0) { - logError( - 'Your Podfile declares pods that are not part of React Native: ' + - `${podNames.join(', ')}. CocoaPods will not install them while ` + - 'the app uses SwiftPM, unless SwiftPM provides them. They are kept ' + - 'in Podfile.bak, and `npx react-native spm deinit` restores the ' + - 'Podfile.', + log( + '\x1b[33mYour Podfile declares pods that are not part of React ' + + `Native: ${podNames.join(', ')}. They are not installed while the ` + + 'app uses SwiftPM, unless SwiftPM provides them. The Podfile is ' + + 'kept, so `npx react-native spm deinit` and then `pod install` ' + + 'bring them back.\x1b[0m', ); } } // Fail fast on a CocoaPods-integrated target BEFORE the (expensive) pipeline, // so the user isn't made to wait through codegen + artifact download only to - // be told to re-run with --deintegrate. Skipped when --deintegrate is set - // (explicitly or via the safe-gate) — deintegration happens in setupXcodeproj. + // be told to re-run with --deintegrate. Skipped when --deintegrate is set — + // deintegration happens in setupXcodeproj. if ((action === 'add' || action === 'update') && !args.deintegrate) { const target = resolveInjectionTarget(args, appRoot); if (target.path != null && pbxprojUsesCocoaPods(target.path)) { @@ -1545,16 +1413,10 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { // so no path-locator JSON is written. buildPerAppHeaderTree(appRoot, {log}); - // First-time setup only adds the gitignore entries on `add`. The record - // reaches the marker through setupXcodeproj, which writes it. - let gitignoreRecord = null; - if (action === 'add') { - const injected = resolveInjectionTarget(args, appRoot).path; - gitignoreRecord = ensureGitignoreSpmEntries( - appRoot, - injected != null ? readMarker(injected)?.gitignore : null, - ); - } + // Only `add` adds the .gitignore entries. setupXcodeproj writes them once the + // injector has recorded them in the marker, so `spm deinit` removes them. + const gitignoreAppended = + action === 'add' ? missingGitignoreSpmEntries(appRoot) : null; // Xcodeproj setup: in-place injection into the existing project (the only // strategy — no rename, no from-scratch; git is the safety net). @@ -1565,7 +1427,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { reactNativeRoot, action, cxxApiDefines, - gitignoreRecord, + gitignoreAppended, ); } catch (e) { logError(`xcodeproj setup failed: ${e.message}`); @@ -1586,11 +1448,12 @@ if (require.main === module) { module.exports = { main, + appendGitignoreSpmEntries, detectStandardRnLayoutRedirect, determineVersion, - ensureGitignoreSpmEntries, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, + missingGitignoreSpmEntries, parseArgs, podfilePodNames, resolveAction, @@ -1599,6 +1462,5 @@ module.exports = { resolveExplicitConfigCommand, resolveCxxApiDefines, runDeintegrate, - shouldAutoDeintegrate, ensureBothArtifactFlavors, }; diff --git a/packages/react-native/scripts/spm/__docs__/README.md b/packages/react-native/scripts/spm/__docs__/README.md index 5417fdf0859a..c5f414f9ae84 100644 --- a/packages/react-native/scripts/spm/__docs__/README.md +++ b/packages/react-native/scripts/spm/__docs__/README.md @@ -23,16 +23,14 @@ cd ios npx react-native spm # add on first run, update thereafter ``` -On a fresh CocoaPods app, this converts the app to SwiftPM. It runs -`pod deintegrate` and moves the `Podfile` to `Podfile.bak`. A Podfile with pods -that are not part of React Native is not converted automatically; -`spm add --deintegrate` converts it and warns that those pods are not installed. -Without a Podfile, the CLI needs `@react-native-community/cli` 21 or newer to -find the iOS project. - -To go back to CocoaPods, run `npx react-native spm deinit`, then `pod install` -in `ios/`. If your Podfile does not call `react_native_post_install`, check -`REACT_NATIVE_PATH`; see +On a CocoaPods app, this stops and changes nothing. To convert the app, run +`npx react-native spm add --deintegrate`. It runs `pod deintegrate` and keeps +the `Podfile`. It warns about pods that are not part of React Native, because +they are not installed while the app uses SwiftPM. + +`pod install` on a converted app stops with an error. To go back to CocoaPods, +run `npx react-native spm deinit`, then `pod install` in `ios/`. If your Podfile +does not call `react_native_post_install`, check `REACT_NATIVE_PATH`; see [Removing / resetting](./spm-scripts.md#removing--resetting). **If an autolinked dependency ships no `Package.swift`, setup stops with diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 29cfe9c754f8..7e6dc3878a62 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -16,8 +16,7 @@ cd ios # First-time setup: injects SwiftPM packages into your existing MyApp.xcodeproj, # in place. `npx react-native spm` with no action auto-resolves to `add` (or -# `update` once injected); on a fresh CocoaPods app it converts in one command -# (implies --deintegrate). To do it explicitly: +# `update` once injected). A CocoaPods app needs --deintegrate to convert: npx react-native spm add --deintegrate # Open in Xcode (or `npm run ios`). Incremental dep changes auto-sync on build. @@ -39,34 +38,31 @@ dependency changes. **On a fresh clone or CI checkout, run ## CocoaPods → SwiftPM migration `spm add` injects into a project that is **not** CocoaPods-integrated. On a -CocoaPods app it fails loud and points you at `--deintegrate`, which: +CocoaPods app, `spm add` and a plain `spm` stop before they change anything and +point you at `--deintegrate`. `spm add --deintegrate`: 1. runs `pod deintegrate`. This removes CocoaPods integration from the `.xcodeproj` (Pods references, `[CP]` build phases, xcconfig links). -2. moves the `Podfile` to `Podfile.bak`, unchanged. `spm deinit` moves it back. - If `Podfile.bak` already exists, the command stops before it changes - anything. +2. keeps the `Podfile` as it is. 3. injects SwiftPM into the `.xcodeproj`. -The zero-arg conversion of a fresh app does the same steps. It does not start -when `Podfile.bak` exists; the command then fails as a plain `add` does. +If the `.xcodeproj` has uncommitted changes, the command asks before step 1. +Answer no, and the `.xcodeproj` and the Podfile stay as they are. If the Podfile declares pods that are not part of React Native, `--deintegrate` converts the app anyway and names those pods in a warning. See [Keeping non-RN pods](#keeping-non-rn-pods). -After the conversion, the app has no Podfile. The CLI then finds the iOS project -through the `.spm-injected.json` marker. This needs -`@react-native-community/cli` 21 or newer. An older CLI needs a Podfile to find -the iOS project. +`pod install` on a converted app stops with an error and changes nothing. To go +back to CocoaPods, run `npx react-native spm deinit`, then `pod install`. ### Keeping non-RN pods `--deintegrate` converts an app whose Podfile has pods that are not part of React Native, and warns. While the app uses SwiftPM, CocoaPods does not install those pods, unless SwiftPM provides them as an autolinked library or a -[local native module](#local-native-modules). The pods stay in `Podfile.bak`, -and `spm deinit` restores the Podfile. +[local native module](#local-native-modules). They stay in the Podfile, so +`spm deinit` and then `pod install` bring them back. To keep a CocoaPods-only pod next to SwiftPM, convert by hand: @@ -84,8 +80,7 @@ other pods through CocoaPods. > **Do not re-add `use_react_native!`.** React Native must come from _either_ > SwiftPM _or_ CocoaPods, never both. They share `build/generated/`, so a -> dual-managed RN does not build. If only the Podfile still declares -> `use_react_native!`, `spm add` prints a warning and continues. +> dual-managed RN does not build. The migration is fully reversible — see [Removing / resetting](#removing--resetting). @@ -117,23 +112,21 @@ react-native spm [action] [options] ``` With no action, the command **auto-resolves**: if SwiftPM has been injected -(`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. On -a freshly-scaffolded CocoaPods project (clean git tree, stock Podfile), the -zero-arg path also implies `--deintegrate` (the safe-gate). So -`npx react-native spm` converts a brand-new app to SwiftPM in one command. +(`.spm-injected.json` marker present) it routes to `update`; otherwise `add`. It +does not convert a CocoaPods app; that takes `spm add --deintegrate`. From the JS root of a standard RN app (sibling `ios/` subdir), the command redirects into `ios/` and prints a banner. -| Action | Description | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `add` | Inject SwiftPM packages (package refs, the base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig`, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` and moves the Podfile to `Podfile.bak`. | -| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | -| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Moves `Podfile.bak` back to `Podfile`. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | -| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | -| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | -| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | -| `download` (advanced) | Download/check xcframework artifacts only. | +| Action | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `add` | Inject SwiftPM packages (package refs, the base reference to `ReactNativeSPM/ReactNativeSPM.xcconfig`, the Sync build phase) into the existing `.xcodeproj`, in place. Idempotent. Default on first run. `--deintegrate` first runs `pod deintegrate` and keeps the Podfile. | +| `update` | Re-run the pipeline and refresh the existing injection. Default once a project is injected. | +| `deinit` | The inverse of `add`: surgically remove only what `add` injected (recorded in `.spm-injected.json`) and drop the marker. Leaves the Podfile alone; run `pod install` next to go back to CocoaPods. Git-recoverable; no prompt. Some edits it does not undo — see [Files the tool touches](#files-the-tool-touches). | +| `scaffold` | Generate `Package.swift` into `node_modules//` for community RN libraries that ship only a podspec. | +| `sync` (advanced) | Lightweight resync invoked by the Xcode auto-sync hooks. Regenerates invariant codegen and autolinking output only. Not for humans. | +| `codegen` (advanced) | Run codegen and install the SwiftPM codegen template only. | +| `download` (advanced) | Download/check xcframework artifacts only. | ## CLI Options @@ -145,7 +138,7 @@ Flags below use the `react-native spm` (camelCase) form. | `--yes` | Skip the dirty-pbxproj confirmation prompt | | `--xcodeproj ` | [add, update, scaffold, deinit] Which `.xcodeproj` to inject into or remove from (when several exist) | | `--productName ` | [add, update, scaffold] Which app target to inject into (when several exist) | -| `--deintegrate` | [add] Run `pod deintegrate` and move the Podfile to `Podfile.bak` before injecting. Warns about pods that are not part of React Native; see [Keeping non-RN pods](#keeping-non-rn-pods) | +| `--deintegrate` | [add] Run `pod deintegrate` before injecting. The Podfile is kept. Warns about pods that are not part of React Native; see [Keeping non-RN pods](#keeping-non-rn-pods) | | `--artifacts ` | [advanced] Local artifact root containing complete `debug/` and `release/` cache slots | | `--download ` | [advanced] Artifact download policy (default: auto) | | `--skipCodegen` | [advanced] Skip the codegen step | @@ -307,7 +300,6 @@ Paths are relative to the Xcode project directory (`ios/`) unless noted. | `ReactNativeSPM/Scripts/` | `add`, `update`, `scaffold` | Regenerated on each run and written when their content changes: `sync-autolinking.sh` and `embed-flavored-frameworks.sh`, the scripts the Sync and embed phases run. Plugin script phases keep their scripts in `project.pbxproj`. **Commit it.** When a script is missing, its phase fails the build with `error: is missing. Run 'npx react-native spm update' to generate it.` | Yes — both scripts are deleted, then each `ReactNativeSPM/` directory left empty | | `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update`, `scaffold` | The sync pre-action is added to the scheme that builds your target; a shared scheme is created if there is none. Commit this or teammates lose the pre-action. | Yes — a scheme `add` created is deleted only if it is still byte-identical to the generated one; otherwise only the pre-action is stripped | | `.gitignore` | `add` only | Created if absent, else appended: a `# SPM – auto-generated at build time` block adding `Package.resolved`, `build/generated/`, `build/xcframeworks/`, `.build/`. | Yes — only the lines `add` wrote, as recorded in the marker, and the file too if `add` created it and nothing else is left. A line you changed is kept, and `deinit` says so. A marker from an older version has no record, so `.gitignore` is left as it is | -| `Podfile` | `add --deintegrate` | Moved to `Podfile.bak`, unchanged. Refused, before any change, if `Podfile.bak` already exists. | Yes — `Podfile.bak` is moved back to `Podfile`. If both files exist, `deinit` stops before it changes anything | | `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. The `.xcworkspace` referencing them is left on disk. | **No** — run `pod install` to restore | The two pins are the `--version` pin (`artifactsVersionOverride`, see @@ -700,8 +692,8 @@ npx react-native spm deinit # surgically removes everything `add` injected pod install # then, to restore CocoaPods ``` -`deinit` also moves `Podfile.bak` back to `Podfile` and removes the `.gitignore` -lines that `add` recorded in the marker. +`deinit` also removes the `.gitignore` lines that `add` recorded in the marker. +It leaves the Podfile alone, so `pod install` can use it. The conversion (`spm add`) removed the app target's `${PODS_ROOT}`-anchored `REACT_NATIVE_PATH` and the unused `JavaScriptCore.framework` reference, and @@ -735,7 +727,8 @@ across apps; refresh it with `react-native spm update --download force`. | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `xcodebuild` fails: "Could not resolve package dependencies … `build/generated/autolinking` doesn't exist" | Fresh clone — run `npx react-native spm` once before building (see [Fresh clones & CI](#fresh-clones--ci)) | | Build fails: `error: …/ReactNativeSPM/Scripts/.sh is missing` | `ReactNativeSPM/` was not committed. Run `npx react-native spm update`, then commit `ReactNativeSPM/` | -| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` and moves the Podfile to `Podfile.bak`). Pods that are not part of React Native are not installed while the app uses SwiftPM (see [Keeping non-RN pods](#keeping-non-rn-pods)). | +| `spm add` fails: "CocoaPods-integrated project" | Re-run `spm add --deintegrate` (runs `pod deintegrate` and keeps the Podfile). Pods that are not part of React Native are not installed while the app uses SwiftPM (see [Keeping non-RN pods](#keeping-non-rn-pods)). | +| `pod install` fails: "This app uses Swift Package Manager for React Native" | The app was converted with `spm add`. To go back to CocoaPods, run `npx react-native spm deinit`, then `pod install`. | | `spm add` fails: "no .xcodeproj found" | Create an app first (`npx @react-native-community/cli init`) or make a project in Xcode, then `spm add`. | | `spm add` fails: "multiple .xcodeproj found" | Pass `--xcodeproj ` (and `--productName ` if multiple app targets). | | `Package.swift is missing for library ""` (exit 2) | The dep ships no SwiftPM support. `npx react-native spm scaffold`, then re-run setup; persist with `patch-package`. See [Community packages without a Package.swift](#community-packages-without-a-packageswift) | @@ -777,7 +770,6 @@ my-app/ios/ ReactNativeSPM/ <-- committed, rewritten by add/update ReactNativeSPM.xcconfig <-- React build settings; base configuration of the app configurations Scripts/ <-- scripts the Sync and embed phases run - Podfile.bak <-- the Podfile, moved aside by `--deintegrate`; `deinit` moves it back build/ generated/ autolinking/ <-- gitignored (regenerated at build time) diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 3afa46d0938c..850eb96df502 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -1946,7 +1946,7 @@ describe('gitignore marker field', () => { appRoot, reactNativeRoot: rnRoot, xcodeprojPath, - gitignore: RECORD, + gitignoreAppended: RECORD, }); expect(readMarker(xcodeprojPath).gitignore).toEqual(RECORD); }); @@ -1957,7 +1957,7 @@ describe('gitignore marker field', () => { appRoot, reactNativeRoot: rnRoot, xcodeprojPath, - gitignore: RECORD, + gitignoreAppended: RECORD, }); injectSpmIntoExistingXcodeproj({ appRoot, @@ -1967,6 +1967,26 @@ describe('gitignore marker field', () => { expect(readMarker(xcodeprojPath).gitignore).toEqual(RECORD); }); + it('extends the record with the lines a later add appended', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + gitignoreAppended: RECORD, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + gitignoreAppended: {created: false, lines: ['# SPM', '.build/']}, + }); + expect(readMarker(xcodeprojPath).gitignore).toEqual({ + created: true, + lines: ['# SPM', 'Package.resolved', '# SPM', '.build/'], + }); + }); + it('writes no record when add never passed one', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); injectSpmIntoExistingXcodeproj({ diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 8473a5312c42..ee5916d253f1 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -45,8 +45,12 @@ jest.mock('node:child_process', () => { // A test that sets mockInjectorCalls to an array runs `add` to the end: the // xcframeworks package (which needs real XCFramework slices) is skipped, and -// the injector (which needs that package's output) only records its options. +// the injector (which needs that package's output) only records its options, +// then refuses with mockInjectorRefusal when a test sets one. A test that sets +// mockCleanupError makes the cleanup that follows a successful injection throw. let mockInjectorCalls = null; +let mockInjectorRefusal = null; +let mockCleanupError = null; jest.mock('../generate-spm-package', () => { const actual = jest.requireActual('../generate-spm-package'); return { @@ -63,19 +67,28 @@ jest.mock('../generate-spm-xcodeproj', () => { return actual.injectSpmIntoExistingXcodeproj(opts); } mockInjectorCalls.push(opts); - return {status: 'injected', target: 'MyApp'}; + return mockInjectorRefusal == null + ? {status: 'injected', target: 'MyApp'} + : {status: 'refused', reason: mockInjectorRefusal}; + }, + cleanupDanglingJavaScriptCoreRef: xcodeprojPath => { + if (mockCleanupError != null) { + throw new Error(mockCleanupError); + } + return actual.cleanupDanglingJavaScriptCoreRef(xcodeprojPath); }, }; }); const { + appendGitignoreSpmEntries, detectStandardRnLayoutRedirect, determineVersion, ensureBothArtifactFlavors, - ensureGitignoreSpmEntries, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, main, + missingGitignoreSpmEntries, parseArgs, podfilePodNames, resolveAction, @@ -84,7 +97,6 @@ const { resolveCxxApiDefines, resolveExplicitConfigCommand, runDeintegrate, - shouldAutoDeintegrate, } = require('../../setup-apple-spm'); const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); const {SPM_INJECTED_MARKER} = require('../generate-spm-xcodeproj'); @@ -93,6 +105,7 @@ const {execFileSync} = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const readline = require('node:readline'); // Create an in-place-injected xcodeproj fixture: a directory carrying the // `.spm-injected.json` marker (what injectSpmIntoExistingXcodeproj writes). @@ -617,131 +630,6 @@ describe('detectStandardRnLayoutRedirect', () => { }); }); -// --------------------------------------------------------------------------- -// shouldAutoDeintegrate — the zero-arg safe-gate. Auto-convert ONLY a fresh -// CocoaPods RN project: CocoaPods pbxproj + stock Podfile (no third-party pods) -// + clean git tree. Anything else → false (strict `add`, fail-loud). -// --------------------------------------------------------------------------- - -describe('shouldAutoDeintegrate', () => { - let tempDir; - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-safegate-')); - }); - afterEach(() => { - fs.rmSync(tempDir, {recursive: true, force: true}); - }); - - it('false when the project is not CocoaPods-integrated', () => { - const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { - cocoapods: false, - }); - expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); - }); - - it('false when there is no target project at all', () => { - expect(shouldAutoDeintegrate(tempDir, null)).toBe(false); - }); - - it('false for a CocoaPods project whose Podfile has third-party pods', () => { - const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { - cocoapods: true, - }); - fs.writeFileSync( - path.join(tempDir, 'Podfile'), - "target 'MyApp' do\n use_react_native!\n pod 'MBProgressHUD'\nend\n", - ); - gitInitAndCommit(tempDir); - expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); - }); - - it('false when the pbxproj has uncommitted edits (not revertible)', () => { - const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { - cocoapods: true, - }); - fs.writeFileSync( - path.join(tempDir, 'Podfile'), - "target 'MyApp' do\n use_react_native!\nend\n", - ); - gitInitAndCommit(tempDir); - // Dirty the pbxproj itself after the commit → conversion not revertible. - fs.appendFileSync( - path.join(xcodeproj, 'project.pbxproj'), - '\n// local edit\n', - ); - expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); - }); - - it('true despite an unrelated dirty file when pbxproj + Podfile are clean', () => { - const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { - cocoapods: true, - }); - fs.writeFileSync( - path.join(tempDir, 'Podfile'), - "target 'MyApp' do\n config = use_native_modules!\n use_react_native!(:path => config[:reactNativePath])\nend\n", - ); - gitInitAndCommit(tempDir); - // A dirty lockfile / untracked file elsewhere must NOT block — the - // conversion only touches the pbxproj + Podfile, which stay clean. - fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '{}'); - expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(true); - }); - - it('false when Podfile.bak exists, which the conversion would overwrite', () => { - const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { - cocoapods: true, - }); - fs.writeFileSync( - path.join(tempDir, 'Podfile'), - "target 'MyApp' do\n use_react_native!\nend\n", - ); - fs.writeFileSync( - path.join(tempDir, 'Podfile.bak'), - "target 'Old' do\nend\n", - ); - gitInitAndCommit(tempDir); - expect(shouldAutoDeintegrate(tempDir, xcodeproj)).toBe(false); - }); - - describe('third-party pod detection', () => { - function podfileWith(line) { - return `target 'MyApp' do\n use_react_native!\n${line}\nend\n`; - } - - function autoDeintegrates(podfile) { - const xcodeproj = mkXcodeproj(tempDir, 'MyApp.xcodeproj', { - cocoapods: true, - }); - fs.writeFileSync(path.join(tempDir, 'Podfile'), podfile); - gitInitAndCommit(tempDir); - return shouldAutoDeintegrate(tempDir, xcodeproj); - } - - it.each([ - [" pod 'MBProgressHUD'"], - [' pod "MBProgressHUD"'], - [" pod('Firebase/Analytics')"], - [' pod ("Firebase/Analytics")'], - [" pod( 'Firebase/Analytics', '~> 10.0')"], - ["target 'App' do; pod('Foo'); end"], - [" use_frameworks!; pod 'Foo'"], - ])('detects %s', line => { - expect(autoDeintegrates(podfileWith(line))).toBe(false); - }); - - it.each([ - [" # pod 'MBProgressHUD'"], - [" podspec :path => '../MyLib.podspec'"], - [" pods_root = 'Pods'"], - [" pod_targets = ['MyApp']"], - [" # use_frameworks!; pod 'MBProgressHUD'"], - [" use_frameworks!; podspec :path => '../MyLib.podspec'"], - ])('ignores %s', line => { - expect(autoDeintegrates(podfileWith(line))).toBe(true); - }); - }); -}); - describe('podfilePodNames', () => { let tempDir; beforeEach(() => { @@ -770,6 +658,18 @@ describe('podfilePodNames', () => { ).toEqual(['A', 'B', 'C']); }); + it.each([ + [" pod 'MBProgressHUD'", 'MBProgressHUD'], + [' pod "MBProgressHUD"', 'MBProgressHUD'], + [" pod('Firebase/Analytics')", 'Firebase/Analytics'], + [' pod ("Firebase/Analytics")', 'Firebase/Analytics'], + [" pod( 'Firebase/Analytics', '~> 10.0')", 'Firebase/Analytics'], + ["target 'App' do; pod('Foo'); end", 'Foo'], + [" use_frameworks!; pod 'Foo'", 'Foo'], + ])('finds the pod in %s', (line, name) => { + expect(namesIn([line])).toEqual([name]); + }); + it('ignores commented-out pods and pod-like words', () => { expect( namesIn([ @@ -777,11 +677,28 @@ describe('podfilePodNames', () => { " use_frameworks! # pod 'Trailing'", " use_frameworks!; # pod 'AfterSemicolon'", " podspec :path => '../MyLib.podspec'", + " use_frameworks!; podspec :path => '../MyLib.podspec'", " pods_root = 'Pods'", + " pod_targets = ['MyApp']", ]), ).toEqual([]); }); + it('reads a pod statement that continues on the next lines', () => { + expect( + namesIn([ + " pod 'React-Core',", + " :path => '../node_modules/react-native/'", + " pod 'React-RCTText', # text", + ' :modular_headers => true,', + " :path => '../node_modules/react-native/Libraries/Text'", + " pod 'RNSVG',", + " :path => '../node_modules/react-native-svg'", + " pod 'OCMock', '~> 3.9.1'", + ]), + ).toEqual(['RNSVG', 'OCMock']); + }); + it('keeps pods whose names use Ruby interpolation', () => { expect( namesIn([ @@ -791,17 +708,31 @@ describe('podfilePodNames', () => { ).toEqual(['#{prefix}Core', 'Foo#{suffix}']); }); + it('leaves out pods that come from the react-native package', () => { + expect( + namesIn([ + " pod 'React-Core', :path => '../node_modules/react-native/'", + " pod 'React', :path => '../node_modules/react-native'", + ' pod "React-RCTText", :path => "../../node_modules/react-native/Libraries/Text"', + " pod 'glog', :podspec => '../node_modules/react-native/third-party-podspecs/glog.podspec'", + " pod('RCT-Folly', podspec: '../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec')", + " pod 'RNSVG', :path => '../node_modules/react-native-svg'", + " pod 'Local', :path => '../native/react-native/Local'", + ]), + ).toEqual(['RNSVG', 'Local']); + }); + it('is empty without a Podfile', () => { expect(podfilePodNames(tempDir)).toEqual([]); }); }); // --------------------------------------------------------------------------- -// CocoaPods conversion is reversible: `--deintegrate` moves the Podfile aside -// as Podfile.bak instead of editing it, and `deinit` moves it back. +// CocoaPods conversion keeps the Podfile: `--deintegrate` only runs +// `pod deintegrate`, and `deinit` + `pod install` go back to CocoaPods. // --------------------------------------------------------------------------- -describe('Podfile backup and restore', () => { +describe('CocoaPods conversion', () => { // Same shape as private/helloworld/ios/Podfile: a multi-line // use_react_native! call and a post_install hook, which line-based // stripping would leave as invalid Ruby. @@ -829,12 +760,13 @@ describe('Podfile backup and restore', () => { 'end', '', ].join('\n'); - // Shaped like packages/rn-tester/Podfile: local example pods, then a test - // pod in a nested target. + // Shaped like packages/rn-tester/Podfile: a React Native pod by path, local + // example pods, then a test pod in a nested target. const PODFILE_WITH_THIRD_PARTY_PODS = STOCK_PODFILE.replace( ' config = use_native_modules!\n', [ ' config = use_native_modules!', + " pod 'React-Core', :path => '../node_modules/react-native/'", " pod 'ScreenshotManager', :path => 'NativeModuleExample'", " pod 'MyNativeView', :path => 'NativeComponentExample'", " target 'MyAppTests' do", @@ -843,6 +775,7 @@ describe('Podfile backup and restore', () => { '', ].join('\n'), ); + const POD_INSTALL_LINE = 'To go back to CocoaPods, run `pod install`.'; let appRoot; let podfilePath; @@ -852,7 +785,7 @@ describe('Podfile backup and restore', () => { let errorSpy; beforeEach(() => { - appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-podfile-backup-')); + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-cocoapods-')); podfilePath = path.join(appRoot, 'Podfile'); backupPath = path.join(appRoot, 'Podfile.bak'); mockPodCalls.length = 0; @@ -867,6 +800,8 @@ describe('Podfile backup and restore', () => { process.exitCode = undefined; mockAutolinkingSourceDir = null; mockInjectorCalls = null; + mockInjectorRefusal = null; + mockCleanupError = null; fs.rmSync(appRoot, {recursive: true, force: true}); }); @@ -893,11 +828,11 @@ describe('Podfile backup and restore', () => { function expectCocoaPodsAdvice() { expect(errors()).toContain('CocoaPods-integrated'); - expect(errors()).toContain('`spm add --deintegrate`'); - expect(errors()).toContain('Podfile.bak'); + expect(errors()).toContain('`npx react-native spm add --deintegrate`'); + expect(errors()).toMatch(/keeps the Podfile/); + expect(errors()).toMatch(/`npx react-native spm deinit`[^]*`pod install`/); expect(errors()).toMatch(/other than React Native[^]*SwiftPM provides/); - expect(errors()).toContain('`spm deinit`'); - expect(errors()).not.toContain('Keeping non-RN pods'); + expect(errors()).not.toContain('Podfile.bak'); expect(errors()).not.toContain('https://'); } @@ -925,132 +860,53 @@ describe('Podfile backup and restore', () => { } describe('add --deintegrate', () => { - it('moves a stock Podfile aside as Podfile.bak, byte-identical and unedited', () => { + it('runs `pod deintegrate` and leaves the Podfile byte-identical, with no Podfile.bak', () => { fs.writeFileSync(podfilePath, STOCK_PODFILE); runDeintegrate(appRoot); expect(mockPodCalls).toContainEqual(['deintegrate']); - expect(fs.existsSync(podfilePath)).toBe(false); - expect(fs.readFileSync(backupPath, 'utf8')).toBe(STOCK_PODFILE); - }); - - it('fails loud and changes nothing when Podfile.bak already exists', () => { - const olderBackup = "target 'Old' do\nend\n"; - fs.writeFileSync(podfilePath, STOCK_PODFILE); - fs.writeFileSync(backupPath, olderBackup); - - expect(() => runDeintegrate(appRoot)).toThrow(); - - expect(process.exitCode).toBe(1); - expect(errors()).toContain('Podfile.bak already exists'); - expect(mockPodCalls).not.toContainEqual(['deintegrate']); expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); - expect(fs.readFileSync(backupPath, 'utf8')).toBe(olderBackup); + expect(fs.existsSync(backupPath)).toBe(false); }); - it('converts anyway and names the pods it will not install when the Podfile has non-React-Native pods', async () => { + it('converts anyway and warns about the pods that are not part of React Native', async () => { mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_PODS); await runAdd('--deintegrate'); - const warning = errors(); - expect(warning).toContain('ScreenshotManager, MyNativeView, OCMock'); - expect(warning).toContain('Podfile.bak'); - expect(warning).toContain('spm deinit'); - expect(warning).not.toMatch(/refused/i); + const warning = output(logSpy) + .split('\n') + .find(line => line.includes('ScreenshotManager')); + expect(warning).toContain('ScreenshotManager, MyNativeView, OCMock.'); + expect(warning).not.toContain('React-Core'); + expect(warning).toMatch(/not installed while the app uses SwiftPM/); + expect(warning).toMatch(/Podfile is kept/); + expect(warning).toMatch(/`npx react-native spm deinit`[^]*`pod install`/); + expect(warning).not.toContain('Podfile.bak'); + expect(errors()).not.toContain('ScreenshotManager'); expect(mockPodCalls).toContainEqual(['deintegrate']); - expect(fs.existsSync(podfilePath)).toBe(false); - expect(fs.readFileSync(backupPath, 'utf8')).toBe( - PODFILE_WITH_THIRD_PARTY_PODS, - ); - expect(fs.existsSync(path.join(appRoot, 'build'))).toBe(true); - }); - - it('does not auto-convert on the zero-arg path when the Podfile has non-React-Native pods', async () => { - const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { - cocoapods: true, - }); - fs.writeFileSync(podfilePath, PODFILE_WITH_THIRD_PARTY_PODS); - gitInitAndCommit(appRoot); - mockAutolinkingSourceDir = appRoot; - - await main([]); - - expect(process.exitCode).toBe(1); - expect(errors()).toContain('CocoaPods-integrated'); - expectNoPipelineOutput(xcodeproj); expect(fs.readFileSync(podfilePath, 'utf8')).toBe( PODFILE_WITH_THIRD_PARTY_PODS, ); expect(fs.existsSync(backupPath)).toBe(false); - }); - - it('refuses before any change when Podfile.bak already exists', async () => { - const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { - cocoapods: true, - }); - const olderBackup = "target 'Old' do\nend\n"; - fs.writeFileSync(podfilePath, STOCK_PODFILE); - fs.writeFileSync(backupPath, olderBackup); - mockAutolinkingSourceDir = appRoot; - - await main(['add', '--deintegrate']); - - expect(process.exitCode).toBe(1); - expect(errors()).toContain('Podfile.bak already exists'); - expectNoPipelineOutput(xcodeproj); - expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); - expect(fs.readFileSync(backupPath, 'utf8')).toBe(olderBackup); - }); - - it('does not auto-convert on the zero-arg path when Podfile.bak already exists', async () => { - const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { - cocoapods: true, - }); - const olderBackup = "target 'Old' do\nend\n"; - fs.writeFileSync(podfilePath, STOCK_PODFILE); - fs.writeFileSync(backupPath, olderBackup); - gitInitAndCommit(appRoot); - mockAutolinkingSourceDir = appRoot; - - await main([]); - - expect(process.exitCode).toBe(1); - expect(errors()).toContain('CocoaPods-integrated'); - expectNoPipelineOutput(xcodeproj); - expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); - expect(fs.readFileSync(backupPath, 'utf8')).toBe(olderBackup); - }); - - it('refuses before any change when Podfile.bak is a dangling symlink', async () => { - const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { - cocoapods: true, - }); - fs.writeFileSync(podfilePath, STOCK_PODFILE); - fs.symlinkSync('missing-podfile', backupPath); - - await runAdd('--deintegrate'); - - expect(process.exitCode).toBe(1); - expect(errors()).toContain('Podfile.bak already exists'); - expectNoPipelineOutput(xcodeproj); - expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); - expect(fs.readlinkSync(backupPath)).toBe('missing-podfile'); + expect(fs.existsSync(path.join(appRoot, 'build'))).toBe(true); }); // Shows that expectNoPipelineOutput can fail: this run gets past every // refusal and writes what the refusal tests rule out. it('runs the pipeline when nothing refuses the conversion', async () => { - mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); fs.writeFileSync(podfilePath, STOCK_PODFILE); await runAdd('--deintegrate'); + expect(process.exitCode).toBeUndefined(); expect(mockPodCalls).toContainEqual(['deintegrate']); expect(fs.existsSync(path.join(appRoot, 'build'))).toBe(true); expect(fs.existsSync(path.join(appRoot, '.gitignore'))).toBe(true); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); }); it('says when --deintegrate applies if the project is still CocoaPods-integrated after it', async () => { @@ -1063,6 +919,53 @@ describe('Podfile backup and restore', () => { expect(process.exitCode).toBe(1); expectCocoaPodsAdvice(); }); + + describe('on a project with uncommitted changes', () => { + let isTTY; + let question; + + beforeEach(() => { + isTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + question = jest.fn((_, answer) => answer('n')); + jest + .spyOn(readline, 'createInterface') + .mockReturnValue({question, close: () => {}}); + }); + + afterEach(() => { + if (isTTY == null) { + delete process.stdin.isTTY; + } else { + Object.defineProperty(process.stdin, 'isTTY', isTTY); + } + }); + + it('asks before `pod deintegrate`, and "no" changes nothing', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + gitInitAndCommit(appRoot); + const pbxprojPath = path.join(xcodeproj, 'project.pbxproj'); + fs.appendFileSync(pbxprojPath, '// local edit\n'); + const pbxprojBefore = fs.readFileSync(pbxprojPath, 'utf8'); + + await runAdd('--deintegrate'); + + expect(question).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(1); + expect(output(logSpy)).toContain('Aborted'); + expect(mockPodCalls).not.toContainEqual(['deintegrate']); + expect(mockInjectorCalls).toEqual([]); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + expect(fs.readFileSync(pbxprojPath, 'utf8')).toBe(pbxprojBefore); + expect(fs.existsSync(path.join(appRoot, '.gitignore'))).toBe(false); + }); + }); }); it('says when --deintegrate applies on an explicit `spm add` of a CocoaPods project', async () => { @@ -1078,78 +981,42 @@ describe('Podfile backup and restore', () => { expectNoPipelineOutput(xcodeproj); }); - describe('deinit', () => { - it('restores Podfile.bak to Podfile, byte-identical, and says to run `pod install`', async () => { - mkInjectedProject(); - fs.writeFileSync(backupPath, STOCK_PODFILE); + it('does not convert a fresh CocoaPods app on a plain `spm`, and changes nothing', async () => { + const xcodeproj = mkXcodeproj(appRoot, 'MyApp.xcodeproj', { + cocoapods: true, + }); + fs.writeFileSync(podfilePath, STOCK_PODFILE); + gitInitAndCommit(appRoot); + mockAutolinkingSourceDir = appRoot; - await main(['deinit']); + await main([]); - expect(process.exitCode).toBeUndefined(); - expect(fs.existsSync(backupPath)).toBe(false); - expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); - expect(output(logSpy)).toContain('pod install'); - }); + expect(process.exitCode).toBe(1); + expectCocoaPodsAdvice(); + expectNoPipelineOutput(xcodeproj); + expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); + }); - it('leaves the Podfile untouched when there is no Podfile.bak', async () => { + describe('deinit', () => { + it('leaves the Podfile byte-identical and says to run `pod install`', async () => { mkInjectedProject(); fs.writeFileSync(podfilePath, STOCK_PODFILE); await main(['deinit']); + expect(process.exitCode).toBeUndefined(); expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); expect(fs.existsSync(backupPath)).toBe(false); + expect(output(logSpy)).toContain(POD_INSTALL_LINE); }); - it('fails loud and keeps both files when Podfile and Podfile.bak both exist', async () => { + it('does not mention `pod install` without a Podfile', async () => { mkInjectedProject(); - const current = "target 'MyApp' do\nend\n"; - fs.writeFileSync(podfilePath, current); - fs.writeFileSync(backupPath, STOCK_PODFILE); - - await main(['deinit']); - - expect(process.exitCode).toBe(1); - expect(errors()).toContain('Podfile.bak'); - expect(errors()).toMatch(/\bPodfile\b(?!\.bak)/); - expect(fs.readFileSync(podfilePath, 'utf8')).toBe(current); - expect(fs.readFileSync(backupPath, 'utf8')).toBe(STOCK_PODFILE); - }); - - it('fails loud and changes nothing when Podfile is a dangling symlink next to Podfile.bak', async () => { - const xcodeproj = mkInjectedProject(); - const markerPath = path.join(xcodeproj, SPM_INJECTED_MARKER); - const markerBefore = fs.readFileSync(markerPath, 'utf8'); - fs.symlinkSync('missing-podfile', podfilePath); - fs.writeFileSync(backupPath, STOCK_PODFILE); - - await main(['deinit']); - - expect(process.exitCode).toBe(1); - expect(errors()).toContain('Both Podfile and Podfile.bak exist'); - expect(fs.readlinkSync(podfilePath)).toBe('missing-podfile'); - expect(fs.readFileSync(backupPath, 'utf8')).toBe(STOCK_PODFILE); - expect(fs.readFileSync(markerPath, 'utf8')).toBe(markerBefore); - }); - - it('restores Podfile.bak even when no SPM injection is found', async () => { - fs.writeFileSync(backupPath, STOCK_PODFILE); - - await main(['deinit']); - - expect(process.exitCode).toBeUndefined(); - expect(fs.existsSync(backupPath)).toBe(false); - expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); - expect(output(logSpy)).toContain('pod install'); - }); - - it('does not claim there is nothing to do when it restores Podfile.bak', async () => { - fs.writeFileSync(backupPath, STOCK_PODFILE); await main(['deinit']); - expect(output(logSpy)).toContain('No SwiftPM injection found.'); - expect(output(logSpy)).not.toContain('nothing to remove'); + expect(output(logSpy)).not.toContain('pod install'); + expect(fs.existsSync(podfilePath)).toBe(false); }); it('says there is nothing to remove when there is nothing to do', async () => { @@ -1160,12 +1027,12 @@ describe('Podfile backup and restore', () => { ); }); - it('restores Podfile.bak even when removing an empty directory fails', async () => { + it('still says to run `pod install` when removing an empty directory fails', async () => { mkInjectedProject(); fs.mkdirSync(path.join(appRoot, 'ReactNativeSPM', 'Scripts'), { recursive: true, }); - fs.writeFileSync(backupPath, STOCK_PODFILE); + fs.writeFileSync(podfilePath, STOCK_PODFILE); jest.spyOn(fs, 'rmdirSync').mockImplementation(() => { throw new Error('EACCES: permission denied'); }); @@ -1173,7 +1040,7 @@ describe('Podfile backup and restore', () => { await main(['deinit']); expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); - expect(fs.existsSync(backupPath)).toBe(false); + expect(output(logSpy)).toContain(POD_INSTALL_LINE); expect(errors()).toContain('EACCES'); }); }); @@ -1192,11 +1059,14 @@ describe('Podfile backup and restore', () => { gitignorePath = path.join(appRoot, '.gitignore'); }); - // What `add` does to .gitignore, with its record stored in the marker. - function add(previous = null) { - const record = ensureGitignoreSpmEntries(appRoot, previous); - mkInjectedProject({gitignore: record}); - return record; + // What a first `add` does to .gitignore, with its record in the marker. + function add() { + const appended = missingGitignoreSpmEntries(appRoot); + if (appended != null) { + appendGitignoreSpmEntries(appRoot, appended); + } + mkInjectedProject(appended != null ? {gitignore: appended} : {}); + return appended; } // Only deinit's output counts as a note; add's own log line does not. @@ -1229,10 +1099,10 @@ describe('Podfile backup and restore', () => { expect(add()).toEqual({created: true, lines: [HEADER, ...ENTRIES]}); }); - it('keeps the record unchanged on a later run that appends nothing', () => { - const first = add(); + it('appends nothing on a later run', () => { + add(); - expect(ensureGitignoreSpmEntries(appRoot, first)).toEqual(first); + expect(missingGitignoreSpmEntries(appRoot)).toBeNull(); }); it('deletes a .gitignore that add created and nobody changed', async () => { @@ -1309,7 +1179,7 @@ describe('Podfile backup and restore', () => { }); it('leaves .gitignore alone when the marker has no record (an older add)', async () => { - ensureGitignoreSpmEntries(appRoot); + appendGitignoreSpmEntries(appRoot, missingGitignoreSpmEntries(appRoot)); mkInjectedProject(); const before = readGitignore(); @@ -1337,17 +1207,22 @@ describe('Podfile backup and restore', () => { gitignorePath, userContent + ['', ...olderLines, ''].join('\n'), ); - const record = add({created: false, lines: olderLines}); - expect(record.lines).toEqual([...olderLines, HEADER, '.build/']); + const appended = missingGitignoreSpmEntries(appRoot); + expect(appended).toEqual({created: false, lines: [HEADER, '.build/']}); + appendGitignoreSpmEntries(appRoot, appended); + // The record as the injector merges it (remove-spm-injection-test.js). + mkInjectedProject({ + gitignore: {created: false, lines: [...olderLines, ...appended.lines]}, + }); await deinit(); expect(readGitignore()).toBe(userContent); }); - it('restores Podfile.bak even when the .gitignore cleanup fails', async () => { + it('still says to run `pod install` when the .gitignore cleanup fails', async () => { add(); - fs.writeFileSync(backupPath, STOCK_PODFILE); + fs.writeFileSync(podfilePath, STOCK_PODFILE); const readFileSync = fs.readFileSync; jest.spyOn(fs, 'readFileSync').mockImplementation((file, ...rest) => { if (file === gitignorePath) { @@ -1359,54 +1234,64 @@ describe('Podfile backup and restore', () => { await deinit(); expect(fs.readFileSync(podfilePath, 'utf8')).toBe(STOCK_PODFILE); - expect(fs.existsSync(backupPath)).toBe(false); + expect(output(logSpy)).toContain(POD_INSTALL_LINE); expect(gitignoreNotes().join('\n')).toContain('EIO'); }); - describe('add hands the injector the record it wrote', () => { - it('records a .gitignore that add created', async () => { + describe('add hands the injector the lines it appends', () => { + it('writes and hands over a .gitignore that add created', async () => { mkXcodeproj(appRoot, 'MyApp.xcodeproj'); await runAdd(); expect(process.exitCode).toBeUndefined(); expect(mockInjectorCalls).toHaveLength(1); - expect(mockInjectorCalls[0].gitignore).toEqual({ + expect(mockInjectorCalls[0].gitignoreAppended).toEqual({ created: true, lines: [HEADER, ...ENTRIES], }); + expect(readGitignore()).toBe(['', HEADER, ...ENTRIES, ''].join('\n')); }); - it('extends the record of an earlier add from the marker', async () => { + it('hands over only the lines this run appends', async () => { const olderLines = [HEADER, ...ENTRIES.slice(0, 3)]; fs.writeFileSync(gitignorePath, ['', ...olderLines, ''].join('\n')); mkInjectedProject({gitignore: {created: true, lines: olderLines}}); await runAdd(); - expect(mockInjectorCalls[0].gitignore).toEqual({ - created: true, - lines: [...olderLines, HEADER, '.build/'], + expect(mockInjectorCalls[0].gitignoreAppended).toEqual({ + created: false, + lines: [HEADER, '.build/'], }); }); - }); - }); - it('tells the user on the zero-arg conversion that `spm deinit` restores CocoaPods', async () => { - mkXcodeproj(appRoot, 'MyApp.xcodeproj', {cocoapods: true}); - fs.writeFileSync(podfilePath, STOCK_PODFILE); - gitInitAndCommit(appRoot); + it('writes no .gitignore when the injection fails', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + mockInjectorRefusal = 'no app target'; - await main([]); + await runAdd(); - const conversionMessage = output(logSpy) - .split('\n') - .find(line => line.includes('freshly-scaffolded CocoaPods project')); - expect(conversionMessage).toContain('spm deinit'); - expect(conversionMessage).toMatch(/restor/i); - expect(conversionMessage).not.toContain( - 'Revert with `git` or `spm deinit`', - ); + expect(process.exitCode).toBe(1); + expect(mockInjectorCalls).toHaveLength(1); + expect(fs.existsSync(gitignorePath)).toBe(false); + }); + + it('writes the lines the marker records even when a later step fails', async () => { + mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + mockCleanupError = 'EACCES: permission denied'; + + await runAdd(); + + expect(process.exitCode).toBe(1); + expect(errors()).toContain('EACCES'); + expect(mockInjectorCalls[0].gitignoreAppended.lines).toEqual([ + HEADER, + ...ENTRIES, + ]); + expect(readGitignore()).toBe(['', HEADER, ...ENTRIES, ''].join('\n')); + }); + }); }); }); diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 3775e8a59cb7..0b65ed16b188 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -2804,7 +2804,7 @@ function linkedReactNativeRoot( * when the project can't be safely edited (caller surfaces it; fail-loud). */ function injectSpmIntoExistingXcodeproj( - opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: ReadonlyArray, gitignore?: ?GitignoreRecord} */, + opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: ReadonlyArray, gitignoreAppended?: ?GitignoreRecord} */, ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { const {appRoot, reactNativeRoot, xcodeprojPath} = opts; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -2958,6 +2958,18 @@ function injectSpmIntoExistingXcodeproj( // the whole marker, this field with it. const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? null; + // `add` passes the lines it appends to .gitignore; `update` passes none and + // keeps the record. Without one (an older marker), deinit leaves .gitignore + // untouched. + const appended = opts.gitignoreAppended; + const gitignore /*: ?GitignoreRecord */ = + appended == null + ? prevMarker?.gitignore + : { + created: prevMarker?.gitignore?.created === true || appended.created, + lines: [...(prevMarker?.gitignore?.lines ?? []), ...appended.lines], + }; + // Marker: idempotency signal + the exact, reversible record of every edit so // `deinit` (removeSpmInjection) can undo precisely what was added. writeIfChanged( @@ -3005,10 +3017,7 @@ function injectSpmIntoExistingXcodeproj( schemeResult.status === 'created' || prevMarker?.scheme?.created === true, }, - // Passed by `add` with the previous record already folded in; - // `update` passes none and keeps it. Without one (an older marker), - // deinit leaves .gitignore untouched. - gitignore: opts.gitignore ?? prevMarker?.gitignore, + gitignore, }, null, 2, diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index 8a263f4484e2..70e5269efdae 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -26,10 +26,7 @@ export type SetupArgs = { // `add` target selection: which app target (when several) and which project. productName: string | null, xcodeprojPath: string | null, - // `add`: run `pod deintegrate` + move the Podfile to Podfile.bak before - // injecting. - // Also implied on the zero-arg path when resolveAction's safe-gate detects a - // freshly-scaffolded CocoaPods project (see setup-apple-spm.js). + // `add`: run `pod deintegrate` before injecting. The Podfile is kept. deintegrate: boolean, // Skip the single remaining confirmation (the add/update dirty-pbxproj // warning). Non-TTY auto-proceeds regardless (git is the safety net). From dae2c23b1a5f54edba1a24432364ab62f74f9a0a Mon Sep 17 00:00:00 2001 From: Christian Falch Date: Fri, 30 Oct 2026 19:54:55 +0100 Subject: [PATCH 4/4] Fix two review findings in `spm` Podfile reading and `deinit` output - A multi-line `pod` call with a blank or comment line in the middle no longer ends early, so a pod sourced from node_modules/react-native is not reported as a non-React Native pod. - `spm deinit` says to run `pod install` only when it removed the SwiftPM setup, and says `cd ios && pod install` when run from the JS root. --- .../react-native/scripts/setup-apple-spm.js | 16 +++++--- .../spm/__tests__/setup-apple-spm-test.js | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index e03d782464ca..38d1e40ea39f 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -779,7 +779,7 @@ function podfilePodNames(appRoot /*: string */) /*: Array */ { for (const line of fs.readFileSync(podfilePath, 'utf8').split('\n')) { // `#{` is Ruby string interpolation, not a comment. A line that ends in // `,` continues on the next one. - const code = continued + line.split(/#(?!\{)/)[0].trimEnd(); + const code = (continued + line.split(/#(?!\{)/)[0]).trimEnd(); if (code.endsWith(',')) { continued = code + ' '; } else { @@ -1154,10 +1154,10 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { // removeSpmInjection drops the marker, and the record with it. const gitignoreRecord = xcodeprojPath != null ? readMarker(xcodeprojPath)?.gitignore : null; - if ( + const removed = xcodeprojPath != null && - removeSpmInjection({appRoot, xcodeprojPath}).status === 'removed' - ) { + removeSpmInjection({appRoot, xcodeprojPath}).status === 'removed'; + if (removed && xcodeprojPath != null) { log(`Removed SPM packages from ${path.basename(xcodeprojPath)}.`); } else { log('No SPM injection found — nothing to remove.'); @@ -1170,8 +1170,12 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { 'Remove them by hand.', ); } - if (fs.existsSync(path.join(appRoot, 'Podfile'))) { - log('To go back to CocoaPods, run `pod install`.'); + if (removed && fs.existsSync(path.join(appRoot, 'Podfile'))) { + const podInstall = + appRoot === process.cwd() + ? 'pod install' + : `cd ${path.relative(process.cwd(), appRoot)} && pod install`; + log(`To go back to CocoaPods, run \`${podInstall}\`.`); } return; } diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index ee5916d253f1..0a916ba63ee9 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -699,6 +699,20 @@ describe('podfilePodNames', () => { ).toEqual(['RNSVG', 'OCMock']); }); + it('reads past blank and comment lines inside a continued pod statement', () => { + expect( + namesIn([ + " pod 'React-Core',", + ' # comment', + " :path => '../node_modules/react-native/'", + " pod 'React-RCTText',", + '', + " :path => '../node_modules/react-native/Libraries/Text'", + " pod 'OCMock', '~> 3.9.1'", + ]), + ).toEqual(['OCMock']); + }); + it('keeps pods whose names use Ruby interpolation', () => { expect( namesIn([ @@ -1027,6 +1041,29 @@ describe('CocoaPods conversion', () => { ); }); + it('does not mention `pod install` when it removed nothing', async () => { + fs.writeFileSync(podfilePath, STOCK_PODFILE); + + await main(['deinit']); + + expect(output(logSpy)).not.toContain('pod install'); + }); + + it('says where to run `pod install` when run from the JS root', async () => { + const iosDir = path.join(appRoot, 'ios'); + fs.mkdirSync(iosDir); + fs.writeFileSync(path.join(appRoot, 'package.json'), '{}'); + mkXcodeproj(iosDir, 'MyApp.xcodeproj'); + mkInjectedXcodeproj(iosDir, 'MyApp.xcodeproj'); + fs.writeFileSync(path.join(iosDir, 'Podfile'), STOCK_PODFILE); + + await main(['deinit']); + + expect(output(logSpy)).toContain( + 'To go back to CocoaPods, run `cd ios && pod install`.', + ); + }); + it('still says to run `pod install` when removing an empty directory fails', async () => { mkInjectedProject(); fs.mkdirSync(path.join(appRoot, 'ReactNativeSPM', 'Scripts'), {