Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/react-native/scripts/spm/__doc__/spm-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
57 changes: 54 additions & 3 deletions packages/react-native/scripts/spm/generate-spm-xcodeproj.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down
Loading