Skip to content

Commit 94a8fdf

Browse files
committed
Improves spm deintegrate script to handle more edge-cases
1 parent 7f18ad0 commit 94a8fdf

2 files changed

Lines changed: 626 additions & 10 deletions

File tree

packages/react-native/scripts/setup-apple-spm.js

Lines changed: 331 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -694,9 +694,327 @@ function shouldAutoDeintegrate(
694694
return true;
695695
}
696696

697+
// The Podfile DSL calls that wire up React Native's CocoaPods integration.
698+
const RN_PODFILE_CALLS = [
699+
'use_react_native!',
700+
'use_native_modules!',
701+
'prepare_react_native_project!',
702+
];
703+
704+
// Strip every occurrence of the RN Podfile calls above, including their
705+
// argument list when the call spans multiple lines, e.g. the stock template's
706+
// use_react_native!(
707+
// :path => "...",
708+
// :app_path => "..."
709+
// )
710+
// A plain line-filter only removes the opening line and leaves the argument
711+
// lines + closing paren behind, producing a syntactically broken Podfile.
712+
// Only strips the call's own line(s); doesn't touch surrounding code, so a
713+
// call assigned to a variable (`config = use_native_modules!(...)`) keeps its
714+
// line but loses the call — matching prior (single-line) behavior.
715+
function stripReactNativeFromPodfile(contents /*: string */) /*: string */ {
716+
let text = contents;
717+
for (const name of RN_PODFILE_CALLS) {
718+
let out = '';
719+
let i = 0;
720+
while (i < text.length) {
721+
const idx = text.indexOf(name, i);
722+
if (idx === -1) {
723+
out += text.slice(i);
724+
break;
725+
}
726+
out += text.slice(i, idx);
727+
let end = idx + name.length;
728+
let k = end;
729+
while (k < text.length && (text[k] === ' ' || text[k] === '\t')) k++;
730+
if (text[k] === '(') {
731+
let depth = 0;
732+
for (let m = k; m < text.length; m++) {
733+
if (text[m] === '(') depth++;
734+
else if (text[m] === ')') {
735+
depth--;
736+
if (depth === 0) {
737+
end = m + 1;
738+
break;
739+
}
740+
}
741+
}
742+
}
743+
// If the rest of the line (after the call) is blank, drop the trailing
744+
// newline too, so we don't leave an empty line behind.
745+
let lineEnd = text.indexOf('\n', end);
746+
if (lineEnd === -1) lineEnd = text.length;
747+
if (text.slice(end, lineEnd).trim() === '') {
748+
end = lineEnd < text.length ? lineEnd + 1 : lineEnd;
749+
}
750+
// If everything before the call on its line is just indentation, drop
751+
// that indentation too, so we don't leave a whitespace-only line.
752+
const lineStart = out.lastIndexOf('\n') + 1;
753+
if (out.slice(lineStart).trim() === '') {
754+
out = out.slice(0, lineStart);
755+
}
756+
i = end;
757+
}
758+
text = out;
759+
}
760+
return text;
761+
}
762+
763+
// Finds the matching `}` for the `{` at `openIdx`, or null if unbalanced.
764+
function matchingBrace(text /*: string */, openIdx /*: number */) /*: number | null */ {
765+
let depth = 0;
766+
for (let i = openIdx; i < text.length; i++) {
767+
if (text[i] === '{') depth++;
768+
else if (text[i] === '}') {
769+
depth--;
770+
if (depth === 0) return i;
771+
}
772+
}
773+
return null;
774+
}
775+
776+
// Finds `key: {` within `[start, end)`, but only occurrences at brace-depth 0
777+
// relative to `start` — i.e. a direct property of the object being scanned,
778+
// not a same-named key nested inside some other property's value. Returns
779+
// the `{...}` range of that key's object value, or null if absent.
780+
function findTopLevelKeyObjectRange(
781+
text /*: string */,
782+
key /*: string */,
783+
start /*: number */,
784+
end /*: number */,
785+
) /*: {open: number, close: number} | null */ {
786+
const re = new RegExp('\\b' + key + '\\s*:\\s*{', 'g');
787+
re.lastIndex = start;
788+
let m;
789+
while ((m = re.exec(text)) && m.index < end) {
790+
let depth = 0;
791+
for (let i = start; i < m.index; i++) {
792+
if (text[i] === '{') depth++;
793+
else if (text[i] === '}') depth--;
794+
}
795+
if (depth === 0) {
796+
const openIdx = m.index + m[0].length - 1;
797+
const closeIdx = matchingBrace(text, openIdx);
798+
if (closeIdx != null && closeIdx <= end) {
799+
return {open: openIdx, close: closeIdx};
800+
}
801+
}
802+
}
803+
return null;
804+
}
805+
806+
// Inserts `propertyText` (no trailing comma/newline) as the first property of
807+
// the object whose `{` is at `openIdx`, matching the existing content's
808+
// line-break style so we don't smash an empty `{}` and a populated object
809+
// into the same shape.
810+
function insertFirstProperty(
811+
text /*: string */,
812+
openIdx /*: number */,
813+
indent /*: string */,
814+
propertyText /*: string */,
815+
) /*: string */ {
816+
const rest = text.slice(openIdx + 1);
817+
const needsNewlineAfter = !/^[ \t]*\r?\n/.test(rest);
818+
return (
819+
text.slice(0, openIdx + 1) +
820+
'\n' +
821+
indent +
822+
propertyText +
823+
',' +
824+
(needsNewlineAfter ? '\n' + indent.slice(0, -2) : '') +
825+
rest
826+
);
827+
}
828+
829+
// Sets `project.ios.automaticPodsInstallation` to `false` in the contents of
830+
// a react-native.config.js, inserting whichever of `project` / `ios` /
831+
// `automaticPodsInstallation` are missing. Returns null when `contents`
832+
// doesn't look like a plain `module.exports = {...}` object literal — the
833+
// caller should warn instead of risking a corrupt rewrite.
834+
function withAutomaticPodsInstallationDisabled(
835+
contents /*: string */,
836+
) /*: string | null */ {
837+
if (/automaticPodsInstallation\s*:\s*false\b/.test(contents)) {
838+
return contents;
839+
}
840+
if (/automaticPodsInstallation\s*:\s*true\b/.test(contents)) {
841+
return contents.replace(
842+
/automaticPodsInstallation\s*:\s*true\b/,
843+
'automaticPodsInstallation: false',
844+
);
845+
}
846+
const exportsMatch = /module\.exports\s*=\s*{/.exec(contents);
847+
if (!exportsMatch) {
848+
return null;
849+
}
850+
const exportsOpen = exportsMatch.index + exportsMatch[0].length - 1;
851+
const exportsClose = matchingBrace(contents, exportsOpen);
852+
if (exportsClose == null) {
853+
return null;
854+
}
855+
856+
const projectRange = findTopLevelKeyObjectRange(
857+
contents,
858+
'project',
859+
exportsOpen + 1,
860+
exportsClose,
861+
);
862+
if (projectRange == null) {
863+
return insertFirstProperty(
864+
contents,
865+
exportsOpen,
866+
' ',
867+
'project: {\n ios: {\n automaticPodsInstallation: false,\n },\n }',
868+
);
869+
}
870+
871+
const iosRange = findTopLevelKeyObjectRange(
872+
contents,
873+
'ios',
874+
projectRange.open + 1,
875+
projectRange.close,
876+
);
877+
if (iosRange == null) {
878+
return insertFirstProperty(
879+
contents,
880+
projectRange.open,
881+
' ',
882+
'ios: {\n automaticPodsInstallation: false,\n }',
883+
);
884+
}
885+
886+
return insertFirstProperty(
887+
contents,
888+
iosRange.open,
889+
' ',
890+
'automaticPodsInstallation: false',
891+
);
892+
}
893+
894+
// Disables automatic `pod install` on future `react-native run-ios` /
895+
// `build-ios` invocations by setting `project.ios.automaticPodsInstallation`
896+
// to `false` in react-native.config.js (default is `true` — see
897+
// @react-native-community/cli-config's schema). Left on, it's a landmine: the
898+
// CLI silently re-runs CocoaPods on the next build and re-breaks the SPM
899+
// package graph, the same class of problem `podfileHasRnIntegration` warns
900+
// about for the Podfile itself.
901+
function disableAutomaticPodsInstallation(appRoot /*: string */) /*: void */ {
902+
const configPath = path.join(appRoot, 'react-native.config.js');
903+
if (!fs.existsSync(configPath)) {
904+
fs.writeFileSync(
905+
configPath,
906+
'module.exports = {\n' +
907+
' project: {\n' +
908+
' ios: {\n' +
909+
' automaticPodsInstallation: false,\n' +
910+
' },\n' +
911+
' },\n' +
912+
'};\n',
913+
'utf8',
914+
);
915+
log(
916+
'Created react-native.config.js with `automaticPodsInstallation: false`.',
917+
);
918+
return;
919+
}
920+
const orig = fs.readFileSync(configPath, 'utf8');
921+
const updated = withAutomaticPodsInstallationDisabled(orig);
922+
if (updated == null) {
923+
log(
924+
"\x1b[33mNote: couldn't automatically disable automaticPodsInstallation " +
925+
"in react-native.config.js (unrecognized format). Set `project.ios." +
926+
'automaticPodsInstallation` to `false` yourself, or a future `pod ' +
927+
'install` will re-break the SPM package graph.\x1b[0m',
928+
);
929+
return;
930+
}
931+
if (updated !== orig) {
932+
fs.writeFileSync(configPath, updated, 'utf8');
933+
log('Disabled `automaticPodsInstallation` in react-native.config.js.');
934+
}
935+
}
936+
937+
// Locate the .xcworkspace CocoaPods manages alongside the .xcodeproj — same
938+
// basename by convention (what `pod install` creates), falling back to the
939+
// single *.xcworkspace in appRoot when the basenames don't line up. Returns
940+
// null when there's no workspace at all (never `pod install`-ed) or when the
941+
// fallback scan is ambiguous.
942+
function findXcworkspace(
943+
appRoot /*: string */,
944+
xcodeprojPath /*: string */,
945+
) /*: string | null */ {
946+
const sibling = path.join(
947+
path.dirname(xcodeprojPath),
948+
path.basename(xcodeprojPath, '.xcodeproj') + '.xcworkspace',
949+
);
950+
if (fs.existsSync(sibling)) {
951+
return sibling;
952+
}
953+
const names /*: Array<string> */ = [];
954+
let entries /*: Array<{name: string, isDirectory(): boolean}> */ = [];
955+
try {
956+
// $FlowFixMe[incompatible-type] Dirent typing
957+
entries = fs.readdirSync(appRoot, {withFileTypes: true});
958+
} catch {
959+
return null;
960+
}
961+
for (const entry of entries) {
962+
if (!entry.isDirectory()) continue;
963+
// $FlowFixMe[incompatible-type] Dirent.name is string|Buffer in Flow stubs
964+
const name /*: string */ = entry.name;
965+
if (name.endsWith('.xcworkspace')) {
966+
names.push(name);
967+
}
968+
}
969+
return names.length === 1 ? path.join(appRoot, names[0]) : null;
970+
}
971+
972+
// Strip the `group:Pods/Pods.xcodeproj` FileRef CocoaPods adds to the
973+
// .xcworkspace's contents.xcworkspacedata, e.g.:
974+
// <FileRef
975+
// location = "group:Pods/Pods.xcodeproj">
976+
// </FileRef>
977+
// `pod deintegrate` removes the Pods project/integration but doesn't touch
978+
// the workspace, so this reference dangles — Xcode shows a permanent red,
979+
// missing Pods.xcodeproj row in the workspace navigator otherwise.
980+
function removeDanglingPodsFileRef(xml /*: string */) /*: string */ {
981+
return xml.replace(
982+
/[ \t]*<FileRef\s+location\s*=\s*"group:Pods\/Pods\.xcodeproj"\s*(?:\/>|>\s*<\/FileRef>)\r?\n?/g,
983+
'',
984+
);
985+
}
986+
987+
// Called by `add --deintegrate` after `pod deintegrate`. Only touches the
988+
// reference when Pods/Pods.xcodeproj is actually gone from disk, so a
989+
// side-by-side non-RN CocoaPods integration is never disturbed. No-op when
990+
// the workspace, its contents.xcworkspacedata, or the reference is absent.
991+
function cleanupDanglingPodsWorkspaceRef(
992+
appRoot /*: string */,
993+
xcodeprojPath /*: string */,
994+
) /*: boolean */ {
995+
if (fs.existsSync(path.join(appRoot, 'Pods', 'Pods.xcodeproj'))) {
996+
return false;
997+
}
998+
const workspacePath = findXcworkspace(appRoot, xcodeprojPath);
999+
if (workspacePath == null) {
1000+
return false;
1001+
}
1002+
const dataPath = path.join(workspacePath, 'contents.xcworkspacedata');
1003+
if (!fs.existsSync(dataPath)) {
1004+
return false;
1005+
}
1006+
const orig = fs.readFileSync(dataPath, 'utf8');
1007+
const cleaned = removeDanglingPodsFileRef(orig);
1008+
if (cleaned === orig) {
1009+
return false;
1010+
}
1011+
fs.writeFileSync(dataPath, cleaned, 'utf8');
1012+
return true;
1013+
}
1014+
6971015
// Run `pod deintegrate` then strip React Native from the Podfile (leaving any
6981016
// non-RN pods). Requires CocoaPods on PATH (fail-loud otherwise). Flag-gated ⇒
699-
// no prompt ⇒ CI-safe. Does NOT touch the .xcworkspace.
1017+
// no prompt ⇒ CI-safe.
7001018
function runDeintegrate(appRoot /*: string */) /*: void */ {
7011019
try {
7021020
execFileSync('pod', ['--version'], {stdio: 'ignore'});
@@ -714,20 +1032,14 @@ function runDeintegrate(appRoot /*: string */) /*: void */ {
7141032
const podfilePath = path.join(appRoot, 'Podfile');
7151033
if (fs.existsSync(podfilePath)) {
7161034
const orig = fs.readFileSync(podfilePath, 'utf8');
717-
const stripped = orig
718-
.split('\n')
719-
.filter(
720-
l =>
721-
!/use_react_native!|use_native_modules!|prepare_react_native_project!/.test(
722-
l,
723-
),
724-
)
725-
.join('\n');
1035+
const stripped = stripReactNativeFromPodfile(orig);
7261036
if (stripped !== orig) {
7271037
fs.writeFileSync(podfilePath, stripped, 'utf8');
7281038
log('Stripped React Native integration from Podfile.');
7291039
}
7301040
}
1041+
1042+
disableAutomaticPodsInstallation(appRoot);
7311043
}
7321044

7331045
// Pick the .xcodeproj to inject into: --xcodeproj override > a prior in-place
@@ -810,6 +1122,11 @@ async function setupXcodeproj(
8101122
if (cleanupLeftoverPodsGroup(xcodeprojPath)) {
8111123
log('Removed the leftover empty `Pods` group from the project.');
8121124
}
1125+
if (cleanupDanglingPodsWorkspaceRef(appRoot, xcodeprojPath)) {
1126+
log(
1127+
'Removed the dangling Pods.xcodeproj reference from the .xcworkspace.',
1128+
);
1129+
}
8131130
}
8141131

8151132
// Preflight: a still-CocoaPods-integrated pbxproj is the real build-breaker.
@@ -1278,6 +1595,10 @@ module.exports = {
12781595
resolveAction,
12791596
resolveConfigCommandToPin,
12801597
resolveExplicitConfigCommand,
1598+
cleanupDanglingPodsWorkspaceRef,
1599+
removeDanglingPodsFileRef,
12811600
shouldAutoDeintegrate,
1601+
stripReactNativeFromPodfile,
1602+
withAutomaticPodsInstallationDisabled,
12821603
ensureBothArtifactFlavors,
12831604
};

0 commit comments

Comments
 (0)