diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index cfdb7f10512..d741f5386ee 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, DanglingPodsWorkspaceRefResult, SetupArgs} from './spm/spm-types'; */ /** * setup-apple-spm.js – Entry point for setting up Swift Package Manager support @@ -648,7 +648,15 @@ 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 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'), ); } @@ -694,10 +702,802 @@ 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!', +]; + +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!( +// :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 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. 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) { + const re = new RegExp( + '^([ \\t]*)((?:[A-Za-z_$][\\w$]*\\s*=\\s*)?)' + escapeRegExp(name), + 'gm', + ); + let out = ''; + let i = 0; + 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 p = k; p < text.length; p++) { + if (text[p] === '(') depth++; + else if (text[p] === ')') { + depth--; + if (depth === 0) { + end = p + 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; + } + out += text.slice(i, statementStart); + i = end; + re.lastIndex = end; + } + out += text.slice(i); + text = out; + } + 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, AND of string-literal +// VALUES (not string-literal object keys — see below), with spaces (same +// length, newlines preserved) so the brace/key scanning below isn't thrown +// off by a stray `}`/`{` or a keyword-looking substring sitting inside a +// comment or a value like `sourceDir: "{PODS_ROOT}/.."`. Only used for +// *finding* positions — every read/write below still slices the original +// text, so indices computed against the masked string stay valid (masking +// never changes length; a multi-line string/comment keeps its newlines). +// +// A string is masked as a VALUE unless the first non-whitespace character +// after its closing quote is `:` — that shape is a quoted object key +// (`'ios': {...}`), and its text must stay visible so a caller matching a +// specific key by name (e.g. `ios`) still finds it. Comments and strings are +// each consumed atomically in one pass, so `//` inside a string can't be +// mistaken for a comment, and a quote inside a comment can't be mistaken for +// a string. +function maskJsCommentsAndStringValues(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; + } + if (text[i] === '"' || text[i] === "'" || text[i] === '`') { + const quote = text[i]; + let j = i + 1; + while (j < text.length && text[j] !== quote) { + if (text[j] === '\\') j++; + j++; + } + j = Math.min(j + 1, text.length); + let k = j; + while (k < text.length && /\s/.test(text[k])) k++; + out += + text[k] === ':' + ? text.slice(i, j) + : 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. +// `masked` must be the same length as the real text (see maskJsCommentsAndStringValues). +function matchingBrace( + masked /*: string */, + openIdx /*: number */, +) /*: number | null */ { + let depth = 0; + for (let i = openIdx; i < masked.length; i++) { + if (masked[i] === '{') depth++; + else if (masked[i] === '}') { + depth--; + if (depth === 0) return i; + } + } + return null; +} + +// 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 +// maskJsCommentsAndStringValues). +function findTopLevelKeyObjectRange( + masked /*: string */, + key /*: string */, + start /*: number */, + end /*: number */, +) /*: {open: number, close: number} | null */ { + 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. +// +// `matchEnd` is trimmed back to the end of the VALUE TOKEN itself, not the +// raw regex match: when this is the object's last property (no trailing +// comma), `[^,}\n]+` runs all the way to the newline, which — since +// maskJsCommentsAndStringValues replaces a trailing `// comment` with +// same-length spaces rather than removing it — swallows that masked comment +// into the match. A caller that slices `matchEnd..` out of the REAL +// (unmasked) text to replace the value would otherwise delete the user's +// comment along with it. +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 = maskJsCommentsAndStringValues(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. 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. A brace or keyword +// embedded in an unrelated string VALUE is similarly masked out (see +// maskJsCommentsAndStringValues) so it can't throw off brace-depth counting. +// And before inserting a new `project: {...}` or `ios: {...}`, we check that +// the key isn't ALREADY present in some other shape (findTopLevelKeyExists) +// — inserting a duplicate would be silently shadowed by the real one (JS +// object literals let the last duplicate key win), reporting success for an +// edit that changed nothing. Returns null when `contents` doesn't look like +// a plain `module.exports = {...}` object literal, when `project`/`ios` +// exists but isn't safely extensible, 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 */ { + const masked = maskJsCommentsAndStringValues(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, + ); + + let updated; + if (projectRange == null) { + // `project` exists but isn't a `{...}` object literal we recognize (e.g. + // `project: someHelper()`) — inserting a second `project: {...}` ahead + // of it would be silently shadowed by the real one (last duplicate key + // wins), so refuse rather than report success for an edit that does + // nothing. + if ( + findTopLevelKeyExists(masked, 'project', exportsOpen + 1, exportsClose) + ) { + return null; + } + 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) { + // Same reasoning as above, one level down: `ios` exists (e.g. + // `ios: iosConfig`, a variable reference) but not as a `{...}` we can + // extend. + if ( + findTopLevelKeyExists( + masked, + 'ios', + projectRange.open + 1, + projectRange.close, + ) + ) { + return 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; +} + +// 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'. Restore the exact pre-edit snapshot rather + // than trying to reverse-engineer the specific edit (e.g. "set the value + // back to `true`") — the edit might have been INSERTING the property + // where it was previously absent (rather than flipping an existing + // `true`), and might have inserted a wrapping `project`/`ios` object too. + // "Flip it back to `true`" would leave those newly-created bits behind; + // writing back `before` verbatim restores whichever shape the file + // actually had. Only restore if the file still matches `after` exactly — + // otherwise something else has edited it since, and guessing risks + // clobbering that edit. + if (!fs.existsSync(result.configPath)) { + return; + } + if (fs.readFileSync(result.configPath, 'utf8') !== result.after) { + log( + '\x1b[33mNote: react-native.config.js has changed since `spm add ' + + '--deintegrate` disabled automaticPodsInstallation — leaving it in ' + + 'place. Set `project.ios.automaticPodsInstallation` back to `true` ' + + "(or remove the property, if it wasn't there originally) yourself " + + 'if you want CocoaPods to auto-install again.\x1b[0m', + ); + return; + } + fs.writeFileSync(result.configPath, result.before, 'utf8'); + log( + 'Restored react-native.config.js to its state before `spm add --deintegrate`.', + ); +} + +// 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` / +// `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. +// +// `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 {kind: 'created', configPath}; + } + + 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.' + + 'automaticPodsInstallation` to `false` yourself, or a future `pod ' + + 'install` will re-break the SPM package graph.\x1b[0m', + ); + return {kind: 'unrecognized', configPath: existing}; + } + 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, before: orig, after: updated}; +} + +// Locate the .xcworkspace CocoaPods manages alongside the .xcodeproj — same +// basename by convention (what `pod install` creates), falling back to the +// 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( + dir, + path.basename(xcodeprojPath, '.xcodeproj') + '.xcworkspace', + ); + if (fs.existsSync(sibling)) { + return sibling; + } + 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 +// .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. +// 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]*]*\blocation\s*=\s*"[^"]*Pods\/Pods\.xcodeproj"[^>]*(?:\/>|>\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. +// Returns the before/after snapshot of contents.xcworkspacedata when it +// actually removed something (null otherwise), so `spm deinit` can restore +// it byte-for-byte — see restoreDanglingPodsWorkspaceRef. +function cleanupDanglingPodsWorkspaceRef( + appRoot /*: string */, + xcodeprojPath /*: string */, +) /*: ?DanglingPodsWorkspaceRefResult */ { + if (fs.existsSync(path.join(appRoot, 'Pods', 'Pods.xcodeproj'))) { + return null; + } + const workspacePath = findXcworkspace(xcodeprojPath); + if (workspacePath == null) { + return null; + } + const dataPath = path.join(workspacePath, 'contents.xcworkspacedata'); + if (!fs.existsSync(dataPath)) { + return null; + } + const orig = fs.readFileSync(dataPath, 'utf8'); + const cleaned = removeDanglingPodsFileRef(orig); + if (cleaned === orig) { + return null; + } + fs.writeFileSync(dataPath, cleaned, 'utf8'); + return {dataPath, before: orig, after: cleaned}; +} + +// Undoes cleanupDanglingPodsWorkspaceRef, using the marker's before/after +// snapshot. Called by `spm deinit` — React Native needs a real +// Pods.xcodeproj reference again once CocoaPods is reintegrated (`pod +// install`, which `deinit`'s own docs tell the user to run next), so this +// puts the reference straight back rather than leaving the user to +// rediscover it's missing. Only restores when the file still matches +// `after` exactly (nothing else has edited it since); otherwise leaves it +// alone and warns, same safety contract as restoreAutomaticPodsInstallation. +function restoreDanglingPodsWorkspaceRef( + result /*: ?DanglingPodsWorkspaceRefResult */, +) /*: void */ { + if (result == null || !fs.existsSync(result.dataPath)) { + return; + } + if (fs.readFileSync(result.dataPath, 'utf8') !== result.after) { + log( + '\x1b[33mNote: contents.xcworkspacedata has changed since `spm add ' + + '--deintegrate` removed the dangling Pods.xcodeproj reference — ' + + "leaving it as-is. Re-run `pod install` if it's missing.\x1b[0m", + ); + return; + } + fs.writeFileSync(result.dataPath, result.before, 'utf8'); + log('Restored the Pods.xcodeproj reference in the .xcworkspace.'); +} + // 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. -function runDeintegrate(appRoot /*: string */) /*: void */ { +// no prompt ⇒ CI-safe. +// +// `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 { @@ -714,20 +1514,45 @@ 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 = stripStockPostInstallBlock( + stripReactNativeFromPodfile(orig), + ); if (stripped !== orig) { fs.writeFileSync(podfilePath, stripped, 'utf8'); log('Stripped React Native integration from Podfile.'); } } + + 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 @@ -747,20 +1572,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: @@ -785,6 +1597,7 @@ function resolveInjectionTarget( async function setupXcodeproj( args /*: SetupArgs */, appRoot /*: string */, + projectRoot /*: string */, reactNativeRoot /*: string */, action /*: string */, ) /*: Promise */ { @@ -802,17 +1615,74 @@ async function setupXcodeproj( // would always look dirty and trigger a spurious confirmation prompt. const cleanBeforeEdits = gitTrackedAndClean(appRoot, pbxprojPath); + // Confirm BEFORE any mutation. `pod deintegrate`, the Podfile strip, the + // react-native.config.js edit, and the workspace cleanup below are only + // reversible via git (for the pbxproj — Podfile/config.js/workspace are + // plain fs writes, not even that) or the .spm-injected.json marker, which + // isn't written until injectSpmIntoExistingXcodeproj succeeds at the very + // end. Asking only right before that last step — after deintegrate had + // already run — meant declining still left `pod deintegrate` executed and + // its file edits on disk, with no record of any of it. The prompt exists + // to prevent exactly that kind of unrecoverable surprise, so it has to + // gate everything this function does, not just the pbxproj injection. + const clean = cleanBeforeEdits; + if (clean === false && !args.yes) { + const proceed = await promptYesNo( + `${path.basename(xcodeprojPath)} has uncommitted changes and no ` + + `backup is made (git is the only undo). ${ + args.deintegrate ? 'Deintegrate CocoaPods and inject' : 'Inject' + } SPM packages anyway?`, + false, + ); + if (!proceed) { + log('Aborted. Commit or stash the project, then re-run `spm add`.'); + process.exitCode = 1; + throw new Error('In-place injection declined (dirty working tree)'); + } + } else if (clean === null) { + log( + `\x1b[33mNote: ${path.basename(xcodeprojPath)} is not in a git ` + + `repo — no backup is made before in-place injection.\x1b[0m`, + ); + } + + // 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; + // Same reasoning, for cleanupDanglingPodsWorkspaceRef below: `null` means + // "didn't run `--deintegrate` this time, or nothing needed removing"; + // non-null means "removed a reference, here's the before/after snapshot". + let removedDanglingPodsWorkspaceRef /*: ?DanglingPodsWorkspaceRefResult */ = + 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. if (cleanupLeftoverPodsGroup(xcodeprojPath)) { log('Removed the leftover empty `Pods` group from the project.'); } + removedDanglingPodsWorkspaceRef = cleanupDanglingPodsWorkspaceRef( + appRoot, + xcodeprojPath, + ); + if (removedDanglingPodsWorkspaceRef != null) { + log( + 'Removed the dangling Pods.xcodeproj reference from the .xcworkspace.', + ); + } } // Preflight: a still-CocoaPods-integrated pbxproj is the real build-breaker. + // (Only reachable here without --deintegrate, or if `pod deintegrate` + // itself failed to fully strip the target's Pods.xcconfig layering — the + // confirmation above already ran either way, so this doesn't skip it.) if (pbxprojUsesCocoaPods(xcodeprojPath)) { logError( `${path.basename(xcodeprojPath)} is CocoaPods-integrated. Re-run ` + @@ -831,28 +1701,6 @@ async function setupXcodeproj( ); } - // 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; - if (clean === false && !args.yes) { - const proceed = await promptYesNo( - `${path.basename(xcodeprojPath)} has uncommitted changes and no ` + - `backup is made (git is the only undo). Inject SPM packages anyway?`, - false, - ); - if (!proceed) { - log('Aborted. Commit or stash the project, then re-run `spm add`.'); - process.exitCode = 1; - throw new Error('In-place injection declined (dirty working tree)'); - } - } else if (clean === null) { - log( - `\x1b[33mNote: ${path.basename(xcodeprojPath)} is not in a git ` + - `repo — no backup is made before in-place injection.\x1b[0m`, - ); - } - const result = injectSpmIntoExistingXcodeproj({ appRoot, reactNativeRoot, @@ -864,9 +1712,28 @@ async function setupXcodeproj( // generate-spm-xcodeproj.js). artifactsVersionOverride: args.version ?? null, configCommand: resolveConfigCommandToPin(args), + automaticPodsInstallation, + removedDanglingPodsWorkspaceRef, }); if (result.status !== 'injected') { logError(`SPM injection failed: ${result.reason}`); + if (args.deintegrate) { + // No .spm-injected.json marker exists to `deinit` from — injection + // never got far enough to write one — so this is the only guidance + // the user gets. `pod deintegrate` and the Podfile/config.js/workspace + // edits already happened and are only git-reversible for the pbxproj; + // the other two are plain fs writes. + logError( + '`pod deintegrate` already ran, and the Podfile / ' + + 'react-native.config.js / .xcworkspace edits above are already on ' + + "disk — CocoaPods integration is gone, but SPM wasn't injected. " + + 'Fix the issue above, then re-run `spm add --deintegrate`: each ' + + 'of those steps is a no-op the second time, so it resumes cleanly ' + + 'from here. To fully back out instead, `git checkout` the ' + + 'pbxproj/Podfile and manually undo the react-native.config.js / ' + + '.xcworkspace edits.', + ); + } process.exitCode = 1; throw new Error(result.reason); } @@ -1065,6 +1932,10 @@ 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); + restoreDanglingPodsWorkspaceRef(result.removedDanglingPodsWorkspaceRef); + } return; } @@ -1250,7 +2121,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) { @@ -1278,6 +2149,16 @@ module.exports = { resolveAction, resolveConfigCommandToPin, resolveExplicitConfigCommand, + cleanupDanglingPodsWorkspaceRef, + restoreDanglingPodsWorkspaceRef, + removeDanglingPodsFileRef, shouldAutoDeintegrate, + stripReactNativeFromPodfile, + stripStockPostInstallBlock, + podfileHasRnIntegration, + withAutomaticPodsInstallationDisabled, + disableAutomaticPodsInstallation, + restoreAutomaticPodsInstallation, + findExistingReactNativeConfig, ensureBothArtifactFlavors, }; diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index fcf1ab32821..9ee1cf4db9d 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -45,10 +45,24 @@ 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**. -3. injects SwiftPM into the `.xcodeproj`. +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 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). @@ -67,6 +81,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 @@ -222,14 +241,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` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added. Purely additive; a re-run is a no-op. | Yes — exactly what was injected, per the marker (one exception below) | -| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update` | Created. Two roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | -| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update` | 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 — the scheme is deleted if `add` created it, 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 React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. | **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` | SwiftPM package refs, the React build settings, the Sync build phase, and the flavored-framework embed phase are added. Purely additive; a re-run is a no-op. | Yes — exactly what was injected, per the marker (one exception below) | +| `MyApp.xcodeproj/.spm-injected.json` | `add`, `update` | Created. Two roles: it records every edit made — including the pre-injection value of any build setting rewritten — so removal is surgical and re-runs stay idempotent; and it **pins configuration** later runs and Xcode builds must reuse (see the two pins below). | Yes — deleted, and the pins go with it | +| `MyApp.xcodeproj/xcshareddata/xcschemes/*.xcscheme` | `add`, `update` | 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 — the scheme is deleted if `add` created it, 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 React Native directives (`use_react_native!`, `use_native_modules!`, `prepare_react_native_project!`) are stripped. Your own `pod '…'` lines are preserved. | **No** — re-add the directives yourself to go back to CocoaPods | +| `Pods/`, `Pods-*.xcconfig`, `[CP]` phases | `add --deintegrate` | Removed by `pod deintegrate`. | **No** — run `pod install` to restore | +| `MyApp.xcworkspace/contents.xcworkspacedata` | `add --deintegrate` | The dangling `Pods/Pods.xcodeproj` reference `pod deintegrate` leaves behind is removed (otherwise Xcode shows a permanent red, missing row). | Yes — restored byte-for-byte, if untouched since (see [Removing / resetting](#removing--resetting)) | +| `react-native.config.js` | `add --deintegrate` | `project.ios.automaticPodsInstallation` is set to `false` (creating the file if it doesn't exist), so a build never silently re-runs `pod install` and re-breaks the SwiftPM package graph. | Yes — restored byte-for-byte, if untouched since (see [Removing / resetting](#removing--resetting)) | The two pinned settings are the `--version` pin (`artifactsVersionOverride`, see [Pinning the React Native version](#pinning-the-react-native-version)) and the @@ -454,6 +475,16 @@ react-native spm deinit # surgically removes everything `add` injected pod install # then, to restore CocoaPods ``` +If `--deintegrate` set `automaticPodsInstallation: false` (see above), `deinit` +restores `react-native.config.js` to its exact state from before `--deintegrate` +touched it — deleting the file if `--deintegrate` created it, or reverting the +edit byte-for-byte otherwise. Same for the dangling `Pods/Pods.xcodeproj` +workspace reference `--deintegrate` removed: `deinit` puts it back byte-for-byte +(React Native needs a real reference there again once `pod install`, above, +reintegrates CocoaPods). Either restoration is skipped — leaving your changes +alone, with a warning — if the file has been touched since `--deintegrate` +edited it, so `deinit` never clobbers something you did afterward. + 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__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index 348fb3b9923..efafbd162f7 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 @@ -1035,6 +1035,252 @@ describe('artifactsVersionOverride marker field', () => { }); }); +// --------------------------------------------------------------------------- +// automaticPodsInstallation marker field — records what setup-apple-spm.js's +// disableAutomaticPodsInstallation did to react-native.config.js on an `add +// --deintegrate` run, so `deinit` can undo exactly that. Unlike the plain +// set-or-preserve fields above (artifactsVersionOverride, configCommand), a +// non-null result on a LATER run doesn't always mean "overwrite": a repeat +// `--deintegrate` run finds automaticPodsInstallation already `false` +// (because a prior run set it) and reports {kind: 'already-disabled'} — that +// must NOT clobber the earlier {kind: 'created'}/{kind: 'edited'} record, or +// `deinit` loses the rollback info and silently leaves the flag disabled. +// --------------------------------------------------------------------------- +describe('automaticPodsInstallation marker field', () => { + it('records a created/edited result from the first --deintegrate run', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + automaticPodsInstallation: { + kind: 'created', + configPath: '/app/react-native.config.js', + }, + }); + expect(readMarker(xcodeprojPath).automaticPodsInstallation).toEqual({ + kind: 'created', + configPath: '/app/react-native.config.js', + }); + }); + + it('defaults to null when --deintegrate has never run', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).automaticPodsInstallation).toBeNull(); + }); + + it('a repeat --deintegrate run (already-disabled) does NOT clobber a prior `created` record', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const configPath = '/app/react-native.config.js'; + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + automaticPodsInstallation: {kind: 'created', configPath}, + }); + // Second `--deintegrate` run: the file is already `false` (this run + // created it), so disableAutomaticPodsInstallation now reports + // 'already-disabled' — the ORIGINAL 'created' record must survive so + // deinit still knows to delete the file it created. + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + automaticPodsInstallation: {kind: 'already-disabled', configPath}, + }); + expect(readMarker(xcodeprojPath).automaticPodsInstallation).toEqual({ + kind: 'created', + configPath, + }); + }); + + it('a repeat --deintegrate run (already-disabled) does NOT clobber a prior `edited` record', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const configPath = '/app/react-native.config.js'; + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + automaticPodsInstallation: {kind: 'edited', configPath}, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + automaticPodsInstallation: {kind: 'already-disabled', configPath}, + }); + expect(readMarker(xcodeprojPath).automaticPodsInstallation).toEqual({ + kind: 'edited', + configPath, + }); + }); + + it('records already-disabled when there is no prior record to preserve', () => { + // First-ever --deintegrate run, and the file was ALREADY false before RN + // touched it (e.g. hand-authored) — there's nothing to roll back either + // way, so recording the current (non-mutating) result is fine. + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const configPath = '/app/react-native.config.js'; + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + automaticPodsInstallation: {kind: 'already-disabled', configPath}, + }); + expect(readMarker(xcodeprojPath).automaticPodsInstallation).toEqual({ + kind: 'already-disabled', + configPath, + }); + }); + + it('a later `update` (no --deintegrate) preserves the prior record', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const configPath = '/app/react-native.config.js'; + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + automaticPodsInstallation: {kind: 'created', configPath}, + }); + // `update` without --deintegrate never calls runDeintegrate, so it + // passes null/omits the field entirely. + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).automaticPodsInstallation).toEqual({ + kind: 'created', + configPath, + }); + }); + + it('deinit surfaces the preserved record after a repeat --deintegrate run', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const configPath = '/app/react-native.config.js'; + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + automaticPodsInstallation: {kind: 'created', configPath}, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + automaticPodsInstallation: {kind: 'already-disabled', configPath}, + }); + const removed = removeSpmInjection({appRoot, xcodeprojPath}); + expect(removed.automaticPodsInstallation).toEqual({ + kind: 'created', + configPath, + }); + }); +}); + +// --------------------------------------------------------------------------- +// removedDanglingPodsWorkspaceRef marker field — same non-clobbering +// set-or-preserve contract as automaticPodsInstallation above, and for the +// same reason: a repeat `--deintegrate` run finds nothing left to remove +// (the first run already removed it) and reports null, which must not erase +// the earlier before/after snapshot `spm deinit` needs to restore the +// reference. +// --------------------------------------------------------------------------- +describe('removedDanglingPodsWorkspaceRef marker field', () => { + const SNAPSHOT = { + dataPath: '/app/MyApp.xcworkspace/contents.xcworkspacedata', + before: '...Pods.xcodeproj...', + after: '...', + }; + + it('records a snapshot from the first --deintegrate run', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + removedDanglingPodsWorkspaceRef: SNAPSHOT, + }); + expect(readMarker(xcodeprojPath).removedDanglingPodsWorkspaceRef).toEqual( + SNAPSHOT, + ); + }); + + it('defaults to null when --deintegrate has never run', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect( + readMarker(xcodeprojPath).removedDanglingPodsWorkspaceRef, + ).toBeNull(); + }); + + it('a repeat --deintegrate run (nothing left to remove) does NOT clobber the earlier snapshot', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + removedDanglingPodsWorkspaceRef: SNAPSHOT, + }); + // Second `--deintegrate` run: the reference is already gone (this run + // removed it), so cleanupDanglingPodsWorkspaceRef now returns null. + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + removedDanglingPodsWorkspaceRef: null, + }); + expect(readMarker(xcodeprojPath).removedDanglingPodsWorkspaceRef).toEqual( + SNAPSHOT, + ); + }); + + it('a later `update` (no --deintegrate) preserves the prior snapshot', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + removedDanglingPodsWorkspaceRef: SNAPSHOT, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(readMarker(xcodeprojPath).removedDanglingPodsWorkspaceRef).toEqual( + SNAPSHOT, + ); + }); + + it('deinit surfaces the preserved snapshot after a repeat --deintegrate run', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + removedDanglingPodsWorkspaceRef: SNAPSHOT, + }); + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + removedDanglingPodsWorkspaceRef: null, + }); + const removed = removeSpmInjection({appRoot, xcodeprojPath}); + expect(removed.removedDanglingPodsWorkspaceRef).toEqual(SNAPSHOT); + }); +}); + // --------------------------------------------------------------------------- // readArtifactsVersionOverride — pure fs read, used by setup-apple-spm.js's // determineVersion to prefer a pinned version over the one derived from 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..d6159fd2095 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,26 @@ 'use strict'; const { + cleanupDanglingPodsWorkspaceRef, detectStandardRnLayoutRedirect, determineVersion, + disableAutomaticPodsInstallation, ensureBothArtifactFlavors, + findExistingReactNativeConfig, findInjectedXcodeproj, generateAutolinkingConfigOrFailClosed, parseArgs, + podfileHasRnIntegration, + removeDanglingPodsFileRef, resolveAction, resolveConfigCommandToPin, resolveExplicitConfigCommand, + restoreAutomaticPodsInstallation, + restoreDanglingPodsWorkspaceRef, shouldAutoDeintegrate, + stripReactNativeFromPodfile, + stripStockPostInstallBlock, + withAutomaticPodsInstallationDisabled, } = require('../../setup-apple-spm'); const {REQUIRED_ARTIFACTS} = require('../download-spm-artifacts'); const {SPM_INJECTED_MARKER} = require('../generate-spm-xcodeproj'); @@ -563,6 +573,903 @@ 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, consuming the enclosing assignment', () => { + 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); + // 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" + + '\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); + }); + + 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 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' + + '\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 = stripStockPostInstallBlock( + stripReactNativeFromPodfile(podfile), + ); + expect(stripped).not.toMatch(/use_react_native!/); + expect(stripped).not.toMatch(/use_native_modules!/); + 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(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, +// 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('preserves a trailing inline comment when flipping the LAST property in the object (no trailing comma)', () => { + // No trailing comma after `true` — the greediest-legal match for the + // value runs up to the newline, which (before the fix) swallowed the + // masked `// keep manual for now` comment into the replaced span and + // deleted it. + const config = + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n' + + ' sourceDir: "./ios",\n' + + ' automaticPodsInstallation: true // keep manual for now\n' + + ' },\n' + + ' },\n' + + '};\n'; + expect(withAutomaticPodsInstallationDisabled(config)).toBe( + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n' + + ' sourceDir: "./ios",\n' + + ' automaticPodsInstallation: false // keep manual for now\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(); + }); + + 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,/, + ); + }); + + it('refuses (returns null) rather than insert a duplicate `ios` key when `ios` is a variable reference', () => { + // `ios: iosConfig` isn't a `{...}` object literal we can extend. + // Inserting a second `ios: {...}` ahead of it would be silently + // shadowed at runtime by the real `iosConfig` value (JS lets the last + // duplicate key win) — the file would look edited but + // automaticPodsInstallation would still resolve to nothing. + const config = + 'const iosConfig = {sourceDir: "./ios"};\n' + + 'module.exports = {\n' + + ' project: {\n' + + ' ios: iosConfig,\n' + + ' },\n' + + '};\n'; + expect(withAutomaticPodsInstallationDisabled(config)).toBeNull(); + }); + + it('refuses (returns null) rather than insert a duplicate `project` key when `project` is a function call', () => { + const config = + 'module.exports = {\n' + ' project: getProjectConfig(),\n' + '};\n'; + expect(withAutomaticPodsInstallationDisabled(config)).toBeNull(); + }); + + it('is not confused by a brace inside an unrelated string value', () => { + // A naive brace-depth scan over raw text sees this stray `{` and thinks + // depth never returns to 0 where it should, throwing off where `ios` is + // found relative to `project`. + const config = + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n' + + ' sourceDir: "{not a real brace",\n' + + ' automaticPodsInstallation: true,\n' + + ' },\n' + + ' },\n' + + '};\n'; + expect(withAutomaticPodsInstallationDisabled(config)).toBe( + 'module.exports = {\n' + + ' project: {\n' + + ' ios: {\n' + + ' sourceDir: "{not a real brace",\n' + + ' automaticPodsInstallation: false,\n' + + ' },\n' + + ' },\n' + + '};\n', + ); + }); +}); + +// --------------------------------------------------------------------------- +// 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('restores byte-for-byte when automaticPodsInstallation was originally ABSENT — does not leave `automaticPodsInstallation: true` behind', () => { + // Regression test: automaticPodsInstallation was never in this file. + // disableAutomaticPodsInstallation INSERTS the property (still `kind: + // 'edited'`, same as flipping an existing `true`) — restoring by + // "set the value back to `true`" would leave a property behind that + // never existed. Restoring the pre-edit snapshot must remove it + // entirely, reproducing the file exactly as it was. + const configPath = path.join(projectRoot, 'react-native.config.js'); + const original = + 'module.exports = {\n project: { ios: { sourceDir: "./ios" } },\n};\n'; + fs.writeFileSync(configPath, original); + const result = disableAutomaticPodsInstallation(projectRoot); + expect(result.kind).toBe('edited'); + // Sanity: the edit really did insert the property (else this test + // wouldn't be exercising the case it's meant to). + expect(fs.readFileSync(configPath, 'utf8')).toContain( + 'automaticPodsInstallation: false', + ); + restoreAutomaticPodsInstallation(result); + expect(fs.readFileSync(configPath, 'utf8')).toBe(original); + }); + + it('restores byte-for-byte when automaticPodsInstallation was originally `true`', () => { + const configPath = path.join(projectRoot, 'react-native.config.js'); + const original = + 'module.exports = {\n' + + ' project: { ios: { automaticPodsInstallation: true, sourceDir: "./ios" } },\n' + + '};\n'; + fs.writeFileSync(configPath, original); + const result = disableAutomaticPodsInstallation(projectRoot); + expect(result.kind).toBe('edited'); + restoreAutomaticPodsInstallation(result); + expect(fs.readFileSync(configPath, 'utf8')).toBe(original); + }); + + it('leaves an edited file in place if the user has since edited it (does not overwrite their change)', () => { + 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'); + const userEdited = + fs.readFileSync(configPath, 'utf8') + '// a note the user added\n'; + fs.writeFileSync(configPath, userEdited); + restoreAutomaticPodsInstallation(result); + expect(fs.readFileSync(configPath, 'utf8')).toBe(userEdited); + }); + + 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 +// 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); + }); + + 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', + ); + }); +}); + +// --------------------------------------------------------------------------- +// 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, returning a before/after snapshot', () => { + const xcodeprojPath = mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + const workspace = mkWorkspace(appRoot, 'MyApp.xcworkspace'); + const dataPath = path.join(workspace, 'contents.xcworkspacedata'); + const before = fs.readFileSync(dataPath, 'utf8'); + + const result = cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath); + expect(result).not.toBeNull(); + expect(result?.dataPath).toBe(dataPath); + expect(result?.before).toBe(before); + + const data = fs.readFileSync(dataPath, 'utf8'); + expect(data).not.toMatch(/Pods\.xcodeproj/); + expect(result?.after).toBe(data); + }); + + 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)).toBeNull(); + 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)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// restoreDanglingPodsWorkspaceRef — the `spm deinit` counterpart, driven by +// the before/after snapshot cleanupDanglingPodsWorkspaceRef recorded. React +// Native needs a real Pods.xcodeproj reference again once CocoaPods is +// reintegrated (`pod install`), so this puts it straight back rather than +// leaving the user to rediscover it's missing. +// --------------------------------------------------------------------------- + +describe('restoreDanglingPodsWorkspaceRef', () => { + let appRoot; + beforeEach(() => { + appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-workspace-restore-')); + }); + 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('restores the reference byte-for-byte when untouched since', () => { + const xcodeprojPath = mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + mkWorkspace(appRoot, 'MyApp.xcworkspace'); + const result = cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath); + expect(result).not.toBeNull(); + + restoreDanglingPodsWorkspaceRef(result); + expect(fs.readFileSync(result?.dataPath ?? '', 'utf8')).toBe( + result?.before, + ); + }); + + it('leaves the file alone if it has changed since (does not clobber a later edit)', () => { + const xcodeprojPath = mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + mkWorkspace(appRoot, 'MyApp.xcworkspace'); + const result = cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath); + expect(result).not.toBeNull(); + + const dataPath = result?.dataPath ?? ''; + const editedSince = fs.readFileSync(dataPath, 'utf8') + '\n'; + fs.writeFileSync(dataPath, editedSince, 'utf8'); + + restoreDanglingPodsWorkspaceRef(result); + expect(fs.readFileSync(dataPath, 'utf8')).toBe(editedSince); + }); + + it('is a no-op for null (nothing was removed)', () => { + expect(() => restoreDanglingPodsWorkspaceRef(null)).not.toThrow(); + }); + + it('is a no-op when the workspace file no longer exists', () => { + const xcodeprojPath = mkXcodeproj(appRoot, 'MyApp.xcodeproj'); + const workspace = mkWorkspace(appRoot, 'MyApp.xcworkspace'); + const result = cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath); + expect(result).not.toBeNull(); + + fs.rmSync(workspace, {recursive: true, force: true}); + expect(() => restoreDanglingPodsWorkspaceRef(result)).not.toThrow(); + }); +}); + // --------------------------------------------------------------------------- // determineVersion — which RN version the artifact slots are wired to: // explicit --version → the `artifactsVersionOverride` pinned in the injection diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index a32b57650a8..95d26fe7c2c 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -55,6 +55,8 @@ const fs = require('node:fs'); const path = require('node:path'); /*:: import type { + AutomaticPodsInstallationResult, + DanglingPodsWorkspaceRefResult, FlavoredFrameworkManifestEntry, PluginScriptPhase, XcframeworkSlice, @@ -2240,7 +2242,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, removedDanglingPodsWorkspaceRef?: ?DanglingPodsWorkspaceRefResult, 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 @@ -2361,7 +2363,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, removedDanglingPodsWorkspaceRef?: ?DanglingPodsWorkspaceRefResult} */, ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { const {appRoot, reactNativeRoot, xcodeprojPath} = opts; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -2500,6 +2502,46 @@ function injectSpmIntoExistingXcodeproj( // the whole marker, this field with it. const configCommand = opts.configCommand ?? prevMarker?.configCommand ?? null; + // 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 a prior deintegrate run recorded. Read back by + // `spm deinit` (removeSpmInjection, below) to restore + // project.ios.automaticPodsInstallation. + // + // Unlike the plain set-or-preserve fields above, a non-null result here + // does NOT always mean "overwrite": 'created'/'edited' are the only kinds + // that represent an actual write THIS run made, so those are the only + // ones allowed to replace the record. 'already-disabled' (already false + // before this run touched it) and 'unrecognized' (couldn't parse the + // file) both mean "no write happened" — on a repeat `--deintegrate` run, + // the file is already false BECAUSE a prior run created/edited it, so a + // naive `??` here would replace that 'created'/'edited' record with + // 'already-disabled' and silently turn `deinit` into a no-op, leaving + // automaticPodsInstallation stuck at `false` forever. Preserve the prior + // record whenever this run made no write; only fall back to the current + // (non-mutating) result when there's no prior record to preserve. + const ranThisTime = opts.automaticPodsInstallation; + const thisRunWroteTheFile = + ranThisTime != null && + (ranThisTime.kind === 'created' || ranThisTime.kind === 'edited'); + const automaticPodsInstallation = thisRunWroteTheFile + ? ranThisTime + : (prevMarker?.automaticPodsInstallation ?? ranThisTime ?? null); + + // Same non-clobbering contract as automaticPodsInstallation above, for the + // dangling `Pods/Pods.xcodeproj` workspace reference + // cleanupDanglingPodsWorkspaceRef removes (setup-apple-spm.js), carrying + // the before/after snapshot `spm deinit` restores. A repeat `--deintegrate` + // run finds nothing left to remove (the first run already removed it) and + // reports null — that must not overwrite an earlier non-null record, or + // `spm deinit` loses the snapshot needed to restore it. Only a non-null + // result (this run actually removed something) replaces the record; null + // preserves whatever was already there. + const removedDanglingPodsWorkspaceRef = + opts.removedDanglingPodsWorkspaceRef ?? + prevMarker?.removedDanglingPodsWorkspaceRef ?? + null; + // Marker: idempotency signal + the exact, reversible record of every edit so // `deinit` (removeSpmInjection) can undo precisely what was added. writeIfChanged( @@ -2523,6 +2565,8 @@ function injectSpmIntoExistingXcodeproj( scriptPhases: scriptPhaseUuids, artifactsVersionOverride, configCommand, + automaticPodsInstallation, + removedDanglingPodsWorkspaceRef, scheme: { file: schemeResult.file, // Sticky — see mergeCreatedArrayFields for why a later sync cannot @@ -2691,7 +2735,7 @@ function removeRecordedBuildSettings( */ function removeSpmInjection( opts /*: {appRoot: string, xcodeprojPath: string} */, -) /*: {status: 'removed', target: string} | {status: 'absent'} */ { +) /*: {status: 'removed', target: string, automaticPodsInstallation: ?AutomaticPodsInstallationResult, removedDanglingPodsWorkspaceRef: ?DanglingPodsWorkspaceRefResult} | {status: 'absent'} */ { const {appRoot, xcodeprojPath} = opts; const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); if (!fs.existsSync(markerPath)) { @@ -2770,7 +2814,13 @@ 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, + removedDanglingPodsWorkspaceRef: + marker.removedDanglingPodsWorkspaceRef ?? 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..ad34c8bb375 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -34,6 +34,42 @@ 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. +// +// 'edited' carries a full before/after snapshot rather than just "flip +// false back to true": the edit that produced `after` might have been +// inserting `automaticPodsInstallation` where it was previously absent +// (rather than flipping an existing `true`), and might have inserted a +// wrapping `project`/`ios` object too — restoring by "set it back to +// `true`" would leave a property (and possibly a whole object) behind that +// never existed in the file to begin with. Reverting to the exact `before` +// snapshot is correct regardless of which case produced `after`, as long as +// nothing else has touched the file since (checked by comparing its current +// contents to `after` before restoring — see restoreAutomaticPodsInstallation). +export type AutomaticPodsInstallationResult = + | {kind: 'created', configPath: string} + | {kind: 'edited', configPath: string, before: string, after: string} + | {kind: 'already-disabled', configPath: string} + | {kind: 'unrecognized', configPath: string}; + +// The dangling `Pods/Pods.xcodeproj` FileRef cleanupDanglingPodsWorkspaceRef +// (setup-apple-spm.js) removed from a .xcworkspace's +// contents.xcworkspacedata, recorded in the `.spm-injected.json` marker so +// `spm deinit` (restoreDanglingPodsWorkspaceRef) can restore it byte-for-byte +// — React Native needs a real Pods.xcodeproj reference again once CocoaPods +// is reintegrated via `pod install`. Same before/after-snapshot approach as +// AutomaticPodsInstallationResult's 'edited' case, for the same reason: only +// restore when nothing else has touched the file since. +export type DanglingPodsWorkspaceRefResult = { + dataPath: string, + before: string, + after: string, +}; + export type DownloadArgs = { version: string | null, flavor: string,