Skip to content

Commit afdfbd7

Browse files
coadofacebook-github-bot
authored andcommitted
Add Swift Package Manager opt-in for RN_STRICT_API
Summary: Let iOS apps that use the Swift Package Manager setup enforce React Native's C++ stable API, matching the CocoaPods and Android opt-ins. Setting `RCT_STRICT_CXX_API=1` when running `npx react-native spm add` or `update` defines `RN_STRICT_API` in: - the app target (`GCC_PREPROCESSOR_DEFINITIONS`, seeded with `$(inherited)`), - every package the autolinker generates (inline `spm.modules` targets and the synthesized per-dependency packages), - the app codegen package (`ReactCodegen`, `ReactAppDependencyProvider`). React Native itself ships prebuilt in this setup, so none of its code is compiled with the flag. The Xcode build phase runs `spm sync` without the shell environment, so `add`/`update` record the choice in `.spm-injected.json` and every later sync regenerates the packages from that record. As with `pod install`, each `add`/`update` re-reads the variable: running `update` without it turns enforcement off, and `deinit` removes the app-target define along with the rest of the injection. Libraries that ship their own `Package.swift`, upstream or created with `spm scaffold`, are referenced as-is and keep their own settings. Changelog: [iOS][Added] - Add `RCT_STRICT_CXX_API` opt-in for the Swift Package Manager setup to enforce React Native's C++ stable API in app and library native code Differential Revision: D124985657
1 parent da7c29f commit afdfbd7

12 files changed

Lines changed: 425 additions & 13 deletions

