Skip to content

Commit 5e4e759

Browse files
chrfalchclaude
andcommitted
feat(spm): automatic Debug/Release flavor swap for SwiftPM
RN ships flavored prebuilt binaries — debug React/hermesvm/ReactNativeDependencies carry the dev experience (dev menu, assertions, RN_DEBUG_STRING_CONVERTIBLE), release strips them — so a Debug build must embed debug binaries and a Release/archive the release ones. SwiftPM binaryTargets can't branch on the build configuration, so mirror the CocoaPods React-Core-prebuilt swap (replace-rncore-version.js): - `spm add` now downloads BOTH flavors up front (best-effort) so the swap never needs the network mid-build. - New `swap-flavor` action + swap-flavor.js: reads the Xcode build env ($CONFIGURATION / $BUILT_PRODUCTS_DIR / $PLATFORM_NAME), slice-matches the target flavor's React / ReactNativeDependencies / hermesvm frameworks from the cache slot, and rsyncs them over the SwiftPM-copied frameworks in BUILT_PRODUCTS_DIR before Link. Idempotent, best-effort (never fatal). Resolves the cache slot via a single-level readlink (not realpath) so a locally-built React.xcframework symlinked into the slot doesn't send resolution out of the slot and under-swap. - The generated build phase invokes it on every build (no-op in the scheme pre-action, where BUILT_PRODUCTS_DIR isn't populated yet). Verified on the sim BOTH directions (hash-compared against the slot binaries): add-debug→Release build ships release; add-release→Debug build ships debug; all three frameworks swapped. Unit tests cover platform→slice mapping (incl. Mac Catalyst) and the swap at both the top-level and PackageFrameworks layouts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ec393ad commit 5e4e759

5 files changed

Lines changed: 466 additions & 2 deletions

File tree

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ const VALID_ACTIONS = new Set([
123123
'codegen',
124124
'download',
125125
'scaffold',
126+
'swap-flavor',
126127
]);
127128

128129
/*::
@@ -297,7 +298,7 @@ function promptYesNo(
297298
function resolveAction(
298299
requestedAction /*: SetupArgs['action'] */,
299300
appRoot /*: string */,
300-
) /*: 'add' | 'update' | 'deinit' | 'sync' | 'codegen' | 'download' | 'scaffold' */ {
301+
) /*: 'add' | 'update' | 'deinit' | 'sync' | 'codegen' | 'download' | 'scaffold' | 'swap-flavor' */ {
301302
if (requestedAction != null) {
302303
return requestedAction;
303304
}
@@ -996,6 +997,28 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
996997
log(`Project root (package.json): ${displayPath(projectRoot)}`);
997998
}
998999

1000+
// Build-time flavor swap (invoked by the generated build phase). Kept early
1001+
// and dependency-free — no version resolution, no network — because it runs
1002+
// on EVERY Xcode build. Reads the Xcode build env for the target flavor +
1003+
// products dir.
1004+
if (action === 'swap-flavor') {
1005+
const {swapFlavorFrameworks} = require('./spm/swap-flavor');
1006+
try {
1007+
swapFlavorFrameworks({
1008+
appRoot,
1009+
configuration: process.env.CONFIGURATION,
1010+
builtProductsDir: process.env.BUILT_PRODUCTS_DIR,
1011+
platformName: process.env.PLATFORM_NAME,
1012+
isMacCatalyst: process.env.IS_MACCATALYST === 'YES',
1013+
logger: {log},
1014+
});
1015+
} catch (e) {
1016+
// Non-fatal: a flavor-swap failure must not break the build.
1017+
logError(`swap-flavor failed: ${e.message}`);
1018+
}
1019+
return;
1020+
}
1021+
9991022
if (action === 'deinit') {
10001023
const xcodeprojPath =
10011024
args.xcodeprojPath != null
@@ -1190,6 +1213,28 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
11901213
process.exitCode = 1;
11911214
return;
11921215
}
1216+
1217+
// Pre-fetch the OTHER flavor's slot so the build-time `swap-flavor` phase
1218+
// can switch debug↔release without touching the network (mirrors CocoaPods
1219+
// downloading both flavors up front). Best-effort: a Debug-only workflow
1220+
// still builds if the release slot can't be fetched now — the swap just
1221+
// logs and leaves the resolved flavor in place. Honors --download skip.
1222+
if (action === 'add' || action === 'update') {
1223+
const otherFlavor = args.flavor === 'release' ? 'debug' : 'release';
1224+
try {
1225+
const otherArgs = {...args, flavor: otherFlavor};
1226+
await ensureArtifacts(
1227+
otherArgs,
1228+
version,
1229+
prepareLocalXcframeworkArtifacts(otherArgs, appRoot, version),
1230+
);
1231+
} catch (e) {
1232+
logError(
1233+
`Could not pre-fetch the ${otherFlavor} flavor for build-time swapping (${e.message}). ` +
1234+
`A ${otherFlavor}-configuration build will need it — re-run with network, or \`spm download --flavor ${otherFlavor}\`.`,
1235+
);
1236+
}
1237+
}
11931238
} else {
11941239
log(`Remote ReactNative package: ${remote.url} @ ${remote.version}`);
11951240
}

