Skip to content

Commit 47f744d

Browse files
coadometa-codesync[bot]
authored andcommitted
Add Swift Package Manager opt-ins for RN_STRICT_API and RN_ALLOW_FRAMEWORKS (#59043)
Summary: Pull Request resolved: #59043 Let iOS apps that use the Swift Package Manager setup enforce React Native's C++ stable API, matching the CocoaPods and Android opt-ins. Two env vars are read by `npx react-native spm add` / `update`: - `RN_STRICT_API=1` defines the `RN_STRICT_API` macro, which arms the `react/cxxstableapi` guards. - `RN_ALLOW_FRAMEWORKS=1` defines the `RN_ALLOW_FRAMEWORKS` macro, which silences their "for frameworks" warning. Each variable is named after the macro it defines, as in the CocoaPods opt-in. Each opted-in macro is defined for the app's own code: - the app target (`GCC_PREPROCESSOR_DEFINITIONS`, seeded with `$(inherited)`), - the packages the autolinker generates for the app's local `spm.modules`, - the app codegen package (`ReactCodegen`, `ReactAppDependencyProvider`). Generated code never warns: the codegen package always defines `RN_ALLOW_FRAMEWORKS`, because nobody can act on a warning in generated code and libraries are held to the API in their own builds. Under SwiftPM every library builds from its own `Package.swift`, so a library declares `RN_ALLOW_FRAMEWORKS` there, and the app's opt-ins do not reach libraries. React Native itself ships prebuilt, so none of its code is compiled with these macros. The Xcode build phase runs `spm sync` without the shell environment, so `add`/`update` record the chosen macros in `.spm-injected.json` and every later sync regenerates the packages from that record. As with `pod install`, each `add`/`update` re-reads the variables: running `update` without one turns its macro off, and `deinit` removes the app-target defines along with the rest of the injection. Changelog: [iOS][Added] - Add `RN_STRICT_API` and `RN_ALLOW_FRAMEWORKS` opt-ins for the Swift Package Manager setup to enforce React Native's C++ stable API in the app's native code Reviewed By: cipolleschi Differential Revision: D124985657 fbshipit-source-id: 5ec66f94d5e3bb9e75fa305487cb39d2bd7e9d95
1 parent b97bd12 commit 47f744d

13 files changed

Lines changed: 450 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
@@ -60,6 +60,14 @@
6060
* [advanced] --skip-codegen Skip the react-native codegen step.
6161
* [advanced] --config-command <json> Override the autolinking config command.
6262
*
63+
* Environment ([add/update]; recorded in .spm-injected.json so Xcode builds
64+
* reuse them, re-run `update` without one to turn it off):
65+
* RN_STRICT_API=1 Enforce React Native's C++ stable API: defines
66+
* the RN_STRICT_API macro for the app target, its
67+
* spm.modules packages and app codegen.
68+
* RN_ALLOW_FRAMEWORKS=1 Silence the stable API's "for frameworks"
69+
* warning there: defines RN_ALLOW_FRAMEWORKS.
70+
*
6371
* Steps performed (add/update):
6472
* 1. react-native codegen → build/generated/ios/ + install SPM codegen template
6573
* 2. generate-spm-autolinking-config.js → build/generated/autolinking/autolinking.json
@@ -101,6 +109,7 @@ const {
101109
findInjectedXcodeproj,
102110
injectSpmIntoExistingXcodeproj,
103111
readArtifactsVersionOverride,
112+
readCxxApiDefines,
104113
readMarker,
105114
readPinnedConfigCommand,
106115
removeSpmInjection,
@@ -114,6 +123,7 @@ const {
114123
scaffoldAll,
115124
} = require('./spm/scaffold-package-swift');
116125
const {
126+
CXX_API_DEFINES,
117127
RemoteVersionError,
118128
buildPerAppHeaderTree,
119129
defaultCacheDir,
@@ -422,17 +432,24 @@ function runCodegenStep(
422432
appRoot /*: string */,
423433
reactNativeRoot /*: string */,
424434
skipCodegen /*: boolean */,
435+
cxxApiDefines /*: ReadonlyArray<string> */,
425436
) /*: void */ {
426437
if (skipCodegen) {
427438
// Output dir may already exist from a previous run; still refresh the
428439
// SPM template so cache-slot changes propagate.
429440
log('Skipping codegen (--skip-codegen)');
430-
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log});
441+
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}, {cxxApiDefines});
431442
return;
432443
}
433444
log('Running react-native codegen...');
434445
try {
435-
runCodegenAndInstallTemplate(projectRoot, appRoot, reactNativeRoot, {log});
446+
runCodegenAndInstallTemplate(
447+
projectRoot,
448+
appRoot,
449+
reactNativeRoot,
450+
{log},
451+
{cxxApiDefines},
452+
);
436453
} catch {
437454
logError('Codegen failed. Continuing anyway...');
438455
}
@@ -834,6 +851,7 @@ async function setupXcodeproj(
834851
appRoot /*: string */,
835852
reactNativeRoot /*: string */,
836853
action /*: string */,
854+
cxxApiDefines /*: ReadonlyArray<string> */,
837855
) /*: Promise<void> */ {
838856
const target = resolveInjectionTarget(args, appRoot);
839857
if (target.error != null) {
@@ -911,6 +929,7 @@ async function setupXcodeproj(
911929
// generate-spm-xcodeproj.js).
912930
artifactsVersionOverride: args.version ?? null,
913931
configCommand: resolveConfigCommandToPin(args),
932+
cxxApiDefines,
914933
});
915934
if (result.status !== 'injected') {
916935
logError(`SPM injection failed: ${result.reason}`);
@@ -970,6 +989,22 @@ function logNextSteps(
970989
log('To remove SPM later: `npx react-native spm deinit`');
971990
}
972991

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

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

11881235
if (action === 'codegen') {
1189-
runCodegenStep(projectRoot, appRoot, reactNativeRoot, false);
1236+
runCodegenStep(projectRoot, appRoot, reactNativeRoot, false, cxxApiDefines);
11901237
return;
11911238
}
11921239

@@ -1200,6 +1247,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
12001247
reactNativeRoot,
12011248
'--ios-deployment-target',
12021249
iosDeploymentTarget,
1250+
...cxxApiDefineArgs,
12031251
]);
12041252
} catch (e) {
12051253
if (
@@ -1245,7 +1293,13 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
12451293
);
12461294
}
12471295

