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); }