packages/react-native/scripts/spm/__doc__/spm-scripts.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ accepts kebab-case equivalents (e.g. `--skip-codegen`).
130130
| Option | Description |
131131
|---|---|
132132
| `--version <ver>` | RN version (default: from package.json) |
133-
| `--flavor <debug\|release>` | Artifact flavor (default: debug) |
133+
| `--flavor <debug\|release>` | Initial artifact flavor to resolve (default: debug). Both flavors are fetched at `add`; the embedded flavor then auto-matches the Xcode build configuration — see below |
134134
| `--yes` | Skip the dirty-pbxproj confirmation prompt |
135135
| `--xcodeproj <path>` | [add] Which `.xcodeproj` to inject into (when several exist) |
136136
| `--productName <name>` | [add] Which app target to inject into (when several exist) |
@@ -139,6 +139,24 @@ accepts kebab-case equivalents (e.g. `--skip-codegen`).
139139
| `--download <auto\|skip\|force>` | [advanced] Artifact download policy (default: auto) |
140140
| `--skipCodegen` | [advanced] Skip the codegen step |
141141

142+
### Debug/Release flavor is automatic
143+
144+
React Native ships **flavored** prebuilt binaries: the *debug* `React.framework`
145+
(and `hermesvm` / `ReactNativeDependencies`) carry the dev experience — dev menu,
146+
assertions, `RN_DEBUG_STRING_CONVERTIBLE` — while *release* strips them for
147+
production. A Debug build must embed the debug binaries and a Release/archive the
148+
release ones.
149+
150+
SwiftPM `binaryTarget`s can't branch on the build configuration, so — mirroring
151+
the CocoaPods `React-Core-prebuilt` swap (`replace-rncore-version.js`) — `spm add`
152+
downloads **both** flavors, and a generated build phase (`react-native spm
153+
swap-flavor`) overwrites the SwiftPM-copied `React` / `ReactNativeDependencies` /
154+
`hermesvm` frameworks in `BUILT_PRODUCTS_DIR` with the slice matching
155+
`$CONFIGURATION`, before Link. It runs on every build, is idempotent, and works
156+
under both Xcode and `xcodebuild`. No manual step: build Debug → debug binaries,
157+
build Release/archive → release binaries. (`--flavor` only sets which flavor is
158+
resolved first; the swap corrects the embedded binaries per build.)
159+
142160
## What to commit
143161