1248-
runCodegenStep(projectRoot, appRoot, reactNativeRoot, args.skipCodegen);
1296+
runCodegenStep(
1297+
projectRoot,
1298+
appRoot,
1299+
reactNativeRoot,
1300+
args.skipCodegen,
1301+
cxxApiDefines,
1302+
);
12491303

12501304
// A manifest scaffolded before the app's deployment target changed (or by a
12511305
// pre-v20 scaffolder) would pin a floor SwiftPM then refuses to link against.
@@ -1274,6 +1328,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
12741328
reactNativeRoot,
12751329
'--ios-deployment-target',
12761330
iosDeploymentTarget,
1331+
...cxxApiDefineArgs,
12771332
]);
12781333
} catch (e) {
12791334
if (e instanceof MissingManifestError) {
@@ -1315,7 +1370,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
13151370
}
13161371

13171372
// (Re)install the static codegen Package.swift template once build/generated/ios exists.
1318-
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log});
1373+
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}, {cxxApiDefines});
13191374

13201375
// Build the per-app generated-headers farm (vended as the ReactAppHeaders
13211376
// SPM target inside the codegen package). React core headers need no trees
@@ -1332,7 +1387,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
13321387
// Xcodeproj setup: in-place injection into the existing project (the only
13331388
// strategy — no rename, no from-scratch; git is the safety net).
13341389
try {
1335-
await setupXcodeproj(args, appRoot, reactNativeRoot, action);
1390+
await setupXcodeproj(args, appRoot, reactNativeRoot, action, cxxApiDefines);
13361391
} catch (e) {
13371392
logError(`xcodeproj setup failed: ${e.message}`);
13381393
if (process.exitCode == null) {
@@ -1361,6 +1416,7 @@ module.exports = {
13611416
resolveAppIosDeploymentTarget,
13621417
resolveConfigCommandToPin,
13631418
resolveExplicitConfigCommand,
1419+
resolveCxxApiDefines,
13641420
shouldAutoDeintegrate,
13651421
ensureBothArtifactFlavors,
13661422
};

‎packages/react-native/scripts/spm/__docs__/spm-scripts.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,28 @@ deployment target in Xcode and re-run `react-native spm update`.
254254
scaffolded manifests. They never create new ones. If you persisted a scaffold
255255
with `patch-package`, re-run `npx patch-package <dep>` afterwards.
256256

257+
### C++ stable API (`RN_STRICT_API`)
258+
259+
`RN_STRICT_API=1` and `RN_ALLOW_FRAMEWORKS=1` on `add`/`update` turn on React
260+
Native's C++ stable API checks:
261+
262+
```bash
263+
RN_STRICT_API=1 npx react-native spm update
264+
```
265+
266+
Each one defines the macro of the same name for the app target
267+
(`GCC_PREPROCESSOR_DEFINITIONS`), the packages generated for your
268+
[local native modules](#local-native-modules), and the app codegen package
269+
(`build/generated/ios/Package.swift`), which also always defines
270+
`RN_ALLOW_FRAMEWORKS`. Libraries build from their own `Package.swift`, so they
271+
are only checked if they define the macros themselves.
272+
273+
The choice is recorded in `.spm-injected.json` (as `cxxApiDefines`), because the
274+
**Sync SPM Autolinking** build phase regenerates those packages on every build
275+
without your shell's environment. Unlike the pins above, every `add`/`update`
276+
reads the variables again: run `update` without one to turn it off. `deinit`
277+
removes the app-target definitions with the rest of the injection.
278+
257279
## Files the tool touches
258280

259281
Paths are relative to the Xcode project directory (`ios/`) unless noted.
@@ -272,6 +294,9 @@ Paths are relative to the Xcode project directory (`ios/`) unless noted.
272294
The two pins are the `--version` pin (`artifactsVersionOverride`, see
273295
[Pinning the React Native version](#pinning-the-react-native-version)) and the
274296
[autolinking config command](#the-autolinking-config-command-is-remembered).
297+
Because `deinit` drops the marker, it drops both. The marker also records the
298+
[C++ stable API](#c-stable-api-rn_strict_api) opt-ins (`cxxApiDefines`), but
299+
those are not pins: every `add`/`update` sets them from the environment again.
275300

276301
### In your repo — generated, gitignored
277302

‎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();

0 commit comments

Comments
 (0)