From 6b295119d14a2172f4b5ea29bfd203fe6a53e4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Rolka?= Date: Wed, 12 Aug 2026 13:04:57 +0200 Subject: [PATCH] Set SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG when injecting SPM Swift's `#if DEBUG` is gated by SWIFT_ACTIVE_COMPILATION_CONDITIONS, not by GCC_PREPROCESSOR_DEFINITIONS (which only reaches C/ObjC/C++). The app template does not commit that setting; CocoaPods injects it at `pod install` time (react_native_post_install -> set_build_setting SWIFT_ACTIVE_COMPILATION_CONDITIONS = ["$(inherited)", "DEBUG"] on Debug). An app set up with the experimental SwiftPM support never runs CocoaPods, so `#if DEBUG` is false even in a Debug build: AppDelegate.swift's `bundleURL()` skips the Metro URL and falls back to a main.jsbundle a Debug build never produced, and the app dies at launch with "No script url provided ... unsanitizedScriptURLString = (null)" while Metro is running. Inject the setting from `spm add`/`update` alongside the other React build settings, into debug-flavored configurations only (the same flavorForBuildConfiguration test that selects the debug xcframeworks), so a config linking the debug binaries also compiles its Swift with DEBUG. Doing it in the injector rather than in the app template fixes existing apps too, and keeps the setting on the one code path that knows a project is SwiftPM-integrated. The merge is additive and reversible like every other injected setting: a config that already defines DEBUG is left byte-identical. That required tightening the "already present" test in mergeReactBuildSettings from a substring match to token membership -- it now recognizes the scalar form (`"$(inherited) DEBUG"`) that addArrayStringValues' exact-member dedupe misses (previously it would have promoted the scalar to an array and re-appended, an edit the marker has no record of and `deinit` could never reverse), while no longer reading a user's `MY_DEBUG_FLAG` as DEBUG. Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/spm/__doc__/spm-scripts.md | 8 +++ .../__tests__/inject-spm-xcodeproj-test.js | 60 +++++++++++++++++++ .../__tests__/remove-spm-injection-test.js | 32 ++++++++++ .../scripts/spm/generate-spm-xcodeproj.js | 57 +++++++++++++++++- 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/packages/react-native/scripts/spm/__doc__/spm-scripts.md b/packages/react-native/scripts/spm/__doc__/spm-scripts.md index d2db90c4f59..2fd6cb601bf 100644 --- a/packages/react-native/scripts/spm/__doc__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__doc__/spm-scripts.md @@ -209,6 +209,14 @@ configuration selects Release. Selection uses only generated build settings and standard macOS tools: builds do not run Node, mutate symlinks, regenerate the package graph, or require a second build. +Those same debug-flavored configurations also get +`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"` — the only thing +that makes Swift's `#if DEBUG` true (`GCC_PREPROCESSOR_DEFINITIONS` reaches +C/ObjC/C++ only), and what `AppDelegate.swift`'s `bundleURL()` branches on to +load from Metro instead of a bundled `main.jsbundle`. CocoaPods injects it at +`pod install` time, so this keeps SwiftPM apps at parity. An existing value is +left alone. + ## What to commit | Path | Commit? | Why | diff --git a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js index bc77f7e1eec..fce82bf8ad6 100644 --- a/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js +++ b/packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js @@ -31,6 +31,32 @@ const PODS = PLAIN.replace( 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = BB0000000000000000000001 /* Pods-MyApp.debug.xcconfig */;\n\t\t\tbuildSettings = {', ); +// The app target's two XCBuildConfiguration UUIDs in the fixture. +const APP_DEBUG_CONFIG = 'AA0000000000000000000901'; +const APP_RELEASE_CONFIG = 'AA00000000000000000000A2'; + +const DEBUG_CONFIG_HEAD = + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {'; + +// Seed the app target's Debug config with a SWIFT_ACTIVE_COMPILATION_CONDITIONS +// the user already had, in the scalar form Xcode and the app template write. +function withDebugCondition(text, value) { + return text.replace( + DEBUG_CONFIG_HEAD, + `${DEBUG_CONFIG_HEAD}\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = ${value};`, + ); +} + +// One XCBuildConfiguration's buildSettings dict, by config UUID. Build settings +// hold only scalars and `( … )` arrays, so the first `};` closes the dict. +function buildSettingsOf(text, configUuid) { + const open = text.indexOf( + 'buildSettings = {', + text.indexOf(`${configUuid} /*`), + ); + return text.slice(open, text.indexOf('};', open)); +} + const RN_PATH = '../node_modules/react-native'; // Absolute, mirroring resolveHermesCliPathSetting (a `..`-relative path through @@ -218,6 +244,40 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => { expect(text).not.toContain('HERMES_CLI_PATH'); }); + // Swift's `#if DEBUG` — which AppDelegate.swift's bundleURL() uses to pick the + // Metro URL — is gated by this setting alone. CocoaPods injects it at `pod + // install`; an SPM app has to get it here or a Debug build looks for a + // main.jsbundle it never built. + it('sets SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG on the debug config only', () => { + const {text} = inject(PLAIN); + const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); + expect(debugSettings).toMatch( + /SWIFT_ACTIVE_COMPILATION_CONDITIONS = \(\s*"\$\(inherited\)",\s*DEBUG,\s*\)/, + ); + expect(buildSettingsOf(text, APP_RELEASE_CONFIG)).not.toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', + ); + }); + + it('leaves a config that already sets DEBUG (scalar form) untouched', () => { + const {text} = inject(withDebugCondition(PLAIN, '"$(inherited) DEBUG"')); + // Not promoted to an array, not re-appended — DEBUG is already there. + expect(buildSettingsOf(text, APP_DEBUG_CONFIG)).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";', + ); + expect(text.match(/\bDEBUG\b/g)).toHaveLength(1); + }); + + it("adds DEBUG alongside the user's own compilation conditions", () => { + const {text} = inject( + withDebugCondition(PLAIN, '"$(inherited) MY_DEBUG_UI"'), + ); + // MY_DEBUG_UI must not be mistaken for DEBUG by a substring check. + const debugSettings = buildSettingsOf(text, APP_DEBUG_CONFIG); + expect(debugSettings).toContain('"$(inherited) MY_DEBUG_UI"'); + expect(debugSettings).toMatch(/^\s*DEBUG,$/m); + }); + it('prepends the Sync SPM Autolinking build phase', () => { const {text} = inject(PLAIN); expect(text).toContain('Sync SPM Autolinking'); 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 d023fa9a2d8..3625dd79e83 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 @@ -190,6 +190,38 @@ describe('removeSpmInjection — the surgical inverse of add', () => { expect(fs.existsSync(schemePath)).toBe(false); }); + // A Debug config that already carries DEBUG gets no edit at all, so there is + // nothing for the marker to record — and nothing left behind. Injecting into + // the scalar form regardless (addArrayStringValues dedupes by exact array + // member, which the scalar never matches) would promote it to an array the + // marker has no record of, and deinit would strand it. + it('leaves a Debug config that already sets DEBUG alone, add through deinit', () => { + const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); + const head = + 'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbuildSettings = {'; + fs.writeFileSync( + path.join(xcodeprojPath, 'project.pbxproj'), + PLAIN.replace( + head, + `${head}\n\t\t\t\tSWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";`, + ), + 'utf8', + ); + const before = pbxprojOf(xcodeprojPath); + + injectSpmIntoExistingXcodeproj({ + appRoot, + reactNativeRoot: rnRoot, + xcodeprojPath, + }); + expect(pbxprojOf(xcodeprojPath)).toContain( + 'SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";', + ); + + expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed'); + expect(pbxprojOf(xcodeprojPath)).toBe(before); + }); + it('preserves an unrelated edit made to the pbxproj after add', () => { const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(); diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 3126df74d0c..def64b4e34a 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -1093,6 +1093,25 @@ const INJECTED_ARRAY_SETTINGS = [ }, ]; +// Array build settings injected only into debug-flavored configurations. +// +// Swift's `#if DEBUG` is gated by SWIFT_ACTIVE_COMPILATION_CONDITIONS, NOT by +// GCC_PREPROCESSOR_DEFINITIONS (which only reaches C/ObjC/C++). The app +// template does not commit the setting: CocoaPods injects it at `pod install` +// time (react_native_post_install → set_build_setting +// SWIFT_ACTIVE_COMPILATION_CONDITIONS = ["$(inherited)", "DEBUG"] on Debug). +// An SPM app never runs CocoaPods, so without this `#if DEBUG` is false even +// in a Debug build — AppDelegate.swift's `bundleURL()` skips the Metro URL, +// falls back to a main.jsbundle that a Debug build never produced, and the app +// dies at launch with "No script url provided … unsanitizedScriptURLString = +// (null)" while Metro is running right there. +// +// Paired with RN_SPM_FLAVOR via flavorForBuildConfiguration, so a config that +// links the debug xcframeworks also compiles its Swift with DEBUG. +const DEBUG_ARRAY_SETTINGS = [ + {key: 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', values: ['DEBUG']}, +]; + /** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */ function targetBuildConfigUuids( text /*: string */, @@ -1687,6 +1706,28 @@ function resolveHermesCliPathSetting( } } +/** Strip the surrounding plist quotes from a build-setting token, if any. */ +function unquotePlist(s /*: string */) /*: string */ { + return s.replace(/^"/, '').replace(/"$/, ''); +} + +/** + * The individual values a build setting already carries, unquoted — for both + * shapes a pbxproj uses: the array form Xcode writes for a multi-value setting + * (`("$(inherited)", DEBUG)`) and the scalar form the app template and + * hand-edits use (`"$(inherited) DEBUG"`). Membership, not substring: the + * latter would read `MY_DEBUG_FLAG` as `DEBUG` already being set and silently + * skip the injection. + */ +function buildSettingValueTokens(value /*: string */) /*: Set */ { + return new Set( + value + .split(/[\s,()]+/) + .filter(Boolean) + .map(unquotePlist), + ); +} + function mergeReactBuildSettings( input /*: string */, configUuid /*: string */, @@ -1732,6 +1773,9 @@ function mergeReactBuildSettings( const createdScalars /*: Array */ = []; const arraySettings = [ ...INJECTED_ARRAY_SETTINGS, + ...(flavorForBuildConfiguration(configurationName) === 'debug' + ? DEBUG_ARRAY_SETTINGS + : []), ...frameworkArrayBuildSettings(flavoredFrameworks), ]; for (const {key, values} of arraySettings) { @@ -1743,10 +1787,17 @@ function mergeReactBuildSettings( if (existing == null) { createdArrayKeys.push(key); } else { - const fresh = values.filter(v => !existing.value.includes(v)); - if (fresh.length > 0) { - appendedArrayValues[key] = fresh; + const present = buildSettingValueTokens(existing.value); + const fresh = values.filter(v => !present.has(unquotePlist(v))); + if (fresh.length === 0) { + // Nothing to add. Skip addArrayStringValues entirely: its dedupe is by + // EXACT array member, so a value the user carries in the scalar form + // (`SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"`) would + // otherwise be promoted to an array and re-appended — an edit `deinit` + // has no record of and so could never reverse. + continue; } + appendedArrayValues[key] = fresh; } text = addArrayStringValues(text, d, key, values); }