‎packages/react-native/scripts/codegen/templates/Package.swift.spm-template‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ let package = Package(
6161
.headerSearchPath("headers"),
6262
.define("DEBUG", .when(configuration: .debug)),
6363
.define("NDEBUG", .when(configuration: .release)),
64+
// Generated code: nobody can act on a C++ stable API "for
65+
// frameworks" warning here. Libraries are held to the API in
66+
// their own packages.
67+
.define("RN_ALLOW_FRAMEWORKS"),
6468
],
6569
linkerSettings: [
6670
.linkedFramework("Foundation")
@@ -79,6 +83,8 @@ let package = Package(
7983
// Match the prebuilt React.framework NDEBUG-gated C++ ABI (see above).
8084
.define("DEBUG", .when(configuration: .debug)),
8185
.define("NDEBUG", .when(configuration: .release)),
86+
// Generated code (see ReactCodegen above).
87+
.define("RN_ALLOW_FRAMEWORKS"),
8288
],
8389
linkerSettings: [
8490
.linkedFramework("Foundation")

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

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@
5959
* [advanced] --skip-codegen Skip the react-native codegen step.
6060
* [advanced] --config-command <json> Override the autolinking config command.
6161
*
62+
* Environment ([add/update]; recorded in .spm-injected.json so Xcode builds
63+
* reuse them, re-run `update` without one to turn it off):
64+
* RN_STRICT_API=1 Enforce React Native's C++ stable API: defines
65+
* the RN_STRICT_API macro for the app target, its
66+
* spm.modules packages and app codegen.
67+
* RN_ALLOW_FRAMEWORKS=1 Silence the stable API's "for frameworks"
68+
* warning there: defines RN_ALLOW_FRAMEWORKS.
69+
*
6270
* Steps performed (add/update):
6371
* 1. react-native codegen → build/generated/ios/ + install SPM codegen template
6472
* 2. generate-spm-autolinking-config.js → build/generated/autolinking/autolinking.json
@@ -99,6 +107,7 @@ const {
99107
findInjectedXcodeproj,
100108
injectSpmIntoExistingXcodeproj,
101109
readArtifactsVersionOverride,
110+
readCxxApiDefines,
102111
readMarker,
103112
readPinnedConfigCommand,
104113
removeSpmInjection,
@@ -112,6 +121,7 @@ const {
112121
scaffoldAll,
113122
} = require('./spm/scaffold-package-swift');
114123
const {
124+
CXX_API_DEFINES,
115125
RemoteVersionError,
116126
buildPerAppHeaderTree,
117127
defaultCacheDir,
@@ -420,17 +430,24 @@ function runCodegenStep(
420430
appRoot /*: string */,
421431
reactNativeRoot /*: string */,
422432
skipCodegen /*: boolean */,
433+
cxxApiDefines /*: ReadonlyArray<string> */,
423434
) /*: void */ {
424435
if (skipCodegen) {
425436
// Output dir may already exist from a previous run; still refresh the
426437
// SPM template so cache-slot changes propagate.
427438
log('Skipping codegen (--skip-codegen)');
428-
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log});
439+
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}, {cxxApiDefines});
429440
return;
430441
}
431442
log('Running react-native codegen...');
432443
try {
433-
runCodegenAndInstallTemplate(projectRoot, appRoot, reactNativeRoot, {log});
444+
runCodegenAndInstallTemplate(
445+
projectRoot,
446+
appRoot,
447+
reactNativeRoot,
448+
{log},
449+
{cxxApiDefines},
450+
);
434451
} catch {
435452
logError('Codegen failed. Continuing anyway...');
436453
}
@@ -832,6 +849,7 @@ async function setupXcodeproj(
832849
appRoot /*: string */,
833850
reactNativeRoot /*: string */,
834851
action /*: string */,
852+
cxxApiDefines /*: ReadonlyArray<string> */,
835853
) /*: Promise<void> */ {
836854
const target = resolveInjectionTarget(args, appRoot);
837855
if (target.error != null) {
@@ -909,6 +927,7 @@ async function setupXcodeproj(
909927
// generate-spm-xcodeproj.js).
910928
artifactsVersionOverride: args.version ?? null,
911929
configCommand: resolveConfigCommandToPin(args),
930+
cxxApiDefines,
912931
});
913932
if (result.status !== 'injected') {
914933
logError(`SPM injection failed: ${result.reason}`);
@@ -968,6 +987,22 @@ function logNextSteps(
968987
log('To remove SPM later: `npx react-native spm deinit`');
969988
}
970989

990+
// The React Native C++ stable API macros this run defines for the app target,
991+
// its spm.modules packages and app codegen (CXX_API_DEFINES). `add`/`update`
992+
// read each from the env var of the same name (=1; unset turns it off, as with
993+
// `pod install`) and record the result in the injection marker; every other
994+
// action, including the build-time `sync`, reuses the recorded macros because
995+
// Xcode builds inherit neither the env vars nor the shell that set them.
996+
function resolveCxxApiDefines(
997+
action /*: string */,
998+
appRoot /*: string */,
999+
) /*: Array<string> */ {
1000+
if (action === 'add' || action === 'update') {
1001+
return CXX_API_DEFINES.filter(name => process.env[name] === '1');
1002+
}
1003+
return readCxxApiDefines(appRoot);
1004+
}
1005+
9711006
// The autolinking config command for this run: an explicit `--config-command`
9721007
// first, then the value a previous `add`/`update` pinned into the injection
9731008
// marker. undefined means "no explicit command", which is what makes
@@ -1160,6 +1195,18 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
11601195
const version = determineVersion(args, reactNativeRoot, appRoot);
11611196
log(`React Native version: ${version}`);
11621197

1198+
const cxxApiDefines = resolveCxxApiDefines(action, appRoot);
1199+
if (cxxApiDefines.length > 0) {
1200+
log(
1201+
`C++ stable API: defining ${cxxApiDefines.join(', ')} in the app ` +
1202+
'target, its spm.modules packages and app codegen',
1203+
);
1204+
}
1205+
const cxxApiDefineArgs = cxxApiDefines.flatMap(name => [
1206+
'--cxx-api-define',
1207+
name,
1208+
]);
1209+
11631210
// Resolve remote SPM mode ONCE up front. remotePackageConfig throws
11641211
// RemoteVersionError when remote mode is active but no usable RN version can
11651212
// be derived (e.g. the monorepo '1000.0.0' placeholder with no override).
@@ -1184,7 +1231,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
11841231
// "Artifacts already present in ...".
11851232

11861233
if (action === 'codegen') {
1187-
runCodegenStep(projectRoot, appRoot, reactNativeRoot, false);
1234+
runCodegenStep(projectRoot, appRoot, reactNativeRoot, false, cxxApiDefines);
11881235
return;
11891236
}
11901237

@@ -1198,6 +1245,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
11981245
reactNativeRoot,
11991246
'--ios-deployment-target',
12001247
iosDeploymentTarget,
1248+
...cxxApiDefineArgs,
12011249
]);
12021250
} catch (e) {
12031251
if (e instanceof MissingManifestError) {
@@ -1240,7 +1288,13 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
12401288
);
12411289
}
12421290

