From 94a8fdff027957db210003db9ea75e57bc3a6af6 Mon Sep 17 00:00:00 2001 From: Oscar Franco Date: Wed, 12 Aug 2026 18:17:34 -0400 Subject: [PATCH 1/3] Improves spm deintegrate script to handle more edge-cases --- .../react-native/scripts/setup-apple-spm.js | 341 +++++++++++++++++- .../spm/__tests__/setup-apple-spm-test.js | 295 +++++++++++++++ 2 files changed, 626 insertions(+), 10 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index cfdb7f10512..ad182003eb9 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -694,9 +694,327 @@ function shouldAutoDeintegrate( return true; } +// The Podfile DSL calls that wire up React Native's CocoaPods integration. +const RN_PODFILE_CALLS = [ + 'use_react_native!', + 'use_native_modules!', + 'prepare_react_native_project!', +]; + +// Strip every occurrence of the RN Podfile calls above, including their +// argument list when the call spans multiple lines, e.g. the stock template's +// use_react_native!( +// :path => "...", +// :app_path => "..." +// ) +// A plain line-filter only removes the opening line and leaves the argument +// lines + closing paren behind, producing a syntactically broken Podfile. +// Only strips the call's own line(s); doesn't touch surrounding code, so a +// call assigned to a variable (`config = use_native_modules!(...)`) keeps its +// line but loses the call — matching prior (single-line) behavior. +function stripReactNativeFromPodfile(contents /*: string */) /*: string */ { + let text = contents; + for (const name of RN_PODFILE_CALLS) { + let out = ''; + let i = 0; + while (i < text.length) { + const idx = text.indexOf(name, i); + if (idx === -1) { + out += text.slice(i); + break; + } + out += text.slice(i, idx); + let end = idx + name.length; + let k = end; + while (k < text.length && (text[k] === ' ' || text[k] === '\t')) k++; + if (text[k] === '(') { + let depth = 0; + for (let m = k; m < text.length; m++) { + if (text[m] === '(') depth++; + else if (text[m] === ')') { + depth--; + if (depth === 0) { + end = m + 1; + break; + } + } + } + } + // If the rest of the line (after the call) is blank, drop the trailing + // newline too, so we don't leave an empty line behind. + let lineEnd = text.indexOf('\n', end); + if (lineEnd === -1) lineEnd = text.length; + if (text.slice(end, lineEnd).trim() === '') { + end = lineEnd < text.length ? lineEnd + 1 : lineEnd; + } + // If everything before the call on its line is just indentation, drop + // that indentation too, so we don't leave a whitespace-only line. + const lineStart = out.lastIndexOf('\n') + 1; + if (out.slice(lineStart).trim() === '') { + out = out.slice(0, lineStart); + } + i = end; + } + text = out; + } + return text; +} + +// Finds the matching `}` for the `{` at `openIdx`, or null if unbalanced. +function matchingBrace(text /*: string */, openIdx /*: number */) /*: number | null */ { + let depth = 0; + for (let i = openIdx; i < text.length; i++) { + if (text[i] === '{') depth++; + else if (text[i] === '}') { + depth--; + if (depth === 0) return i; + } + } + return null; +} + +// Finds `key: {` within `[start, end)`, but only occurrences at brace-depth 0 +// relative to `start` — i.e. a direct property of the object being scanned, +// not a same-named key nested inside some other property's value. Returns +// the `{...}` range of that key's object value, or null if absent. +function findTopLevelKeyObjectRange( + text /*: string */, + key /*: string */, + start /*: number */, + end /*: number */, +) /*: {open: number, close: number} | null */ { + const re = new RegExp('\\b' + key + '\\s*:\\s*{', 'g'); + re.lastIndex = start; + let m; + while ((m = re.exec(text)) && m.index < end) { + let depth = 0; + for (let i = start; i < m.index; i++) { + if (text[i] === '{') depth++; + else if (text[i] === '}') depth--; + } + if (depth === 0) { + const openIdx = m.index + m[0].length - 1; + const closeIdx = matchingBrace(text, openIdx); + if (closeIdx != null && closeIdx <= end) { + return {open: openIdx, close: closeIdx}; + } + } + } + return null; +} + +// Inserts `propertyText` (no trailing comma/newline) as the first property of +// the object whose `{` is at `openIdx`, matching the existing content's +// line-break style so we don't smash an empty `{}` and a populated object +// into the same shape. +function insertFirstProperty( + text /*: string */, + openIdx /*: number */, + indent /*: string */, + propertyText /*: string */, +) /*: string */ { + const rest = text.slice(openIdx + 1); + const needsNewlineAfter = !/^[ \t]*\r?\n/.test(rest); + return ( + text.slice(0, openIdx + 1) + + '\n' + + indent + + propertyText + + ',' + + (needsNewlineAfter ? '\n' + indent.slice(0, -2) : '') + + rest + ); +} + +// Sets `project.ios.automaticPodsInstallation` to `false` in the contents of +// a react-native.config.js, inserting whichever of `project` / `ios` / +// `automaticPodsInstallation` are missing. Returns null when `contents` +// doesn't look like a plain `module.exports = {...}` object literal — the +// caller should warn instead of risking a corrupt rewrite. +function withAutomaticPodsInstallationDisabled( + contents /*: string */, +) /*: string | null */ { + if (/automaticPodsInstallation\s*:\s*false\b/.test(contents)) { + return contents; + } + if (/automaticPodsInstallation\s*:\s*true\b/.test(contents)) { + return contents.replace( + /automaticPodsInstallation\s*:\s*true\b/, + 'automaticPodsInstallation: false', + ); + } + const exportsMatch = /module\.exports\s*=\s*{/.exec(contents); + if (!exportsMatch) { + return null; + } + const exportsOpen = exportsMatch.index + exportsMatch[0].length - 1; + const exportsClose = matchingBrace(contents, exportsOpen); + if (exportsClose == null) { + return null; + } + + const projectRange = findTopLevelKeyObjectRange( + contents, + 'project', + exportsOpen + 1, + exportsClose, + ); + if (projectRange == null) { + return insertFirstProperty( + contents, + exportsOpen, + ' ', + 'project: {\n ios: {\n automaticPodsInstallation: false,\n },\n }', + ); + } + + const iosRange = findTopLevelKeyObjectRange( + contents, + 'ios', + projectRange.open + 1, + projectRange.close, + ); + if (iosRange == null) { + return insertFirstProperty( + contents, + projectRange.open, + ' ', + 'ios: {\n automaticPodsInstallation: false,\n }', + ); + } + + return insertFirstProperty( + contents, + iosRange.open, + ' ', + 'automaticPodsInstallation: false', + ); +} + +// Disables automatic `pod install` on future `react-native run-ios` / +// `build-ios` invocations by setting `project.ios.automaticPodsInstallation` +// to `false` in react-native.config.js (default is `true` — see +// @react-native-community/cli-config's schema). Left on, it's a landmine: the +// CLI silently re-runs CocoaPods on the next build and re-breaks the SPM +// package graph, the same class of problem `podfileHasRnIntegration` warns +// about for the Podfile itself. +function disableAutomaticPodsInstallation(appRoot /*: string */) /*: void */ { + const configPath = path.join(appRoot, 'react-native.config.js'); + if (!fs.existsSync(configPath)) { + fs.writeFileSync( + configPath, + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n' + + ' automaticPodsInstallation: false,\n' + + ' },\n' + + ' },\n' + + '};\n', + 'utf8', + ); + log( + 'Created react-native.config.js with `automaticPodsInstallation: false`.', + ); + return; + } + const orig = fs.readFileSync(configPath, 'utf8'); + const updated = withAutomaticPodsInstallationDisabled(orig); + if (updated == null) { + log( + "\x1b[33mNote: couldn't automatically disable automaticPodsInstallation " + + "in react-native.config.js (unrecognized format). Set `project.ios." + + 'automaticPodsInstallation` to `false` yourself, or a future `pod ' + + 'install` will re-break the SPM package graph.\x1b[0m', + ); + return; + } + if (updated !== orig) { + fs.writeFileSync(configPath, updated, 'utf8'); + log('Disabled `automaticPodsInstallation` in react-native.config.js.'); + } +} + +// Locate the .xcworkspace CocoaPods manages alongside the .xcodeproj — same +// basename by convention (what `pod install` creates), falling back to the +// single *.xcworkspace in appRoot when the basenames don't line up. Returns +// null when there's no workspace at all (never `pod install`-ed) or when the +// fallback scan is ambiguous. +function findXcworkspace( + appRoot /*: string */, + xcodeprojPath /*: string */, +) /*: string | null */ { + const sibling = path.join( + path.dirname(xcodeprojPath), + path.basename(xcodeprojPath, '.xcodeproj') + '.xcworkspace', + ); + if (fs.existsSync(sibling)) { + return sibling; + } + const names /*: Array */ = []; + let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(appRoot, {withFileTypes: true}); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs + const name /*: string */ = entry.name; + if (name.endsWith('.xcworkspace')) { + names.push(name); + } + } + return names.length === 1 ? path.join(appRoot, names[0]) : null; +} + +// Strip the `group:Pods/Pods.xcodeproj` FileRef CocoaPods adds to the +// .xcworkspace's contents.xcworkspacedata, e.g.: +// +// +// `pod deintegrate` removes the Pods project/integration but doesn't touch +// the workspace, so this reference dangles — Xcode shows a permanent red, +// missing Pods.xcodeproj row in the workspace navigator otherwise. +function removeDanglingPodsFileRef(xml /*: string */) /*: string */ { + return xml.replace( + /[ \t]*|>\s*<\/FileRef>)\r?\n?/g, + '', + ); +} + +// Called by `add --deintegrate` after `pod deintegrate`. Only touches the +// reference when Pods/Pods.xcodeproj is actually gone from disk, so a +// side-by-side non-RN CocoaPods integration is never disturbed. No-op when +// the workspace, its contents.xcworkspacedata, or the reference is absent. +function cleanupDanglingPodsWorkspaceRef( + appRoot /*: string */, + xcodeprojPath /*: string */, +) /*: boolean */ { + if (fs.existsSync(path.join(appRoot, 'Pods', 'Pods.xcodeproj'))) { + return false; + } + const workspacePath = findXcworkspace(appRoot, xcodeprojPath); + if (workspacePath == null) { + return false; + } + const dataPath = path.join(workspacePath, 'contents.xcworkspacedata'); + if (!fs.existsSync(dataPath)) { + return false; + } + const orig = fs.readFileSync(dataPath, 'utf8'); + const cleaned = removeDanglingPodsFileRef(orig); + if (cleaned === orig) { + return false; + } + fs.writeFileSync(dataPath, cleaned, 'utf8'); + 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. +// no prompt ⇒ CI-safe. function runDeintegrate(appRoot /*: string */) /*: void */ { try { execFileSync('pod', ['--version'], {stdio: 'ignore'}); @@ -714,20 +1032,14 @@ function runDeintegrate(appRoot /*: string */) /*: void */ { 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'); + const stripped = stripReactNativeFromPodfile(orig); if (stripped !== orig) { fs.writeFileSync(podfilePath, stripped, 'utf8'); log('Stripped React Native integration from Podfile.'); } } + + disableAutomaticPodsInstallation(appRoot); } // Pick the .xcodeproj to inject into: --xcodeproj override > a prior in-place @@ -810,6 +1122,11 @@ async function setupXcodeproj( if (cleanupLeftoverPodsGroup(xcodeprojPath)) { log('Removed the leftover empty `Pods` group from the project.'); } + if (cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath)) { + log( + 'Removed the dangling Pods.xcodeproj reference from the .xcworkspace.', + ); + } } // Preflight: a still-CocoaPods-integrated pbxproj is the real build-breaker. @@ -1278,6 +1595,10 @@ module.exports = { resolveAction, resolveConfigCommandToPin, resolveExplicitConfigCommand, + cleanupDanglingPodsWorkspaceRef, + removeDanglingPodsFileRef, shouldAutoDeintegrate, + stripReactNativeFromPodfile, + withAutomaticPodsInstallationDisabled, ensureBothArtifactFlavors, }; 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 362538af284..f2b2f62b84a 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 @@ -11,16 +11,20 @@ 'use strict'; const { + cleanupDanglingPodsWorkspaceRef, detectStandardRnLayoutRedirect, determineVersion, ensureBothArtifactFlavors, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, parseArgs, + removeDanglingPodsFileRef, resolveAction, resolveConfigCommandToPin, resolveExplicitConfigCommand, shouldAutoDeintegrate, + stripReactNativeFromPodfile, + withAutomaticPodsInstallationDisabled, } = require('../../setup-apple-spm'); const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); const {SPM_INJECTED_MARKER} = require('../generate-spm-xcodeproj'); @@ -563,6 +567,297 @@ describe('shouldAutoDeintegrate', () => { }); }); +// --------------------------------------------------------------------------- +// stripReactNativeFromPodfile — removes the RN Podfile DSL calls, including +// multi-line argument lists (the stock template's `use_react_native!(...)` +// spans several lines), without corrupting the rest of the Podfile. +// --------------------------------------------------------------------------- + +describe('stripReactNativeFromPodfile', () => { + it('strips a single-line call', () => { + const podfile = + "target 'MyApp' do\n use_react_native!\nend\n"; + expect(stripReactNativeFromPodfile(podfile)).toBe( + "target 'MyApp' do\nend\n", + ); + }); + + it('strips a multi-line call with a parenthesized argument list', () => { + const podfile = + "target 'HelloWorld' do\n" + + ' config = use_native_modules!\n' + + '\n' + + ' use_react_native!(\n' + + ' :path => "../../../packages/react-native",\n' + + ' # An absolute path to your application root.\n' + + ' :app_path => "#{Pod::Config.instance.installation_root}/.."\n' + + ' )\n' + + '\n' + + " target 'HelloWorldTests' do\n" + + ' inherit! :complete\n' + + ' end\n' + + 'end\n'; + const stripped = stripReactNativeFromPodfile(podfile); + expect(stripped).not.toMatch(/use_react_native!/); + expect(stripped).not.toMatch(/:app_path/); + expect(stripped).not.toMatch(/^\s*\)\s*$/m); + expect(stripped).toBe( + "target 'HelloWorld' do\n" + + ' config = \n' + + '\n' + + " target 'HelloWorldTests' do\n" + + ' inherit! :complete\n' + + ' end\n' + + 'end\n', + ); + }); + + it('strips `prepare_react_native_project!` on its own line', () => { + const podfile = + 'platform :ios, min_ios_version_supported\n' + + 'prepare_react_native_project!\n' + + '\n' + + "target 'MyApp' do\nend\n"; + expect(stripReactNativeFromPodfile(podfile)).toBe( + 'platform :ios, min_ios_version_supported\n' + + '\n' + + "target 'MyApp' do\nend\n", + ); + }); + + it('leaves an unrelated Podfile untouched', () => { + const podfile = "target 'MyApp' do\n pod 'MBProgressHUD'\nend\n"; + expect(stripReactNativeFromPodfile(podfile)).toBe(podfile); + }); +}); + +// --------------------------------------------------------------------------- +// withAutomaticPodsInstallationDisabled — sets +// project.ios.automaticPodsInstallation to false in react-native.config.js, +// inserting `project` / `ios` / the key itself as needed. Left `true` (the +// CLI default), a future `react-native run-ios` silently re-runs CocoaPods +// and re-breaks the SPM package graph. +// --------------------------------------------------------------------------- + +describe('withAutomaticPodsInstallationDisabled', () => { + it('inserts a project.ios block into an empty config', () => { + expect(withAutomaticPodsInstallationDisabled('module.exports = {};\n')).toBe( + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n' + + ' automaticPodsInstallation: false,\n' + + ' },\n' + + ' },\n' + + '};\n', + ); + }); + + it('inserts a project.ios block ahead of existing keys', () => { + const config = + 'module.exports = {\n' + + ' dependencies: {\n' + + ' foo: {},\n' + + ' },\n' + + '};\n'; + expect(withAutomaticPodsInstallationDisabled(config)).toBe( + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n' + + ' automaticPodsInstallation: false,\n' + + ' },\n' + + ' },\n' + + ' dependencies: {\n' + + ' foo: {},\n' + + ' },\n' + + '};\n', + ); + }); + + it('inserts an ios block into an existing project with no ios key', () => { + const config = + 'module.exports = {\n' + + ' project: {\n' + + " android: {\n sourceDir: './android',\n },\n" + + ' },\n' + + '};\n'; + expect(withAutomaticPodsInstallationDisabled(config)).toBe( + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n' + + ' automaticPodsInstallation: false,\n' + + ' },\n' + + " android: {\n sourceDir: './android',\n },\n" + + ' },\n' + + '};\n', + ); + }); + + it('inserts the key into an existing project.ios block', () => { + const config = + 'module.exports = {\n' + + ' project: {\n' + + " ios: {\n sourceDir: './ios',\n },\n" + + ' },\n' + + '};\n'; + expect(withAutomaticPodsInstallationDisabled(config)).toBe( + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n' + + ' automaticPodsInstallation: false,\n' + + " sourceDir: './ios',\n" + + ' },\n' + + ' },\n' + + '};\n', + ); + }); + + it('flips an existing `true` to `false`', () => { + const config = + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n automaticPodsInstallation: true,\n },\n' + + ' },\n' + + '};\n'; + expect(withAutomaticPodsInstallationDisabled(config)).toBe( + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n automaticPodsInstallation: false,\n },\n' + + ' },\n' + + '};\n', + ); + }); + + it('is a no-op when already `false`', () => { + const config = + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n automaticPodsInstallation: false,\n },\n' + + ' },\n' + + '};\n'; + expect(withAutomaticPodsInstallationDisabled(config)).toBe(config); + }); + + it('returns null for an unrecognized config shape', () => { + expect( + withAutomaticPodsInstallationDisabled('export default { project: {} };\n'), + ).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// removeDanglingPodsFileRef — strips the `group:Pods/Pods.xcodeproj` FileRef +// `pod install` adds to contents.xcworkspacedata. `pod deintegrate` doesn't +// touch the workspace, so left alone this is a permanent red/missing row in +// Xcode's workspace navigator. +// --------------------------------------------------------------------------- + +describe('removeDanglingPodsFileRef', () => { + it('removes the Pods.xcodeproj FileRef, leaving the app project ref intact', () => { + const xml = + '\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + '\n'; + expect(removeDanglingPodsFileRef(xml)).toBe( + '\n' + + '\n' + + ' \n' + + ' \n' + + '\n', + ); + }); + + it('is a no-op when there is no Pods.xcodeproj reference', () => { + const xml = + '\n' + + '\n' + + ' \n' + + ' \n' + + '\n'; + expect(removeDanglingPodsFileRef(xml)).toBe(xml); + }); +}); + +// --------------------------------------------------------------------------- +// cleanupDanglingPodsWorkspaceRef — the safety-gated wrapper `add +// --deintegrate` calls: only rewrites contents.xcworkspacedata when +// Pods/Pods.xcodeproj is actually gone from disk, so a still-valid +// side-by-side CocoaPods integration is never disturbed. +// --------------------------------------------------------------------------- + +describe('cleanupDanglingPodsWorkspaceRef', () => { + let appRoot; + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-workspace-')); + }); + afterEach(() => { + fs.rmSync(appRoot, {recursive: true, force: true}); + }); + + function mkWorkspace(root, name) { + const dir = path.join(root, name); + fs.mkdirSync(dir, {recursive: true}); + fs.writeFileSync( + path.join(dir, 'contents.xcworkspacedata'), + '\n' + + '\n' + + ' \n` + + ' \n' + + ' \n' + + ' \n' + + '\n', + ); + return dir; + } + + it('removes the dangling ref when Pods.xcodeproj is gone from disk', () => { + const xcodeprojPath = mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + const workspace = mkWorkspace(appRoot, 'MyApp.xcworkspace'); + expect(cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath)).toBe(true); + const data = fs.readFileSync( + path.join(workspace, 'contents.xcworkspacedata'), + 'utf8', + ); + expect(data).not.toMatch(/Pods\.xcodeproj/); + }); + + it('leaves the ref alone when Pods.xcodeproj still exists on disk', () => { + const xcodeprojPath = mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + const workspace = mkWorkspace(appRoot, 'MyApp.xcworkspace'); + fs.mkdirSync(path.join(appRoot, 'Pods', 'Pods.xcodeproj'), { + recursive: true, + }); + expect(cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath)).toBe( + false, + ); + const data = fs.readFileSync( + path.join(workspace, 'contents.xcworkspacedata'), + 'utf8', + ); + expect(data).toMatch(/Pods\.xcodeproj/); + }); + + it('is a no-op when there is no .xcworkspace', () => { + const xcodeprojPath = mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + expect(cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath)).toBe( + false, + ); + }); +}); + // --------------------------------------------------------------------------- // determineVersion — which RN version the artifact slots are wired to: // explicit --version → the `artifactsVersionOverride` pinned in the injection From 53c039ae1e70232c2bb795a3e24a771e071768e1 Mon Sep 17 00:00:00 2001 From: Oscar Franco Date: Thu, 13 Aug 2026 12:26:28 -0400 Subject: [PATCH 2/3] Addresses PR comments --- .../react-native/scripts/setup-apple-spm.js | 615 ++++++++++++++---- .../scripts/spm/__doc__/spm-scripts.md | 27 +- .../spm/__tests__/setup-apple-spm-test.js | 405 +++++++++++- .../scripts/spm/generate-spm-xcodeproj.js | 24 +- .../react-native/scripts/spm/spm-types.js | 11 + 5 files changed, 934 insertions(+), 148 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index ad182003eb9..1b4b5e81baf 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -10,7 +10,7 @@ 'use strict'; -/*:: import type {CliConfigJson, SetupArgs} from './spm/spm-types'; */ +/*:: import type {AutomaticPodsInstallationResult, CliConfigJson, SetupArgs} from './spm/spm-types'; */ /** * setup-apple-spm.js – Entry point for setting up Swift Package Manager support @@ -648,7 +648,14 @@ function podfileHasRnIntegration(appRoot /*: string */) /*: boolean */ { if (!fs.existsSync(podfilePath)) { return false; } - return /use_react_native!|use_native_modules!|prepare_react_native_project!/.test( + // react_native_post_install is included because a stock template's + // `post_install do |installer| react_native_post_install(installer, + // config[:reactNativePath]) end` block references the removed + // use_native_modules! return value — stripReactNativeFromPodfile + // intentionally doesn't try to remove that block (its shape is too open- + // ended to strip safely), so this is what surfaces "you still have to + // finish cleaning up the Podfile by hand" to the user. + return /use_react_native!|use_native_modules!|prepare_react_native_project!|react_native_post_install/.test( fs.readFileSync(podfilePath, 'utf8'), ); } @@ -701,6 +708,10 @@ const RN_PODFILE_CALLS = [ 'prepare_react_native_project!', ]; +function escapeRegExp(s /*: string */) /*: string */ { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + // Strip every occurrence of the RN Podfile calls above, including their // argument list when the call spans multiple lines, e.g. the stock template's // use_react_native!( @@ -709,32 +720,46 @@ const RN_PODFILE_CALLS = [ // ) // A plain line-filter only removes the opening line and leaves the argument // lines + closing paren behind, producing a syntactically broken Podfile. -// Only strips the call's own line(s); doesn't touch surrounding code, so a -// call assigned to a variable (`config = use_native_modules!(...)`) keeps its -// line but loses the call — matching prior (single-line) behavior. +// +// Only matches the call at statement position — start of line (whitespace +// only), optionally preceded by a simple `lhs = ` assignment target — so a +// mention inside a comment (`# use_react_native! does X`) or embedded in +// other code is left untouched. When the call IS the entire assignment +// (`config = use_native_modules!(...)`), the `lhs = ` is consumed too and +// the whole line is dropped: leaving `config = ` behind is worse than +// removing it outright, since Ruby folds a dangling `lhs =` into whatever +// statement follows (e.g. `config = \n\npost_install do ... end` becomes +// `config = (post_install do ... end)`), corrupting unrelated code instead +// of just losing the `config` binding. Anything that then references +// `config` (e.g. a stock `post_install` block calling +// `react_native_post_install(installer, config[:reactNativePath])`) is +// intentionally NOT stripped here — its shape is too open-ended to remove +// safely — but podfileHasRnIntegration still detects the leftover +// react_native_post_install call and warns. function stripReactNativeFromPodfile(contents /*: string */) /*: string */ { let text = contents; for (const name of RN_PODFILE_CALLS) { + const re = new RegExp( + '^([ \\t]*)((?:[A-Za-z_$][\\w$]*\\s*=\\s*)?)' + escapeRegExp(name), + 'gm', + ); let out = ''; let i = 0; - while (i < text.length) { - const idx = text.indexOf(name, i); - if (idx === -1) { - out += text.slice(i); - break; - } - out += text.slice(i, idx); - let end = idx + name.length; + let m; + while ((m = re.exec(text))) { + const statementStart = m.index; + const callNameStart = statementStart + m[1].length + m[2].length; + let end = callNameStart + name.length; let k = end; while (k < text.length && (text[k] === ' ' || text[k] === '\t')) k++; if (text[k] === '(') { let depth = 0; - for (let m = k; m < text.length; m++) { - if (text[m] === '(') depth++; - else if (text[m] === ')') { + for (let p = k; p < text.length; p++) { + if (text[p] === '(') depth++; + else if (text[p] === ')') { depth--; if (depth === 0) { - end = m + 1; + end = p + 1; break; } } @@ -747,25 +772,55 @@ function stripReactNativeFromPodfile(contents /*: string */) /*: string */ { if (text.slice(end, lineEnd).trim() === '') { end = lineEnd < text.length ? lineEnd + 1 : lineEnd; } - // If everything before the call on its line is just indentation, drop - // that indentation too, so we don't leave a whitespace-only line. - const lineStart = out.lastIndexOf('\n') + 1; - if (out.slice(lineStart).trim() === '') { - out = out.slice(0, lineStart); - } + out += text.slice(i, statementStart); i = end; + re.lastIndex = end; } + out += text.slice(i); text = out; } return text; } +// Replaces the contents of `//` and `/* */` comments with spaces (same +// length, newlines preserved) so the brace/key scanning below isn't thrown +// off by a stray `}` or keyword sitting inside a comment. Only used for +// *finding* positions — every read/write below still slices the original +// text, so indices computed against the masked string stay valid. +function maskJsComments(text /*: string */) /*: string */ { + let out = ''; + let i = 0; + while (i < text.length) { + if (text[i] === '/' && text[i + 1] === '/') { + let j = i; + while (j < text.length && text[j] !== '\n') j++; + out += ' '.repeat(j - i); + i = j; + continue; + } + if (text[i] === '/' && text[i + 1] === '*') { + let j = text.indexOf('*/', i + 2); + j = j === -1 ? text.length : j + 2; + out += text.slice(i, j).replace(/[^\n]/g, ' '); + i = j; + continue; + } + out += text[i]; + i++; + } + return out; +} + // Finds the matching `}` for the `{` at `openIdx`, or null if unbalanced. -function matchingBrace(text /*: string */, openIdx /*: number */) /*: number | null */ { +// `masked` must be the same length as the real text (see maskJsComments). +function matchingBrace( + masked /*: string */, + openIdx /*: number */, +) /*: number | null */ { let depth = 0; - for (let i = openIdx; i < text.length; i++) { - if (text[i] === '{') depth++; - else if (text[i] === '}') { + for (let i = openIdx; i < masked.length; i++) { + if (masked[i] === '{') depth++; + else if (masked[i] === '}') { depth--; if (depth === 0) return i; } @@ -773,28 +828,30 @@ function matchingBrace(text /*: string */, openIdx /*: number */) /*: number | n return null; } -// Finds `key: {` within `[start, end)`, but only occurrences at brace-depth 0 -// relative to `start` — i.e. a direct property of the object being scanned, -// not a same-named key nested inside some other property's value. Returns -// the `{...}` range of that key's object value, or null if absent. +// Finds `key: {` (or `'key': {` / `"key": {`) within `[start, end)`, but only +// occurrences at brace-depth 0 relative to `start` — i.e. a direct property +// of the object being scanned, not a same-named key nested inside some other +// property's value, and not a longer identifier that merely contains `key`. +// Returns the `{...}` range of that key's object value, or null if absent. +// `masked` must be the same length as the real text (see maskJsComments). function findTopLevelKeyObjectRange( - text /*: string */, + masked /*: string */, key /*: string */, start /*: number */, end /*: number */, ) /*: {open: number, close: number} | null */ { - const re = new RegExp('\\b' + key + '\\s*:\\s*{', 'g'); + const re = new RegExp('(?` (or quoted-key variant) within `[start, end)` +// at brace-depth 0 relative to `start` — same top-level-only semantics as +// findTopLevelKeyObjectRange, but for a non-object value like `true`/`false`. +// Returns the match bounds in the real text and the trimmed value text, or +// null if absent. `masked` must be the same length as the real text. +function findTopLevelScalarValue( + masked /*: string */, + key /*: string */, + start /*: number */, + end /*: number */, +) /*: {matchStart: number, matchEnd: number, value: string} | null */ { + const re = new RegExp( + '(? out of a react-native.config.js's +// `module.exports = {...}` object literal, scoped the same way +// withAutomaticPodsInstallationDisabled writes it. Returns null when the +// path isn't found or the file isn't a recognized plain object literal — +// callers use that to distinguish "absent" from "not parseable". +function readProjectIosScalar( + contents /*: string */, + key /*: string */, +) /*: string | null */ { + const masked = maskJsComments(contents); + const exportsMatch = /module\.exports\s*=\s*{/.exec(masked); + if (!exportsMatch) { + return null; + } + const exportsOpen = exportsMatch.index + exportsMatch[0].length - 1; + const exportsClose = matchingBrace(masked, exportsOpen); + if (exportsClose == null) { + return null; + } + const projectRange = findTopLevelKeyObjectRange( + masked, + 'project', + exportsOpen + 1, + exportsClose, + ); + if (projectRange == null) { + return null; + } + const iosRange = findTopLevelKeyObjectRange( + masked, + 'ios', + projectRange.open + 1, + projectRange.close, + ); + if (iosRange == null) { + return null; + } + const found = findTopLevelScalarValue( + masked, + key, + iosRange.open + 1, + iosRange.close, + ); + return found?.value ?? null; +} + // Sets `project.ios.automaticPodsInstallation` to `false` in the contents of // a react-native.config.js, inserting whichever of `project` / `ios` / -// `automaticPodsInstallation` are missing. Returns null when `contents` -// doesn't look like a plain `module.exports = {...}` object literal — the -// caller should warn instead of risking a corrupt rewrite. +// `automaticPodsInstallation` are missing. All scanning is scoped to +// `project.ios` specifically (never a bare whole-file search), so a comment +// or an unrelated `automaticPodsInstallation` under a different key can't +// produce a false "already disabled" / silent no-op. Returns null when +// `contents` doesn't look like a plain `module.exports = {...}` object +// literal, or when the edit's result can't be verified afterward — either +// way the caller should warn instead of risking a corrupt or ineffective +// rewrite. function withAutomaticPodsInstallationDisabled( contents /*: string */, ) /*: string | null */ { - if (/automaticPodsInstallation\s*:\s*false\b/.test(contents)) { - return contents; - } - if (/automaticPodsInstallation\s*:\s*true\b/.test(contents)) { - return contents.replace( - /automaticPodsInstallation\s*:\s*true\b/, - 'automaticPodsInstallation: false', - ); - } - const exportsMatch = /module\.exports\s*=\s*{/.exec(contents); + const masked = maskJsComments(contents); + const exportsMatch = /module\.exports\s*=\s*{/.exec(masked); if (!exportsMatch) { return null; } const exportsOpen = exportsMatch.index + exportsMatch[0].length - 1; - const exportsClose = matchingBrace(contents, exportsOpen); + const exportsClose = matchingBrace(masked, exportsOpen); if (exportsClose == null) { return null; } const projectRange = findTopLevelKeyObjectRange( - contents, + masked, 'project', exportsOpen + 1, exportsClose, ); + + let updated; if (projectRange == null) { - return insertFirstProperty( + updated = insertFirstProperty( contents, exportsOpen, ' ', 'project: {\n ios: {\n automaticPodsInstallation: false,\n },\n }', ); + } else { + const iosRange = findTopLevelKeyObjectRange( + masked, + 'ios', + projectRange.open + 1, + projectRange.close, + ); + if (iosRange == null) { + updated = insertFirstProperty( + contents, + projectRange.open, + ' ', + 'ios: {\n automaticPodsInstallation: false,\n }', + ); + } else { + const existing = findTopLevelScalarValue( + masked, + 'automaticPodsInstallation', + iosRange.open + 1, + iosRange.close, + ); + if (existing == null) { + updated = insertFirstProperty( + contents, + iosRange.open, + ' ', + 'automaticPodsInstallation: false', + ); + } else if (existing.value === 'false') { + return contents; + } else if (existing.value === 'true') { + updated = + contents.slice(0, existing.matchStart) + + 'automaticPodsInstallation: false' + + contents.slice(existing.matchEnd); + } else { + // Some other expression (a variable, a ternary, ...) — don't guess. + return null; + } + } } + // Verify the edit actually took at the expected path before trusting it — + // cheap insurance against a scanning edge case we didn't anticipate + // producing a duplicate key or a value that isn't actually reachable. + return readProjectIosScalar(updated, 'automaticPodsInstallation') === 'false' + ? updated + : null; +} + +// Inverse of the 'edited' branch above: flips project.ios.automaticPodsInstallation +// from `false` back to `true`. Used by `spm deinit` to restore what +// `--deintegrate` changed. Returns null when the value isn't `false` at that +// scope anymore (hand-edited since) — the caller should leave it alone. +function withAutomaticPodsInstallationEnabled( + contents /*: string */, +) /*: string | null */ { + const masked = maskJsComments(contents); + const exportsMatch = /module\.exports\s*=\s*{/.exec(masked); + if (!exportsMatch) { + return null; + } + const exportsOpen = exportsMatch.index + exportsMatch[0].length - 1; + const exportsClose = matchingBrace(masked, exportsOpen); + if (exportsClose == null) { + return null; + } + const projectRange = findTopLevelKeyObjectRange( + masked, + 'project', + exportsOpen + 1, + exportsClose, + ); + if (projectRange == null) { + return null; + } const iosRange = findTopLevelKeyObjectRange( - contents, + masked, 'ios', projectRange.open + 1, projectRange.close, ); if (iosRange == null) { - return insertFirstProperty( - contents, - projectRange.open, - ' ', - 'ios: {\n automaticPodsInstallation: false,\n }', + return null; + } + const existing = findTopLevelScalarValue( + masked, + 'automaticPodsInstallation', + iosRange.open + 1, + iosRange.close, + ); + if (existing == null || existing.value !== 'false') { + return null; + } + return ( + contents.slice(0, existing.matchStart) + + 'automaticPodsInstallation: true' + + contents.slice(existing.matchEnd) + ); +} + +// The exact contents disableAutomaticPodsInstallation writes when it creates +// a fresh react-native.config.js. Used by restoreAutomaticPodsInstallation to +// recognize "nobody touched this since we created it" before deleting it. +const CREATED_RN_CONFIG_CONTENTS = + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n' + + ' automaticPodsInstallation: false,\n' + + ' },\n' + + ' },\n' + + '};\n'; + +// Undoes disableAutomaticPodsInstallation, using the marker's record of +// exactly what it did. Called by `spm deinit` so `automaticPodsInstallation` +// doesn't stay silently `false` after CocoaPods is back in charge — the same +// "record every mutation, undo exactly that" contract removeSpmInjection +// applies to the pbxproj (see generate-spm-xcodeproj.js). +function restoreAutomaticPodsInstallation( + result /*: ?AutomaticPodsInstallationResult */, +) /*: void */ { + if (result == null || result.kind === 'unrecognized') { + return; + } + if (result.kind === 'already-disabled') { + // We made no edit (it was already `false` before --deintegrate ran) — + // nothing to restore. + return; + } + if (result.kind === 'created') { + if ( + fs.existsSync(result.configPath) && + fs.readFileSync(result.configPath, 'utf8') === CREATED_RN_CONFIG_CONTENTS + ) { + fs.rmSync(result.configPath, {force: true}); + log( + `Removed ${path.basename(result.configPath)} (created by \`spm add --deintegrate\`).`, + ); + } else { + log( + '\x1b[33mNote: react-native.config.js has changed since `spm add ' + + '--deintegrate` created it — leaving it in place. Remove ' + + '`automaticPodsInstallation: false` yourself if you want ' + + 'CocoaPods to auto-install again.\x1b[0m', + ); + } + return; + } + // result.kind === 'edited' + if (!fs.existsSync(result.configPath)) { + return; + } + const orig = fs.readFileSync(result.configPath, 'utf8'); + const restored = withAutomaticPodsInstallationEnabled(orig); + if (restored == null) { + log( + "\x1b[33mNote: couldn't automatically restore automaticPodsInstallation " + + 'in react-native.config.js — it may have changed since `spm add ' + + '--deintegrate` disabled it. Set `project.ios.automaticPodsInstallation` ' + + 'back to `true` yourself if you want CocoaPods to auto-install ' + + 'again.\x1b[0m', ); + return; } + fs.writeFileSync(result.configPath, restored, 'utf8'); + log('Re-enabled `automaticPodsInstallation` in react-native.config.js.'); +} - return insertFirstProperty( - contents, - iosRange.open, - ' ', - 'automaticPodsInstallation: false', - ); +// Search order @react-native-community/cli-config's cosmiconfig setup uses +// (readConfigFromDisk.js's `searchPlaces`) — checked so we detect whichever +// config file the CLI would actually load instead of creating a second +// `react-native.config.js` that silently shadows a real `.ts` / `.cjs` / +// `.mjs` config the project already has. +const RN_CONFIG_SEARCH_PLACES = [ + 'react-native.config.js', + 'react-native.config.cjs', + 'react-native.config.ts', + 'react-native.config.mjs', +]; + +function findExistingReactNativeConfig( + projectRoot /*: string */, +) /*: string | null */ { + for (const name of RN_CONFIG_SEARCH_PLACES) { + const p = path.join(projectRoot, name); + if (fs.existsSync(p)) { + return p; + } + } + return null; } // Disables automatic `pod install` on future `react-native run-ios` / @@ -898,75 +1201,76 @@ function withAutomaticPodsInstallationDisabled( // CLI silently re-runs CocoaPods on the next build and re-breaks the SPM // package graph, the same class of problem `podfileHasRnIntegration` warns // about for the Podfile itself. -function disableAutomaticPodsInstallation(appRoot /*: string */) /*: void */ { - const configPath = path.join(appRoot, 'react-native.config.js'); - if (!fs.existsSync(configPath)) { - fs.writeFileSync( - configPath, - 'module.exports = {\n' + - ' project: {\n' + - ' ios: {\n' + - ' automaticPodsInstallation: false,\n' + - ' },\n' + - ' },\n' + - '};\n', - 'utf8', - ); +// +// `projectRoot` — NOT `appRoot` — because that's the only directory the CLI's +// cosmiconfig lookup ever searches (readConfigFromDisk.js resolves +// searchPlaces against, and sets `stopDir` to, the project root). Writing +// next to the .xcodeproj (`appRoot`, which is `/ios` for a +// standard app layout) would produce a file nothing reads. +function disableAutomaticPodsInstallation( + projectRoot /*: string */, +) /*: AutomaticPodsInstallationResult */ { + const existing = findExistingReactNativeConfig(projectRoot); + + if (existing == null) { + const configPath = path.join(projectRoot, 'react-native.config.js'); + fs.writeFileSync(configPath, CREATED_RN_CONFIG_CONTENTS, 'utf8'); log( 'Created react-native.config.js with `automaticPodsInstallation: false`.', ); - return; + return {kind: 'created', configPath}; } - const orig = fs.readFileSync(configPath, 'utf8'); + + if (path.extname(existing) !== '.js') { + log( + `\x1b[33mNote: found ${path.basename(existing)} — couldn't ` + + 'automatically disable automaticPodsInstallation in a non-.js ' + + 'config. Set `project.ios.automaticPodsInstallation` to `false` ' + + 'yourself, or a future `pod install` will re-break the SPM ' + + 'package graph.\x1b[0m', + ); + return {kind: 'unrecognized', configPath: existing}; + } + + const orig = fs.readFileSync(existing, 'utf8'); const updated = withAutomaticPodsInstallationDisabled(orig); if (updated == null) { log( "\x1b[33mNote: couldn't automatically disable automaticPodsInstallation " + - "in react-native.config.js (unrecognized format). Set `project.ios." + + 'in react-native.config.js (unrecognized format). Set `project.ios.' + 'automaticPodsInstallation` to `false` yourself, or a future `pod ' + 'install` will re-break the SPM package graph.\x1b[0m', ); - return; + return {kind: 'unrecognized', configPath: existing}; } - if (updated !== orig) { - fs.writeFileSync(configPath, updated, 'utf8'); - log('Disabled `automaticPodsInstallation` in react-native.config.js.'); + if (updated === orig) { + return {kind: 'already-disabled', configPath: existing}; } + fs.writeFileSync(existing, updated, 'utf8'); + log('Disabled `automaticPodsInstallation` in react-native.config.js.'); + return {kind: 'edited', configPath: existing}; } // Locate the .xcworkspace CocoaPods manages alongside the .xcodeproj — same // basename by convention (what `pod install` creates), falling back to the -// single *.xcworkspace in appRoot when the basenames don't line up. Returns -// null when there's no workspace at all (never `pod install`-ed) or when the -// fallback scan is ambiguous. -function findXcworkspace( - appRoot /*: string */, - xcodeprojPath /*: string */, -) /*: string | null */ { +// single *.xcworkspace alongside the .xcodeproj when the basenames don't +// line up. Both the sibling check and the fallback scan look in the SAME +// directory (the .xcodeproj's own directory, not appRoot) — those differ +// when `--xcodeproj` points into a subdirectory of appRoot, and scanning +// appRoot in that case would miss the workspace that's actually there (or +// find an unrelated one). Returns null when there's no workspace at all +// (never `pod install`-ed) or when the fallback scan is ambiguous. +function findXcworkspace(xcodeprojPath /*: string */) /*: string | null */ { + const dir = path.dirname(xcodeprojPath); const sibling = path.join( - path.dirname(xcodeprojPath), + dir, path.basename(xcodeprojPath, '.xcodeproj') + '.xcworkspace', ); if (fs.existsSync(sibling)) { return sibling; } - const names /*: Array */ = []; - let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; - try { - // $FlowFixMe[incompatible-type] Dirent typing - entries = fs.readdirSync(appRoot, {withFileTypes: true}); - } catch { - return null; - } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs - const name /*: string */ = entry.name; - if (name.endsWith('.xcworkspace')) { - names.push(name); - } - } - return names.length === 1 ? path.join(appRoot, names[0]) : null; + const names = listSubdirsWithSuffix(dir, '.xcworkspace'); + return names.length === 1 ? path.join(dir, names[0]) : null; } // Strip the `group:Pods/Pods.xcodeproj` FileRef CocoaPods adds to the @@ -977,9 +1281,15 @@ function findXcworkspace( // `pod deintegrate` removes the Pods project/integration but doesn't touch // the workspace, so this reference dangles — Xcode shows a permanent red, // missing Pods.xcodeproj row in the workspace navigator otherwise. +// Matches any whose `location` attribute ENDS in +// `Pods/Pods.xcodeproj`, regardless of the container prefix (`group:`, +// `container:`, ...), a nested path (`group:ios/Pods/Pods.xcodeproj`), or +// attribute order — this file is machine-generated by Xcode/CocoaPods with a +// stable shape, but pinning to one exact prefix/ordering is needless +// fragility for a location value that's really just being suffix-matched. function removeDanglingPodsFileRef(xml /*: string */) /*: string */ { return xml.replace( - /[ \t]*|>\s*<\/FileRef>)\r?\n?/g, + /[ \t]*]*\blocation\s*=\s*"[^"]*Pods\/Pods\.xcodeproj"[^>]*(?:\/>|>\s*<\/FileRef>)\r?\n?/g, '', ); } @@ -995,7 +1305,7 @@ function cleanupDanglingPodsWorkspaceRef( if (fs.existsSync(path.join(appRoot, 'Pods', 'Pods.xcodeproj'))) { return false; } - const workspacePath = findXcworkspace(appRoot, xcodeprojPath); + const workspacePath = findXcworkspace(xcodeprojPath); if (workspacePath == null) { return false; } @@ -1015,7 +1325,15 @@ function cleanupDanglingPodsWorkspaceRef( // 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. -function runDeintegrate(appRoot /*: string */) /*: void */ { +// +// `appRoot` is where `pod`/the Podfile live; `projectRoot` (the package.json +// directory, which differs from appRoot for a standard `/ios` +// layout) is where react-native.config.js lives — see +// disableAutomaticPodsInstallation. +function runDeintegrate( + appRoot /*: string */, + projectRoot /*: string */, +) /*: {automaticPodsInstallation: AutomaticPodsInstallationResult} */ { try { execFileSync('pod', ['--version'], {stdio: 'ignore'}); } catch { @@ -1039,7 +1357,36 @@ function runDeintegrate(appRoot /*: string */) /*: void */ { } } - disableAutomaticPodsInstallation(appRoot); + return { + automaticPodsInstallation: disableAutomaticPodsInstallation(projectRoot), + }; +} + +// Directory entry names (not full paths) of the immediate subdirectories of +// `dir` whose name ends with `suffix` — e.g. every `*.xcodeproj` or +// `*.xcworkspace` package (both are directories on disk) directly inside +// `dir`. Returns [] if `dir` doesn't exist / isn't readable. +function listSubdirsWithSuffix( + dir /*: string */, + suffix /*: string */, +) /*: Array */ { + const names /*: Array */ = []; + let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; + try { + // $FlowFixMe[incompatible-type] Dirent typing + entries = fs.readdirSync(dir, {withFileTypes: true}); + } catch { + return names; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs + const name /*: string */ = entry.name; + if (name.endsWith(suffix)) { + names.push(name); + } + } + return names; } // Pick the .xcodeproj to inject into: --xcodeproj override > a prior in-place @@ -1059,20 +1406,7 @@ function resolveInjectionTarget( if (injected != null) { return {path: injected}; } - const names /*: Array */ = []; - let entries /*: Array<{name: string, isDirectory(): boolean}> */ = []; - try { - // $FlowFixMe[incompatible-type] Dirent typing - entries = fs.readdirSync(appRoot, {withFileTypes: true}); - } catch {} - for (const entry of entries) { - if (!entry.isDirectory()) continue; - // $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs - const name /*: string */ = entry.name; - if (name.endsWith('.xcodeproj')) { - names.push(name); - } - } + const names = listSubdirsWithSuffix(appRoot, '.xcodeproj'); if (names.length === 0) { return { error: @@ -1097,6 +1431,7 @@ function resolveInjectionTarget( async function setupXcodeproj( args /*: SetupArgs */, appRoot /*: string */, + projectRoot /*: string */, reactNativeRoot /*: string */, action /*: string */, ) /*: Promise */ { @@ -1114,8 +1449,17 @@ async function setupXcodeproj( // would always look dirty and trigger a spurious confirmation prompt. const cleanBeforeEdits = gitTrackedAndClean(appRoot, pbxprojPath); + // Preserved across the `if` so it can be threaded into the marker below — + // only recorded on runs that actually deintegrate; `update` runs without + // `--deintegrate` pass `null` and injectSpmIntoExistingXcodeproj keeps + // whatever a prior `add --deintegrate` recorded. + let automaticPodsInstallation /*: ?AutomaticPodsInstallationResult */ = null; + if (args.deintegrate) { - runDeintegrate(appRoot); + automaticPodsInstallation = runDeintegrate( + appRoot, + projectRoot, + ).automaticPodsInstallation; // `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. @@ -1181,6 +1525,7 @@ async function setupXcodeproj( // generate-spm-xcodeproj.js). artifactsVersionOverride: args.version ?? null, configCommand: resolveConfigCommandToPin(args), + automaticPodsInstallation, }); if (result.status !== 'injected') { logError(`SPM injection failed: ${result.reason}`); @@ -1382,6 +1727,9 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { ? `Removed SPM packages from ${path.basename(xcodeprojPath)}.` : 'No SPM injection found — nothing to remove.', ); + if (result.status === 'removed') { + restoreAutomaticPodsInstallation(result.automaticPodsInstallation); + } return; } @@ -1567,7 +1915,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { // 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); + await setupXcodeproj(args, appRoot, projectRoot, reactNativeRoot, action); } catch (e) { logError(`xcodeproj setup failed: ${e.message}`); if (process.exitCode == null) { @@ -1599,6 +1947,11 @@ module.exports = { removeDanglingPodsFileRef, shouldAutoDeintegrate, stripReactNativeFromPodfile, + podfileHasRnIntegration, withAutomaticPodsInstallationDisabled, + withAutomaticPodsInstallationEnabled, + disableAutomaticPodsInstallation, + restoreAutomaticPodsInstallation, + findExistingReactNativeConfig, ensureBothArtifactFlavors, }; diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__doc__/spm-scripts.md index d2db90c4f59..c105a169fee 100644 --- a/packages/react-native/scripts/spm/__doc__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__doc__/spm-scripts.md @@ -49,7 +49,19 @@ CocoaPods app it fails loud and points you at `--deintegrate`, which: 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**. -3. injects SwiftPM into the `.xcodeproj`. + A stock template's `post_install do |installer| react_native_post_install(...) + end` block is left in place (its shape is too open-ended to strip safely); + if you keep non-RN pods and run `pod install`, remove that block by hand + first — `spm add` (and `pod install` failing) will remind you it's still + there. +3. sets `project.ios.automaticPodsInstallation` to `false` in + `react-native.config.js` (creating the file if it doesn't exist). Left + `true` (the default), the next `react-native run-ios`/`build-ios` silently + re-runs CocoaPods and re-breaks the SwiftPM package graph. +4. removes the dangling `Pods/Pods.xcodeproj` reference from the + `.xcworkspace`, if `pod deintegrate` left one — otherwise Xcode shows a + permanent red, missing row in the workspace navigator. +5. injects SwiftPM into the `.xcodeproj`. React Native now comes from SwiftPM; no pods are linked yet (deintegrate removed the integration). @@ -68,6 +80,11 @@ Then **open the `.xcworkspace`** (not the `.xcodeproj`): the workspace includes the SwiftPM-injected project, so React Native resolves through SwiftPM and your other pods through CocoaPods, together. +> **Automatic pod installs are deliberately off.** `--deintegrate` sets +> `automaticPodsInstallation: false` so `react-native run-ios`/`build-ios` +> won't silently `pod install` and re-break the SwiftPM package graph. Run +> `pod install` yourself whenever you change the non-RN pods above. + > **Do not re-add `use_react_native!`.** React Native must be provided by > _either_ SwiftPM _or_ CocoaPods, never both — they share `build/generated/`, > so a dual-managed RN does not build. `spm add` refuses to run while the @@ -364,6 +381,14 @@ react-native spm deinit # surgically removes everything `add` injected pod install # then, to restore CocoaPods ``` +If `--deintegrate` set `automaticPodsInstallation: false` (see above), +`deinit` restores it — flipping it back to `true`, or removing +`react-native.config.js` entirely if `--deintegrate` created it and nothing +else has touched it since. The dangling-workspace-reference cleanup is not +reversed: by the time `deinit` runs, `Pods/Pods.xcodeproj` is still absent +(that only comes back via `pod install`, above), so re-adding the reference +would just recreate the same dangling row it removed. + To reset the regenerable build state (without un-injecting), just delete the gitignored dirs and re-run: 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 f2b2f62b84a..e9daf390be0 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 @@ -14,17 +14,22 @@ const { cleanupDanglingPodsWorkspaceRef, detectStandardRnLayoutRedirect, determineVersion, + disableAutomaticPodsInstallation, ensureBothArtifactFlavors, + findExistingReactNativeConfig, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, parseArgs, + podfileHasRnIntegration, removeDanglingPodsFileRef, resolveAction, resolveConfigCommandToPin, resolveExplicitConfigCommand, + restoreAutomaticPodsInstallation, shouldAutoDeintegrate, stripReactNativeFromPodfile, withAutomaticPodsInstallationDisabled, + withAutomaticPodsInstallationEnabled, } = require('../../setup-apple-spm'); const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); const {SPM_INJECTED_MARKER} = require('../generate-spm-xcodeproj'); @@ -575,14 +580,13 @@ describe('shouldAutoDeintegrate', () => { describe('stripReactNativeFromPodfile', () => { it('strips a single-line call', () => { - const podfile = - "target 'MyApp' do\n use_react_native!\nend\n"; + const podfile = "target 'MyApp' do\n use_react_native!\nend\n"; expect(stripReactNativeFromPodfile(podfile)).toBe( "target 'MyApp' do\nend\n", ); }); - it('strips a multi-line call with a parenthesized argument list', () => { + it('strips a multi-line call with a parenthesized argument list, consuming the enclosing assignment', () => { const podfile = "target 'HelloWorld' do\n" + ' config = use_native_modules!\n' + @@ -601,9 +605,14 @@ describe('stripReactNativeFromPodfile', () => { expect(stripped).not.toMatch(/use_react_native!/); expect(stripped).not.toMatch(/:app_path/); expect(stripped).not.toMatch(/^\s*\)\s*$/m); + // The `config = ` assignment is dropped along with the call — leaving it + // behind would let Ruby fold it into the next statement (the blank line, + // then `target 'HelloWorldTests' do ... end` would become the RHS of + // `config =`), which is worse than losing the `config` binding outright. + expect(stripped).not.toMatch(/config\s*=\s*$/m); expect(stripped).toBe( "target 'HelloWorld' do\n" + - ' config = \n' + + '\n' + '\n' + " target 'HelloWorldTests' do\n" + ' inherit! :complete\n' + @@ -629,6 +638,64 @@ describe('stripReactNativeFromPodfile', () => { const podfile = "target 'MyApp' do\n pod 'MBProgressHUD'\nend\n"; expect(stripReactNativeFromPodfile(podfile)).toBe(podfile); }); + + it('leaves a call mentioned inside a comment untouched', () => { + const podfile = + "target 'MyApp' do\n" + + ' # use_react_native! does a lot of setup, see the docs\n' + + " pod 'MBProgressHUD'\n" + + 'end\n'; + expect(stripReactNativeFromPodfile(podfile)).toBe(podfile); + }); + + it('leaves a call embedded in other code (not at statement position) untouched', () => { + const podfile = + "target 'MyApp' do\n" + + " puts 'about to call use_react_native!'\n" + + 'end\n'; + expect(stripReactNativeFromPodfile(podfile)).toBe(podfile); + }); + + it('the stock template, once stripped, still needs `post_install` removed by hand — podfileHasRnIntegration says so', () => { + // Full stock react-native init Podfile shape, including the post_install + // block that references the (now-removed) use_native_modules! return + // value. stripReactNativeFromPodfile intentionally doesn't try to strip + // that block — its shape is too open-ended — so podfileHasRnIntegration + // must still flag the leftover `react_native_post_install` call. + const podfile = + "target 'HelloWorld' do\n" + + ' config = use_native_modules!\n' + + '\n' + + ' use_react_native!(\n' + + ' :path => config[:reactNativePath],\n' + + ' :app_path => "#{Pod::Config.instance.installation_root}/.."\n' + + ' )\n' + + '\n' + + ' post_install do |installer|\n' + + ' react_native_post_install(\n' + + ' installer,\n' + + ' config[:reactNativePath],\n' + + ' :mac_catalyst_enabled => false\n' + + ' )\n' + + ' end\n' + + 'end\n'; + const stripped = stripReactNativeFromPodfile(podfile); + expect(stripped).not.toMatch(/use_react_native!/); + expect(stripped).not.toMatch(/use_native_modules!/); + // The post_install block (and its react_native_post_install call) is + // left in place on purpose. + expect(stripped).toMatch(/react_native_post_install/); + + const appRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'spm-podfile-leftover-'), + ); + try { + fs.writeFileSync(path.join(appRoot, 'Podfile'), stripped, 'utf8'); + expect(podfileHasRnIntegration(appRoot)).toBe(true); + } finally { + fs.rmSync(appRoot, {recursive: true, force: true}); + } + }); }); // --------------------------------------------------------------------------- @@ -641,7 +708,9 @@ describe('stripReactNativeFromPodfile', () => { describe('withAutomaticPodsInstallationDisabled', () => { it('inserts a project.ios block into an empty config', () => { - expect(withAutomaticPodsInstallationDisabled('module.exports = {};\n')).toBe( + expect( + withAutomaticPodsInstallationDisabled('module.exports = {};\n'), + ).toBe( 'module.exports = {\n' + ' project: {\n' + ' ios: {\n' + @@ -739,11 +808,306 @@ describe('withAutomaticPodsInstallationDisabled', () => { it('returns null for an unrecognized config shape', () => { expect( - withAutomaticPodsInstallationDisabled('export default { project: {} };\n'), + withAutomaticPodsInstallationDisabled( + 'export default { project: {} };\n', + ), + ).toBeNull(); + }); + + it('is not confused by a `}` inside a comment when locating project.ios', () => { + // A naive brace-depth scan over raw text sees this `}` and thinks + // `project`'s object closed one line early, so `ios: {` looks like a + // sibling of `project` instead of nested inside it — inserting a + // duplicate `ios` key ahead of the real one instead of editing it. + const config = + 'module.exports = {\n' + + ' project: {\n' + + ' // closes the } block\n' + + " ios: {sourceDir: './ios'},\n" + + ' },\n' + + '};\n'; + const updated = withAutomaticPodsInstallationDisabled(config); + expect(updated).not.toBeNull(); + // Exactly one `ios:` object — a duplicate would mean the scanner treated + // the `}` in the comment as closing `project` early. + expect((updated ?? '').match(/ios:\s*{/g)).toHaveLength(1); + expect(updated).toContain('automaticPodsInstallation: false'); + // The original sourceDir survives in the SAME ios block — a duplicate-key + // insertion ahead of the real `ios: {` would have orphaned it instead. + expect(updated).toMatch( + /ios:\s*{\s*automaticPodsInstallation: false,\s*sourceDir: '\.\/ios'},/, + ); + }); + + it('matches a quoted `project` key', () => { + const config = "module.exports = {\n 'project': {},\n};\n"; + const updated = withAutomaticPodsInstallationDisabled(config); + expect(updated).not.toBeNull(); + expect(updated).toContain('automaticPodsInstallation: false'); + expect((updated ?? '').match(/project['"]?\s*:\s*{/g)).toHaveLength(1); + }); + + it('a commented-out `automaticPodsInstallation: false,` does not count as already disabled', () => { + const config = + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n' + + ' // automaticPodsInstallation: false,\n' + + " sourceDir: './ios',\n" + + ' },\n' + + ' },\n' + + '};\n'; + const updated = withAutomaticPodsInstallationDisabled(config); + expect(updated).not.toBeNull(); + // The real (uncommented) key must actually be inserted, not skipped + // because a commented-out mention of the key was mistaken for it. + expect(updated).toMatch(/^\s*automaticPodsInstallation: false,$/m); + }); + + it('an `automaticPodsInstallation` set under an unrelated key does not count as already disabled', () => { + const config = + 'module.exports = {\n' + + ' dependencies: {\n' + + ' foo: {\n' + + ' automaticPodsInstallation: false,\n' + + ' },\n' + + ' },\n' + + '};\n'; + const updated = withAutomaticPodsInstallationDisabled(config); + expect(updated).not.toBeNull(); + expect(updated).toMatch( + /project:\s*{\s*ios:\s*{\s*automaticPodsInstallation: false,/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// withAutomaticPodsInstallationEnabled — the inverse used by `spm deinit` to +// restore project.ios.automaticPodsInstallation after --deintegrate disabled +// it. +// --------------------------------------------------------------------------- + +describe('withAutomaticPodsInstallationEnabled', () => { + it('flips an existing `false` back to `true`', () => { + const config = + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n automaticPodsInstallation: false,\n },\n' + + ' },\n' + + '};\n'; + expect(withAutomaticPodsInstallationEnabled(config)).toBe( + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n automaticPodsInstallation: true,\n },\n' + + ' },\n' + + '};\n', + ); + }); + + it('returns null when the value is no longer `false` (hand-edited since)', () => { + const config = + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n automaticPodsInstallation: true,\n },\n' + + ' },\n' + + '};\n'; + expect(withAutomaticPodsInstallationEnabled(config)).toBeNull(); + }); + + it('returns null when project.ios is absent', () => { + expect( + withAutomaticPodsInstallationEnabled('module.exports = {};\n'), ).toBeNull(); }); }); +// --------------------------------------------------------------------------- +// findExistingReactNativeConfig / disableAutomaticPodsInstallation — +// disableAutomaticPodsInstallation must write to projectRoot (the only +// directory @react-native-community/cli-config's cosmiconfig lookup +// searches), never to appRoot, and must never create a second +// react-native.config.js that shadows an existing .ts/.cjs/.mjs config. +// --------------------------------------------------------------------------- + +describe('findExistingReactNativeConfig', () => { + let projectRoot; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-rnconfig-')); + }); + + afterEach(() => { + fs.rmSync(projectRoot, {recursive: true, force: true}); + }); + + it('returns null when no config file exists', () => { + expect(findExistingReactNativeConfig(projectRoot)).toBeNull(); + }); + + it('finds react-native.config.js', () => { + const p = path.join(projectRoot, 'react-native.config.js'); + fs.writeFileSync(p, 'module.exports = {};\n'); + expect(findExistingReactNativeConfig(projectRoot)).toBe(p); + }); + + it('finds a .ts config when there is no .js config', () => { + const p = path.join(projectRoot, 'react-native.config.ts'); + fs.writeFileSync(p, 'export default {};\n'); + expect(findExistingReactNativeConfig(projectRoot)).toBe(p); + }); + + it('finds a .cjs config when there is no .js config', () => { + const p = path.join(projectRoot, 'react-native.config.cjs'); + fs.writeFileSync(p, 'module.exports = {};\n'); + expect(findExistingReactNativeConfig(projectRoot)).toBe(p); + }); +}); + +describe('disableAutomaticPodsInstallation', () => { + let projectRoot; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-disable-pods-')); + }); + + afterEach(() => { + fs.rmSync(projectRoot, {recursive: true, force: true}); + }); + + it('creates react-native.config.js in projectRoot when none exists', () => { + const result = disableAutomaticPodsInstallation(projectRoot); + expect(result.kind).toBe('created'); + const configPath = path.join(projectRoot, 'react-native.config.js'); + expect(result.configPath).toBe(configPath); + expect(fs.existsSync(configPath)).toBe(true); + expect(fs.readFileSync(configPath, 'utf8')).toContain( + 'automaticPodsInstallation: false', + ); + }); + + it('edits an existing react-native.config.js in place', () => { + const configPath = path.join(projectRoot, 'react-native.config.js'); + fs.writeFileSync( + configPath, + 'module.exports = {\n project: { ios: {} },\n};\n', + ); + const result = disableAutomaticPodsInstallation(projectRoot); + expect(result.kind).toBe('edited'); + expect(result.configPath).toBe(configPath); + expect(fs.readFileSync(configPath, 'utf8')).toContain( + 'automaticPodsInstallation: false', + ); + }); + + it('reports already-disabled without rewriting the file', () => { + const configPath = path.join(projectRoot, 'react-native.config.js'); + const contents = + 'module.exports = {\n' + + ' project: { ios: { automaticPodsInstallation: false } },\n' + + '};\n'; + fs.writeFileSync(configPath, contents); + const before = fs.statSync(configPath).mtimeMs; + const result = disableAutomaticPodsInstallation(projectRoot); + expect(result.kind).toBe('already-disabled'); + expect(fs.readFileSync(configPath, 'utf8')).toBe(contents); + expect(fs.statSync(configPath).mtimeMs).toBe(before); + }); + + it('does NOT create a second config when a .ts config already exists (no shadowing)', () => { + const tsPath = path.join(projectRoot, 'react-native.config.ts'); + fs.writeFileSync(tsPath, 'export default { dependencies: {} };\n'); + const result = disableAutomaticPodsInstallation(projectRoot); + expect(result.kind).toBe('unrecognized'); + expect(result.configPath).toBe(tsPath); + expect( + fs.existsSync(path.join(projectRoot, 'react-native.config.js')), + ).toBe(false); + // The .ts file itself is left completely untouched. + expect(fs.readFileSync(tsPath, 'utf8')).toBe( + 'export default { dependencies: {} };\n', + ); + }); + + it('writes next to package.json (projectRoot), not the .xcodeproj directory (appRoot)', () => { + // Regression test for the standard `/ios` layout: appRoot + // (where the .xcodeproj lives) and projectRoot (where package.json and + // react-native.config.js live) are different directories. + const appRoot = path.join(projectRoot, 'ios'); + fs.mkdirSync(appRoot, {recursive: true}); + disableAutomaticPodsInstallation(projectRoot); + expect( + fs.existsSync(path.join(projectRoot, 'react-native.config.js')), + ).toBe(true); + expect(fs.existsSync(path.join(appRoot, 'react-native.config.js'))).toBe( + false, + ); + }); +}); + +// --------------------------------------------------------------------------- +// restoreAutomaticPodsInstallation — the `spm deinit` counterpart, driven by +// the AutomaticPodsInstallationResult recorded in the .spm-injected.json +// marker by disableAutomaticPodsInstallation. +// --------------------------------------------------------------------------- + +describe('restoreAutomaticPodsInstallation', () => { + let projectRoot; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-restore-pods-')); + }); + + afterEach(() => { + fs.rmSync(projectRoot, {recursive: true, force: true}); + }); + + it('removes the file it created, if untouched since', () => { + const result = disableAutomaticPodsInstallation(projectRoot); + expect(result.kind).toBe('created'); + restoreAutomaticPodsInstallation(result); + expect(fs.existsSync(result.configPath)).toBe(false); + }); + + it('leaves a created file in place if the user has since edited it', () => { + const result = disableAutomaticPodsInstallation(projectRoot); + expect(result.kind).toBe('created'); + fs.appendFileSync(result.configPath, '// a note the user added\n'); + restoreAutomaticPodsInstallation(result); + expect(fs.existsSync(result.configPath)).toBe(true); + }); + + it('flips an edited file back to `true`', () => { + const configPath = path.join(projectRoot, 'react-native.config.js'); + fs.writeFileSync( + configPath, + 'module.exports = {\n project: { ios: { sourceDir: "./ios" } },\n};\n', + ); + const result = disableAutomaticPodsInstallation(projectRoot); + expect(result.kind).toBe('edited'); + restoreAutomaticPodsInstallation(result); + expect(fs.readFileSync(configPath, 'utf8')).toContain( + 'automaticPodsInstallation: true', + ); + }); + + it('is a no-op for already-disabled (we made no edit to undo)', () => { + const configPath = path.join(projectRoot, 'react-native.config.js'); + const contents = + 'module.exports = {\n' + + ' project: { ios: { automaticPodsInstallation: false } },\n' + + '};\n'; + fs.writeFileSync(configPath, contents); + const result = disableAutomaticPodsInstallation(projectRoot); + expect(result.kind).toBe('already-disabled'); + restoreAutomaticPodsInstallation(result); + expect(fs.readFileSync(configPath, 'utf8')).toBe(contents); + }); + + it('is a no-op for null (deintegrate never ran)', () => { + expect(() => restoreAutomaticPodsInstallation(null)).not.toThrow(); + }); +}); + // --------------------------------------------------------------------------- // removeDanglingPodsFileRef — strips the `group:Pods/Pods.xcodeproj` FileRef // `pod install` adds to contents.xcworkspacedata. `pod deintegrate` doesn't @@ -786,6 +1150,27 @@ describe('removeDanglingPodsFileRef', () => { '\n'; expect(removeDanglingPodsFileRef(xml)).toBe(xml); }); + + it('matches a `container:` prefix and a nested path, not just `group:Pods/Pods.xcodeproj`', () => { + const xml = + '\n' + + '\n' + + ' \n' + + ' \n' + + ' \n' + + '\n'; + expect(removeDanglingPodsFileRef(xml)).toBe( + '\n' + + '\n' + + ' \n' + + ' \n' + + '\n', + ); + }); }); // --------------------------------------------------------------------------- @@ -840,9 +1225,7 @@ describe('cleanupDanglingPodsWorkspaceRef', () => { fs.mkdirSync(path.join(appRoot, 'Pods', 'Pods.xcodeproj'), { recursive: true, }); - expect(cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath)).toBe( - false, - ); + expect(cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath)).toBe(false); const data = fs.readFileSync( path.join(workspace, 'contents.xcworkspacedata'), 'utf8', @@ -852,9 +1235,7 @@ describe('cleanupDanglingPodsWorkspaceRef', () => { it('is a no-op when there is no .xcworkspace', () => { const xcodeprojPath = mkXcodeproj(appRoot, 'MyApp.xcodeproj'); - expect(cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath)).toBe( - false, - ); + expect(cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath)).toBe(false); }); }); diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 3126df74d0c..c646859d7f1 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -55,6 +55,7 @@ const fs = require('node:fs'); const path = require('node:path'); /*:: import type { + AutomaticPodsInstallationResult, FlavoredFrameworkManifestEntry, PluginScriptPhase, XcframeworkSlice, @@ -2161,7 +2162,7 @@ function readScriptPhasesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { +) /*: ?{generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, automaticPodsInstallation?: ?AutomaticPodsInstallationResult, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -2282,7 +2283,7 @@ function mergeCreatedArrayFields( * 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} */, + opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array, automaticPodsInstallation?: ?AutomaticPodsInstallationResult} */, ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { const {appRoot, reactNativeRoot, xcodeprojPath} = opts; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -2421,6 +2422,16 @@ function injectSpmIntoExistingXcodeproj( // the whole marker, this field with it. const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? null; + // Same set-or-preserve contract: only an `add --deintegrate` run computes + // this (setup-apple-spm.js's runDeintegrate); a later `update` without + // `--deintegrate` passes null and must not forget what the deintegrate run + // recorded. Read back by `spm deinit` (removeSpmInjection, below) to + // restore project.ios.automaticPodsInstallation. + const automaticPodsInstallation = + opts.automaticPodsInstallation ?? + prevMarker?.automaticPodsInstallation ?? + null; + // Marker: idempotency signal + the exact, reversible record of every edit so // `deinit` (removeSpmInjection) can undo precisely what was added. writeIfChanged( @@ -2444,6 +2455,7 @@ function injectSpmIntoExistingXcodeproj( scriptPhases: scriptPhaseUuids, artifactsVersionOverride, configCommand, + automaticPodsInstallation, scheme: { file: schemeResult.file, // Sticky — see mergeCreatedArrayFields for why a later sync cannot @@ -2593,7 +2605,7 @@ function removeRecordedBuildSettings( */ function removeSpmInjection( opts /*: {appRoot: string, xcodeprojPath: string} */, -) /*: {status: 'removed', target: string} | {status: 'absent'} */ { +) /*: {status: 'removed', target: string, automaticPodsInstallation: ?AutomaticPodsInstallationResult} | {status: 'absent'} */ { const {appRoot, xcodeprojPath} = opts; const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); if (!fs.existsSync(markerPath)) { @@ -2672,7 +2684,11 @@ function removeSpmInjection( // 4. Drop the marker — the project is no longer SPM-injected. fs.rmSync(markerPath, {force: true}); - return {status: 'removed', target: marker.target}; + return { + status: 'removed', + target: marker.target, + automaticPodsInstallation: marker.automaticPodsInstallation ?? null, + }; } module.exports = { diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index ba290ee05de..0be7de6fe63 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -34,6 +34,17 @@ export type SetupArgs = { yes: boolean, }; +// How `disableAutomaticPodsInstallation` (setup-apple-spm.js) left +// project.ios.automaticPodsInstallation in react-native.config.js, recorded +// in the `.spm-injected.json` marker so `spm deinit` +// (removeSpmInjection/generate-spm-xcodeproj.js) can undo exactly this and +// nothing else. +export type AutomaticPodsInstallationResult = + | {kind: 'created', configPath: string} + | {kind: 'edited', configPath: string} + | {kind: 'already-disabled', configPath: string} + | {kind: 'unrecognized', configPath: string}; + export type DownloadArgs = { version: string | null, flavor: string, From 466b350cdfeb8e61e7c6cf62756a0f6ef42db6e5 Mon Sep 17 00:00:00 2001 From: Oscar Franco Date: Thu, 13 Aug 2026 12:59:59 -0400 Subject: [PATCH 3/3] Do a best effort stripping of the post_install hook --- .../react-native/scripts/setup-apple-spm.js | 107 +++++++++++++++--- .../scripts/spm/__doc__/spm-scripts.md | 18 +-- .../spm/__tests__/setup-apple-spm-test.js | 102 +++++++++++++++-- 3 files changed, 194 insertions(+), 33 deletions(-) diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 1b4b5e81baf..460f0fb192a 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -648,13 +648,14 @@ function podfileHasRnIntegration(appRoot /*: string */) /*: boolean */ { if (!fs.existsSync(podfilePath)) { return false; } - // react_native_post_install is included because a stock template's - // `post_install do |installer| react_native_post_install(installer, - // config[:reactNativePath]) end` block references the removed - // use_native_modules! return value — stripReactNativeFromPodfile - // intentionally doesn't try to remove that block (its shape is too open- - // ended to strip safely), so this is what surfaces "you still have to - // finish cleaning up the Podfile by hand" to the user. + // react_native_post_install is included because a CUSTOMIZED post_install + // block — anything beyond the stock template's single + // react_native_post_install(...) call — is left alone by + // stripStockPostInstallBlock on purpose (its shape is too open-ended to + // safely strip more of it), so this is what surfaces "you still have to + // finish cleaning up the Podfile by hand" to the user in that case. + // The stock shape itself is now fully removed by stripReactNativeFromPodfile + // + stripStockPostInstallBlock, so it never reaches this check. return /use_react_native!|use_native_modules!|prepare_react_native_project!|react_native_post_install/.test( fs.readFileSync(podfilePath, 'utf8'), ); @@ -730,12 +731,9 @@ function escapeRegExp(s /*: string */) /*: string */ { // removing it outright, since Ruby folds a dangling `lhs =` into whatever // statement follows (e.g. `config = \n\npost_install do ... end` becomes // `config = (post_install do ... end)`), corrupting unrelated code instead -// of just losing the `config` binding. Anything that then references -// `config` (e.g. a stock `post_install` block calling -// `react_native_post_install(installer, config[:reactNativePath])`) is -// intentionally NOT stripped here — its shape is too open-ended to remove -// safely — but podfileHasRnIntegration still detects the leftover -// react_native_post_install call and warns. +// of just losing the `config` binding. The stock template's `post_install` +// block (which references `config`) is a separate, narrower case — see +// stripStockPostInstallBlock, below. function stripReactNativeFromPodfile(contents /*: string */) /*: string */ { let text = contents; for (const name of RN_PODFILE_CALLS) { @@ -782,6 +780,84 @@ function stripReactNativeFromPodfile(contents /*: string */) /*: string */ { return text; } +// Strips the stock template's +// post_install do |installer| +// react_native_post_install( +// installer, +// config[:reactNativePath], +// :mac_catalyst_enabled => false +// ) +// end +// — but ONLY when the block's entire body is exactly one +// react_native_post_install(...) call (whitespace aside). That call is the +// one thing stripReactNativeFromPodfile's removal of `use_native_modules!` +// breaks (it references the now-gone `config`), so this is safe to remove +// unconditionally in that exact shape. +// +// A customized block — anything else inside it, another statement before or +// after the call, extra hooks a user added — is left completely alone: we +// can't tell what else in there matters, so guessing would risk losing user +// logic. podfileHasRnIntegration still flags the leftover +// react_native_post_install call in that case, so the user knows to finish +// the cleanup by hand. +function stripStockPostInstallBlock(contents /*: string */) /*: string */ { + const re = /^([ \t]*)post_install\s+do\s*\|\s*installer\s*\|/gm; + let out = ''; + let i = 0; + let m; + while ((m = re.exec(contents))) { + const statementStart = m.index; + let cursor = re.lastIndex; + while (cursor < contents.length && /\s/.test(contents[cursor])) cursor++; + const callMatch = /^react_native_post_install\s*\(/.exec( + contents.slice(cursor), + ); + if (callMatch == null) { + continue; // Not the stock shape — leave the whole block alone. + } + const parenStart = cursor + callMatch[0].length - 1; + let depth = 0; + let callEnd = -1; + for (let p = parenStart; p < contents.length; p++) { + if (contents[p] === '(') depth++; + else if (contents[p] === ')') { + depth--; + if (depth === 0) { + callEnd = p + 1; + break; + } + } + } + if (callEnd === -1) { + continue; // Unbalanced parens — bail out rather than guess. + } + let afterCall = callEnd; + while (afterCall < contents.length && /\s/.test(contents[afterCall])) { + afterCall++; + } + const nextChar = contents[afterCall + 3]; + if ( + contents.slice(afterCall, afterCall + 3) !== 'end' || + (nextChar != null && /\w/.test(nextChar)) + ) { + continue; // Something else follows the call inside the block. + } + let end = afterCall + 3; + // If the rest of the line (after `end`) is blank, drop the trailing + // newline too, so we don't leave an empty line behind. + let lineEnd = contents.indexOf('\n', end); + if (lineEnd === -1) lineEnd = contents.length; + if (contents.slice(end, lineEnd).trim() === '') { + end = lineEnd < contents.length ? lineEnd + 1 : lineEnd; + } + out += contents.slice(i, statementStart); + i = end; + re.lastIndex = end; + } + out += contents.slice(i); + return out; +} + // Replaces the contents of `//` and `/* */` comments with spaces (same // length, newlines preserved) so the brace/key scanning below isn't thrown // off by a stray `}` or keyword sitting inside a comment. Only used for @@ -1350,7 +1426,9 @@ function runDeintegrate( const podfilePath = path.join(appRoot, 'Podfile'); if (fs.existsSync(podfilePath)) { const orig = fs.readFileSync(podfilePath, 'utf8'); - const stripped = stripReactNativeFromPodfile(orig); + const stripped = stripStockPostInstallBlock( + stripReactNativeFromPodfile(orig), + ); if (stripped !== orig) { fs.writeFileSync(podfilePath, stripped, 'utf8'); log('Stripped React Native integration from Podfile.'); @@ -1947,6 +2025,7 @@ module.exports = { removeDanglingPodsFileRef, shouldAutoDeintegrate, stripReactNativeFromPodfile, + stripStockPostInstallBlock, podfileHasRnIntegration, withAutomaticPodsInstallationDisabled, withAutomaticPodsInstallationEnabled, diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__doc__/spm-scripts.md index c105a169fee..62d488f56b6 100644 --- a/packages/react-native/scripts/spm/__doc__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__doc__/spm-scripts.md @@ -46,14 +46,16 @@ CocoaPods app it fails loud and points you at `--deintegrate`, which: 1. runs `pod deintegrate` — 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**. - A stock template's `post_install do |installer| react_native_post_install(...) - end` block is left in place (its shape is too open-ended to strip safely); - if you keep non-RN pods and run `pod install`, remove that block by hand - first — `spm add` (and `pod install` failing) will remind you it's still - there. +2. strips the React Native directives (`use_react_native!`, + `use_native_modules!`, `prepare_react_native_project!`) from the Podfile, + and — only when it's exactly the stock template's shape — the + `post_install do |installer| react_native_post_install(...) end` block + that calls them. Every other line, **including your own `pod '…'` entries + and any customization you've made to `post_install`, is preserved**. A + customized `post_install` block is left in place (its shape is too + open-ended to strip safely beyond the exact stock call); if you keep + non-RN pods and run `pod install`, remove it by hand first — `spm add` + (and `pod install` failing) will remind you it's still there. 3. sets `project.ios.automaticPodsInstallation` to `false` in `react-native.config.js` (creating the file if it doesn't exist). Left `true` (the default), the next `react-native run-ios`/`build-ios` silently 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 e9daf390be0..07c6811d396 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 @@ -28,6 +28,7 @@ const { restoreAutomaticPodsInstallation, shouldAutoDeintegrate, stripReactNativeFromPodfile, + stripStockPostInstallBlock, withAutomaticPodsInstallationDisabled, withAutomaticPodsInstallationEnabled, } = require('../../setup-apple-spm'); @@ -656,12 +657,13 @@ describe('stripReactNativeFromPodfile', () => { expect(stripReactNativeFromPodfile(podfile)).toBe(podfile); }); - it('the stock template, once stripped, still needs `post_install` removed by hand — podfileHasRnIntegration says so', () => { - // Full stock react-native init Podfile shape, including the post_install - // block that references the (now-removed) use_native_modules! return - // value. stripReactNativeFromPodfile intentionally doesn't try to strip - // that block — its shape is too open-ended — so podfileHasRnIntegration - // must still flag the leftover `react_native_post_install` call. + it('the full stock template ends up with no leftover RN integration once both strips run', () => { + // Full stock react-native init Podfile shape. stripReactNativeFromPodfile + // removes the use_native_modules!/use_react_native! calls; + // stripStockPostInstallBlock (run after it, as runDeintegrate does) then + // removes the now-dangling post_install block, since its body is exactly + // one react_native_post_install(...) call. podfileHasRnIntegration should + // find nothing left to warn about. const podfile = "target 'HelloWorld' do\n" + ' config = use_native_modules!\n' + @@ -679,25 +681,103 @@ describe('stripReactNativeFromPodfile', () => { ' )\n' + ' end\n' + 'end\n'; - const stripped = stripReactNativeFromPodfile(podfile); + const stripped = stripStockPostInstallBlock( + stripReactNativeFromPodfile(podfile), + ); expect(stripped).not.toMatch(/use_react_native!/); expect(stripped).not.toMatch(/use_native_modules!/); - // The post_install block (and its react_native_post_install call) is - // left in place on purpose. - expect(stripped).toMatch(/react_native_post_install/); + expect(stripped).not.toMatch(/post_install/); + expect(stripped).not.toMatch(/react_native_post_install/); + expect(stripped).toBe("target 'HelloWorld' do\n\n\nend\n"); const appRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'spm-podfile-leftover-'), ); try { fs.writeFileSync(path.join(appRoot, 'Podfile'), stripped, 'utf8'); - expect(podfileHasRnIntegration(appRoot)).toBe(true); + expect(podfileHasRnIntegration(appRoot)).toBe(false); } finally { fs.rmSync(appRoot, {recursive: true, force: true}); } }); }); +// --------------------------------------------------------------------------- +// stripStockPostInstallBlock — removes the stock template's `post_install do +// |installer| react_native_post_install(...) end` block, but ONLY when its +// body is exactly that one call. A customized block is left alone, since we +// can't tell what else inside it matters. +// --------------------------------------------------------------------------- + +describe('stripStockPostInstallBlock', () => { + it('removes the stock block (multi-line call, trailing arg)', () => { + const podfile = + "target 'HelloWorld' do\n" + + ' post_install do |installer|\n' + + ' react_native_post_install(\n' + + ' installer,\n' + + ' config[:reactNativePath],\n' + + ' :mac_catalyst_enabled => false\n' + + ' )\n' + + ' end\n' + + 'end\n'; + expect(stripStockPostInstallBlock(podfile)).toBe( + "target 'HelloWorld' do\nend\n", + ); + }); + + it('removes the stock block (single-line call)', () => { + const podfile = + "target 'HelloWorld' do\n" + + ' post_install do |installer|\n' + + ' react_native_post_install(installer, config[:reactNativePath])\n' + + ' end\n' + + 'end\n'; + expect(stripStockPostInstallBlock(podfile)).toBe( + "target 'HelloWorld' do\nend\n", + ); + }); + + it('leaves a customized block (extra statement before the call) untouched', () => { + const podfile = + "target 'HelloWorld' do\n" + + ' post_install do |installer|\n' + + ' installer.pods_project.targets.each do |target|\n' + + ' flipper_post_install(installer)\n' + + ' end\n' + + ' react_native_post_install(installer, config[:reactNativePath])\n' + + ' end\n' + + 'end\n'; + expect(stripStockPostInstallBlock(podfile)).toBe(podfile); + }); + + it('leaves a customized block (extra statement after the call) untouched', () => { + const podfile = + "target 'HelloWorld' do\n" + + ' post_install do |installer|\n' + + ' react_native_post_install(installer, config[:reactNativePath])\n' + + ' my_other_post_install_hook(installer)\n' + + ' end\n' + + 'end\n'; + expect(stripStockPostInstallBlock(podfile)).toBe(podfile); + }); + + it('leaves a Podfile with no post_install block untouched', () => { + const podfile = "target 'MyApp' do\n pod 'MBProgressHUD'\nend\n"; + expect(stripStockPostInstallBlock(podfile)).toBe(podfile); + }); + + it('leaves a post_install block that does not call react_native_post_install untouched', () => { + const podfile = + "target 'MyApp' do\n" + + ' post_install do |installer|\n' + + ' some_other_hook(installer)\n' + + ' end\n' + + 'end\n'; + expect(stripStockPostInstallBlock(podfile)).toBe(podfile); + }); +}); + // --------------------------------------------------------------------------- // withAutomaticPodsInstallationDisabled — sets // project.ios.automaticPodsInstallation to false in react-native.config.js,