144162
| Path | Commit? | Why |
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @format
8+
* @noflow
9+
*/
10+
11+
'use strict';
12+
13+
const {matchSlice, swapFlavorFrameworks} = require('../swap-flavor');
14+
const fs = require('fs');
15+
const os = require('os');
16+
const path = require('path');
17+
const plist = require('plist');
18+
19+
// Build a minimal <name>.xcframework with the given slices, each carrying a
20+
// <name>.framework whose binary contains `content` (to assert what got copied).
21+
function mkXcframework(dir, name, slices, content) {
22+
fs.mkdirSync(dir, {recursive: true});
23+
const available = slices.map(s => ({
24+
LibraryIdentifier: s.id,
25+
LibraryPath: `${name}.framework`,
26+
SupportedPlatform: s.platform,
27+
SupportedArchitectures: ['arm64'],
28+
...(s.variant ? {SupportedPlatformVariant: s.variant} : {}),
29+
}));
30+
fs.writeFileSync(
31+
path.join(dir, 'Info.plist'),
32+
plist.build({
33+
AvailableLibraries: available,
34+
CFBundlePackageType: 'XFWK',
35+
XCFrameworkFormatVersion: '1.0',
36+
}),
37+
);
38+
for (const s of slices) {
39+
const fw = path.join(dir, s.id, `${name}.framework`);
40+
fs.mkdirSync(fw, {recursive: true});
41+
fs.writeFileSync(path.join(fw, name), content);
42+
}
43+
}
44+
45+
describe('matchSlice', () => {
46+
let tmp, xcfw;
47+
beforeEach(() => {
48+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'swap-match-'));
49+
xcfw = path.join(tmp, 'React.xcframework');
50+
mkXcframework(
51+
xcfw,
52+
'React',
53+
[
54+
{id: 'ios-arm64', platform: 'ios'},
55+
{
56+
id: 'ios-arm64_x86_64-simulator',
57+
platform: 'ios',
58+
variant: 'simulator',
59+
},
60+
{
61+
id: 'ios-arm64_x86_64-maccatalyst',
62+
platform: 'ios',
63+
variant: 'maccatalyst',
64+
},
65+
{id: 'tvos-arm64', platform: 'tvos'},
66+
],
67+
'x',
68+
);
69+
});
70+
afterEach(() => fs.rmSync(tmp, {recursive: true, force: true}));
71+
72+
it('maps iphonesimulator to the ios+simulator slice', () => {
73+
expect(matchSlice(xcfw, 'iphonesimulator', false)).toBe(
74+
'ios-arm64_x86_64-simulator',
75+
);
76+
});
77+
it('maps iphoneos to the ios (no-variant) slice', () => {
78+
expect(matchSlice(xcfw, 'iphoneos', false)).toBe('ios-arm64');
79+
});
80+
it('maps Mac Catalyst explicitly (macosx PLATFORM_NAME + variant)', () => {
81+
expect(matchSlice(xcfw, 'macosx', true)).toBe(
82+
'ios-arm64_x86_64-maccatalyst',
83+
);
84+
});
85+
it('returns null for an unknown platform', () => {
86+
expect(matchSlice(xcfw, 'watchos', false)).toBeNull();
87+
});
88+
});
89+
90+
describe('swapFlavorFrameworks', () => {
91+
let tmp, appRoot, builtProducts;
92+
const read = p => fs.readFileSync(p, 'utf8');
93+
94+
beforeEach(() => {
95+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'swap-fw-'));
96+
// Cache slots: debug + release, each with a React.framework binary tagged
97+
// by flavor so we can assert which one landed.
98+
for (const flavor of ['debug', 'release']) {
99+
mkXcframework(
100+
path.join(tmp, 'cache', '1.0', flavor, 'React.xcframework'),
101+
'React',
102+
[
103+
{
104+
id: 'ios-arm64_x86_64-simulator',
105+
platform: 'ios',
106+
variant: 'simulator',
107+
},
108+
],
109+
`REACT-${flavor}`,
110+
);
111+
}
112+
// App: build/xcframeworks/React.xcframework -> the DEBUG slot (as `spm add`
113+
// --flavor debug would leave it).
114+
appRoot = path.join(tmp, 'app');
115+
const links = path.join(appRoot, 'build', 'xcframeworks');
116+
fs.mkdirSync(links, {recursive: true});
117+
fs.symlinkSync(
118+
path.join(tmp, 'cache', '1.0', 'debug', 'React.xcframework'),
119+
path.join(links, 'React.xcframework'),
120+
);
121+
// SPM-copied (debug) framework in PackageFrameworks.
122+
builtProducts = path.join(tmp, 'products');
123+
const pf = path.join(builtProducts, 'PackageFrameworks', 'React.framework');
124+
fs.mkdirSync(pf, {recursive: true});
125+
fs.writeFileSync(path.join(pf, 'React'), 'REACT-debug');
126+
});
127+
afterEach(() => fs.rmSync(tmp, {recursive: true, force: true}));
128+
129+
const embedded = () =>
130+
read(
131+
path.join(builtProducts, 'PackageFrameworks', 'React.framework', 'React'),
132+
);
133+
134+
it('swaps the copied debug framework to release for a Release build', () => {
135+
swapFlavorFrameworks({
136+
appRoot,
137+
configuration: 'Release',
138+
builtProductsDir: builtProducts,
139+
platformName: 'iphonesimulator',
140+
});
141+
expect(embedded()).toBe('REACT-release');
142+
});
143+
144+
it('leaves debug in place for a Debug build (idempotent)', () => {
145+
swapFlavorFrameworks({
146+
appRoot,
147+
configuration: 'Debug',
148+
builtProductsDir: builtProducts,
149+
platformName: 'iphonesimulator',
150+
});
151+
expect(embedded()).toBe('REACT-debug');
152+
});
153+
154+
it('swaps a framework at the BUILT_PRODUCTS_DIR top level (real SPM binaryTarget layout)', () => {
155+
// SPM copies binaryTarget frameworks to <BUILT_PRODUCTS_DIR>/<F>.framework,
156+
// not PackageFrameworks/. Put it there and confirm the swap finds it.
157+
const topFw = path.join(builtProducts, 'React.framework');
158+
fs.mkdirSync(topFw, {recursive: true});
159+
fs.writeFileSync(path.join(topFw, 'React'), 'REACT-debug');
160+
swapFlavorFrameworks({
161+
appRoot,
162+
configuration: 'Release',
163+
builtProductsDir: builtProducts,
164+
platformName: 'iphonesimulator',
165+
});
166+
expect(read(path.join(topFw, 'React'))).toBe('REACT-release');
167+
});
168+
169+
it('no-ops when the products dir does not exist (pre-action)', () => {
170+
expect(() =>
171+
swapFlavorFrameworks({
172+
appRoot,
173+
configuration: 'Release',
174+
builtProductsDir: path.join(tmp, 'nope'),
175+
platformName: 'iphonesimulator',
176+
}),
177+
).not.toThrow();
178+
expect(embedded()).toBe('REACT-debug');
179+
});
180+
});