1243-
runCodegenStep(projectRoot, appRoot, reactNativeRoot, args.skipCodegen);
1291+
runCodegenStep(
1292+
projectRoot,
1293+
appRoot,
1294+
reactNativeRoot,
1295+
args.skipCodegen,
1296+
cxxApiDefines,
1297+
);
12441298

12451299
// A manifest scaffolded before the app's deployment target changed (or by a
12461300
// pre-v20 scaffolder) would pin a floor SwiftPM then refuses to link against.
@@ -1269,6 +1323,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
12691323
reactNativeRoot,
12701324
'--ios-deployment-target',
12711325
iosDeploymentTarget,
1326+
...cxxApiDefineArgs,
12721327
]);
12731328
} catch (e) {
12741329
if (e instanceof MissingManifestError) {
@@ -1310,7 +1365,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
13101365
}
13111366

13121367
// (Re)install the static codegen Package.swift template once build/generated/ios exists.
1313-
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log});
1368+
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}, {cxxApiDefines});
13141369

13151370
// Build the per-app generated-headers farm (vended as the ReactAppHeaders
13161371
// SPM target inside the codegen package). React core headers need no trees
@@ -1327,7 +1382,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
13271382
// Xcodeproj setup: in-place injection into the existing project (the only
13281383
// strategy — no rename, no from-scratch; git is the safety net).
13291384
try {
1330-
await setupXcodeproj(args, appRoot, reactNativeRoot, action);
1385+
await setupXcodeproj(args, appRoot, reactNativeRoot, action, cxxApiDefines);
13311386
} catch (e) {
13321387
logError(`xcodeproj setup failed: ${e.message}`);
13331388
if (process.exitCode == null) {
@@ -1356,6 +1411,7 @@ module.exports = {
13561411
resolveAppIosDeploymentTarget,
13571412
resolveConfigCommandToPin,
13581413
resolveExplicitConfigCommand,
1414+
resolveCxxApiDefines,
13591415
shouldAutoDeintegrate,
13601416
ensureBothArtifactFlavors,
13611417
};

‎packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,25 @@ describe('generateSynthPackageSwift', () => {
283283
);
284284
});
285285

286+
it('defines its C++ API macros once each, next to the config-gated ABI defines', () => {
287+
expect(generateSynthPackageSwift(baseSpec())).not.toMatch(
288+
/RN_(STRICT_API|ALLOW_FRAMEWORKS)/,
289+
);
290+
const result = generateSynthPackageSwift(
291+
baseSpec({
292+
cxxApiDefines: [
293+
'RN_STRICT_API',
294+
'RN_ALLOW_FRAMEWORKS',
295+
'RN_ALLOW_FRAMEWORKS',
296+
],
297+
}),
298+
);
299+
expect(result).toMatch(
300+
/cxxSettings: \[[^\]]*\.define\("NDEBUG", \.when\(configuration: \.release\)\), \.define\("RN_STRICT_API"\), \.define\("RN_ALLOW_FRAMEWORKS"\)[,\]]/,
301+
);
302+
expect(result.match(/\.define\("RN_ALLOW_FRAMEWORKS"\)/g)).toHaveLength(1);
303+
});
304+
286305
it('depends on ReactNative via a fixed relative path (default synth depth)', () => {
287306
const result = generateSynthPackageSwift(baseSpec({hasReactDep: true}));
288307
expect(result).toContain(
@@ -538,6 +557,27 @@ describe('main() — --ios-deployment-target', () => {
538557
return {appRoot, rnRoot, autolinkDir};
539558
}
540559

560+
it("defines the app's C++ API macros (--cxx-api-define) in the synth manifest", () => {
561+
const {appRoot, rnRoot, autolinkDir} = buildApp();
562+
main([
563+
'--app-root',
564+
appRoot,
565+
'--react-native-root',
566+
rnRoot,
567+
'--cxx-api-define',
568+
'RN_STRICT_API',
569+
'--cxx-api-define',
570+
'RN_ALLOW_FRAMEWORKS',
571+
]);
572+
const synth = fs.readFileSync(
573+
path.join(autolinkDir, 'packages', 'MyNativeModule', 'Package.swift'),
574+
'utf8',
575+
);
576+
expect(synth).toMatch(
577+
/cxxSettings: \[[^\]]*\.define\("RN_STRICT_API"\), \.define\("RN_ALLOW_FRAMEWORKS"\)/,
578+
);
579+
});
580+
541581
it('sanitizes the flag into both the aggregator and the synth manifest', () => {
542582
const {appRoot, rnRoot, autolinkDir} = buildApp();
543583
main([

‎packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const {
1414
SPM_INJECTED_MARKER,
1515
injectSpmIntoExistingXcodeproj,
1616
readArtifactsVersionOverride,
17+
readCxxApiDefines,
1718
readPinnedConfigCommand,
1819
removeSpmInjection,
1920
} = require('../generate-spm-xcodeproj');
@@ -212,6 +213,52 @@ describe('injectSpmIntoExistingXcodeproj — HERMES_CLI_PATH', () => {
212213
});
213214
});
214215

216+
describe('injectSpmIntoExistingXcodeproj — C++ stable API defines', () => {
217+
const BOTH = ['RN_STRICT_API', 'RN_ALLOW_FRAMEWORKS'];
218+
const inject = (ctx, cxxApiDefines) =>
219+
injectSpmIntoExistingXcodeproj({
220+
appRoot: ctx.appRoot,
221+
reactNativeRoot: ctx.rnRoot,
222+
xcodeprojPath: ctx.xcodeprojPath,
223+
cxxApiDefines,
224+
});
225+
226+
it('defines the opted-in macros in every app-target config and records them', () => {
227+
const ctx = scaffoldApp();
228+
expect(inject(ctx, BOTH).status).toBe('injected');
229+
// Debug + Release, each seeded with $(inherited) so project-level
230+
// definitions (DEBUG=1) still apply.
231+
expect(
232+
pbxprojOf(ctx.xcodeprojPath).match(
233+
/GCC_PREPROCESSOR_DEFINITIONS = \(\s*"\$\(inherited\)",\s*RN_STRICT_API,\s*RN_ALLOW_FRAMEWORKS,\s*\);/g,
234+
),
235+
).toHaveLength(2);
236+
expect(readCxxApiDefines(ctx.appRoot)).toEqual(BOTH);
237+
});
238+
239+
it('removes them on an update without the opt-ins', () => {
240+
const ctx = scaffoldApp();
241+
inject(ctx, BOTH);
242+
inject(ctx, []);
243+
expect(pbxprojOf(ctx.xcodeprojPath)).not.toContain('RN_STRICT_API');
244+
expect(pbxprojOf(ctx.xcodeprojPath)).not.toContain('RN_ALLOW_FRAMEWORKS');
245+
expect(readCxxApiDefines(ctx.appRoot)).toEqual([]);
246+
});
247+
248+
it('round-trips add (opted in) → deinit byte-for-byte', () => {
249+
const ctx = scaffoldApp();
250+
const before = pbxprojOf(ctx.xcodeprojPath);
251+
inject(ctx, BOTH);
252+
expect(
253+
removeSpmInjection({
254+
appRoot: ctx.appRoot,
255+
xcodeprojPath: ctx.xcodeprojPath,
256+
}).status,
257+
).toBe('removed');
258+
expect(pbxprojOf(ctx.xcodeprojPath)).toBe(before);
259+
});
260+
});
261+
215262
describe('removeSpmInjection — the surgical inverse of add', () => {
216263
it('round-trips: add then deinit restores the pbxproj byte-for-byte', () => {
217264
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();

‎packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const {
2020
resolveAction,
2121
resolveAppIosDeploymentTarget,
2222
resolveConfigCommandToPin,
23+
resolveCxxApiDefines,
2324
resolveExplicitConfigCommand,
2425
shouldAutoDeintegrate,
2526
} = require('../../setup-apple-spm');
@@ -175,6 +176,60 @@ describe('generateAutolinkingConfigOrFailClosed', () => {
175176
// the env var or the default".
176177
// ---------------------------------------------------------------------------
177178

179+
describe('resolveCxxApiDefines', () => {
180+
const ENV_VARS = ['RN_STRICT_API', 'RN_ALLOW_FRAMEWORKS'];
181+
let tempDir;
182+
let prevEnv;
183+
184+
beforeEach(() => {
185+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-cxx-api-'));
186+
prevEnv = ENV_VARS.map(name => process.env[name]);
187+
ENV_VARS.forEach(name => delete process.env[name]);
188+
});
189+
190+
afterEach(() => {
191+
fs.rmSync(tempDir, {recursive: true, force: true});
192+
ENV_VARS.forEach((name, i) => {
193+
if (prevEnv[i] === undefined) {
194+
delete process.env[name];
195+
} else {
196+
process.env[name] = prevEnv[i];
197+
}
198+
});
199+
});
200+
201+
it('defines, on add/update, each opt-in whose env var is 1, whatever was recorded', () => {
202+
mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', {
203+
cxxApiDefines: ['RN_STRICT_API', 'RN_ALLOW_FRAMEWORKS'],
204+
});
205+
process.env.RN_STRICT_API = '1';
206+
process.env.RN_ALLOW_FRAMEWORKS = 'true';
207+
expect(resolveCxxApiDefines('add', tempDir)).toEqual(['RN_STRICT_API']);
208+
delete process.env.RN_STRICT_API;
209+
process.env.RN_ALLOW_FRAMEWORKS = '1';
210+
expect(resolveCxxApiDefines('update', tempDir)).toEqual([
211+
'RN_ALLOW_FRAMEWORKS',
212+
]);
213+
});
214+
215+
it('reuses the recorded macros on sync/codegen, which Xcode runs without the shell env', () => {
216+
mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', {
217+
cxxApiDefines: ['RN_ALLOW_FRAMEWORKS', 'NOT_A_CXX_API_MACRO'],
218+
});
219+
process.env.RN_STRICT_API = '1';
220+
expect(resolveCxxApiDefines('sync', tempDir)).toEqual([
221+
'RN_ALLOW_FRAMEWORKS',
222+
]);
223+
expect(resolveCxxApiDefines('codegen', tempDir)).toEqual([
224+
'RN_ALLOW_FRAMEWORKS',
225+
]);
226+
});
227+
228+
it('defines nothing on sync before anything is injected', () => {
229+
expect(resolveCxxApiDefines('sync', tempDir)).toEqual([]);
230+
});
231+
});
232+
178233
describe('resolveExplicitConfigCommand', () => {
179234
const ENV = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND';
180235
const PINNED = ['npx', 'expo-modules-autolinking', 'react-native-config'];

0 commit comments

Comments
 (0)