Set SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG when injecting SPM (#57914)

Summary:
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.

Info: https://github.com/react-native-community/template/pull/244

## Changelog:

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.

<!-- Help reviewers and the release process by writing your own changelog entry.

Pick one each for the category and type tags:

[IOS] [FIXED] - Set SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG when injecting SPM

For more details, see:
https://reactnative.dev/contributing/changelogs-in-pull-requests

Pull Request resolved: https://github.com/react/react-native/pull/57914

Test Plan:
1. Build spm-only rn app in debug
    - App will fail to connect to the Metro
2. Apply changes and run `react-native spm`
3. Build again
    - App will work as expected in debug and Metro will be connected

Reviewed By: Abbondanzo

Differential Revision: D115736551

Pulled By: cipolleschi

fbshipit-source-id: 1e42ca26ac1992bfa827ce85a63cf4564d313e15
This commit is contained in:
Radosław Rolka
2026-08-13 03:55:48 -07:00
committed by meta-codesync[bot]
parent a4733b1ca1
commit 4433cdbe62
4 changed files with 154 additions and 3 deletions
@@ -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 |
@@ -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');
@@ -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();
+54 -3
View File
@@ -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<string> */ {
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<string> */ = [];
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);
}