packages/react-native/scripts/spm/generate-spm-xcodeproj.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,21 @@ function buildSyncAutolinkingScript(
260260
) /*: string */ {
261261
return `set -euo pipefail
262262
263+
# Swap the flavored framework binaries (React / ReactNativeDependencies /
264+
# hermesvm) to match the Xcode build configuration. SwiftPM can't branch a
265+
# binaryTarget on \$CONFIGURATION, so — mirroring the CocoaPods
266+
# React-Core-prebuilt swap (replace-rncore-version.js) — we overwrite the
267+
# SPM-copied frameworks in \$BUILT_PRODUCTS_DIR/PackageFrameworks with the
268+
# correct-flavor slices, before Link. Runs on EVERY build (a config flip changes
269+
# no sync input) and is timing-robust: it operates on the ALREADY-copied
270+
# frameworks, so it doesn't race SPM package resolution. A no-op in the scheme
271+
# pre-action (\$BUILT_PRODUCTS_DIR unset / nothing copied yet) and when already
272+
# the right flavor. Non-fatal.
273+
if command -v npx >/dev/null 2>&1 && [ -n "\${BUILT_PRODUCTS_DIR:-}" ]; then
274+
( cd "$SRCROOT" && npx react-native spm swap-flavor ) \\
275+
|| echo "warning: 'react-native spm swap-flavor' failed; build may link the add-time flavor"
276+
fi
277+
263278
STAMP="$SRCROOT/build/generated/autolinking/.spm-sync-stamp"
264279
STALE=0
265280

0 commit comments

Comments
 (0)