Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ let package = Package(
.headerSearchPath("headers"),
.define("DEBUG", .when(configuration: .debug)),
.define("NDEBUG", .when(configuration: .release)),
// Generated code: nobody can act on a C++ stable API "for
// frameworks" warning here. Libraries are held to the API in
// their own packages.
.define("RN_ALLOW_FRAMEWORKS"),
],
linkerSettings: [
.linkedFramework("Foundation")
Expand All @@ -79,6 +83,8 @@ let package = Package(
// Match the prebuilt React.framework NDEBUG-gated C++ ABI (see above).
.define("DEBUG", .when(configuration: .debug)),
.define("NDEBUG", .when(configuration: .release)),
// Generated code (see ReactCodegen above).
.define("RN_ALLOW_FRAMEWORKS"),
],
linkerSettings: [
.linkedFramework("Foundation")
Expand Down
68 changes: 62 additions & 6 deletions packages/react-native/scripts/setup-apple-spm.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@
* [advanced] --skip-codegen Skip the react-native codegen step.
* [advanced] --config-command <json> Override the autolinking config command.
*
* Environment ([add/update]; recorded in .spm-injected.json so Xcode builds
* reuse them, re-run `update` without one to turn it off):
* RN_STRICT_API=1 Enforce React Native's C++ stable API: defines
* the RN_STRICT_API macro for the app target, its
* spm.modules packages and app codegen.
* RN_ALLOW_FRAMEWORKS=1 Silence the stable API's "for frameworks"
* warning there: defines RN_ALLOW_FRAMEWORKS.
*
* Steps performed (add/update):
* 1. react-native codegen → build/generated/ios/ + install SPM codegen template
* 2. generate-spm-autolinking-config.js → build/generated/autolinking/autolinking.json
Expand Down Expand Up @@ -99,6 +107,7 @@ const {
findInjectedXcodeproj,
injectSpmIntoExistingXcodeproj,
readArtifactsVersionOverride,
readCxxApiDefines,
readMarker,
readPinnedConfigCommand,
removeSpmInjection,
Expand All @@ -112,6 +121,7 @@ const {
scaffoldAll,
} = require('./spm/scaffold-package-swift');
const {
CXX_API_DEFINES,
RemoteVersionError,
buildPerAppHeaderTree,
defaultCacheDir,
Expand Down Expand Up @@ -420,17 +430,24 @@ function runCodegenStep(
appRoot /*: string */,
reactNativeRoot /*: string */,
skipCodegen /*: boolean */,
cxxApiDefines /*: ReadonlyArray<string> */,
) /*: void */ {
if (skipCodegen) {
// Output dir may already exist from a previous run; still refresh the
// SPM template so cache-slot changes propagate.
log('Skipping codegen (--skip-codegen)');
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log});
installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}, {cxxApiDefines});
return;
}
log('Running react-native codegen...');
try {
runCodegenAndInstallTemplate(projectRoot, appRoot, reactNativeRoot, {log});
runCodegenAndInstallTemplate(
projectRoot,
appRoot,
reactNativeRoot,
{log},
{cxxApiDefines},
);
} catch {
logError('Codegen failed. Continuing anyway...');
}
Expand Down Expand Up @@ -832,6 +849,7 @@ async function setupXcodeproj(
appRoot /*: string */,
reactNativeRoot /*: string */,
action /*: string */,
cxxApiDefines /*: ReadonlyArray<string> */,
) /*: Promise<void> */ {
const target = resolveInjectionTarget(args, appRoot);
if (target.error != null) {
Expand Down Expand Up @@ -909,6 +927,7 @@ async function setupXcodeproj(
// generate-spm-xcodeproj.js).
artifactsVersionOverride: args.version ?? null,
configCommand: resolveConfigCommandToPin(args),
cxxApiDefines,
});
if (result.status !== 'injected') {
logError(`SPM injection failed: ${result.reason}`);
Expand Down Expand Up @@ -968,6 +987,22 @@ function logNextSteps(
log('To remove SPM later: `npx react-native spm deinit`');
}

// The React Native C++ stable API macros this run defines for the app target,
// its spm.modules packages and app codegen (CXX_API_DEFINES). `add`/`update`
// read each from the env var of the same name (=1; unset turns it off, as with
// `pod install`) and record the result in the injection marker; every other
// action, including the build-time `sync`, reuses the recorded macros because
// Xcode builds inherit neither the env vars nor the shell that set them.
function resolveCxxApiDefines(
action /*: string */,
appRoot /*: string */,
) /*: Array<string> */ {
if (action === 'add' || action === 'update') {
return CXX_API_DEFINES.filter(name => process.env[name] === '1');
}
return readCxxApiDefines(appRoot);
}

// The autolinking config command for this run: an explicit `--config-command`
// first, then the value a previous `add`/`update` pinned into the injection
// marker. undefined means "no explicit command", which is what makes
Expand Down Expand Up @@ -1160,6 +1195,18 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
const version = determineVersion(args, reactNativeRoot, appRoot);
log(`React Native version: ${version}`);

const cxxApiDefines = resolveCxxApiDefines(action, appRoot);
if (cxxApiDefines.length > 0) {
log(
`C++ stable API: defining ${cxxApiDefines.join(', ')} in the app ` +
'target, its spm.modules packages and app codegen',
);
}
const cxxApiDefineArgs = cxxApiDefines.flatMap(name => [
'--cxx-api-define',
name,
]);

// Resolve remote SPM mode ONCE up front. remotePackageConfig throws
// RemoteVersionError when remote mode is active but no usable RN version can
// be derived (e.g. the monorepo '1000.0.0' placeholder with no override).
Expand All @@ -1184,7 +1231,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
// "Artifacts already present in ...".

if (action === 'codegen') {
runCodegenStep(projectRoot, appRoot, reactNativeRoot, false);
runCodegenStep(projectRoot, appRoot, reactNativeRoot, false, cxxApiDefines);
return;
}

Expand All @@ -1198,6 +1245,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
reactNativeRoot,
'--ios-deployment-target',
iosDeploymentTarget,
...cxxApiDefineArgs,
]);
} catch (e) {
if (e instanceof MissingManifestError) {
Expand Down Expand Up @@ -1240,7 +1288,13 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
);
}

runCodegenStep(projectRoot, appRoot, reactNativeRoot, args.skipCodegen);
runCodegenStep(
projectRoot,
appRoot,
reactNativeRoot,
args.skipCodegen,
cxxApiDefines,
);

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

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

// Build the per-app generated-headers farm (vended as the ReactAppHeaders
// SPM target inside the codegen package). React core headers need no trees
Expand All @@ -1327,7 +1382,7 @@ async function main(argv /*:: ?: Array<string> */) /*: Promise<void> */ {
// Xcodeproj setup: in-place injection into the existing project (the only
// strategy — no rename, no from-scratch; git is the safety net).
try {
await setupXcodeproj(args, appRoot, reactNativeRoot, action);
await setupXcodeproj(args, appRoot, reactNativeRoot, action, cxxApiDefines);
} catch (e) {
logError(`xcodeproj setup failed: ${e.message}`);
if (process.exitCode == null) {
Expand Down Expand Up @@ -1356,6 +1411,7 @@ module.exports = {
resolveAppIosDeploymentTarget,
resolveConfigCommandToPin,
resolveExplicitConfigCommand,
resolveCxxApiDefines,
shouldAutoDeintegrate,
ensureBothArtifactFlavors,
};
26 changes: 25 additions & 1 deletion packages/react-native/scripts/spm/__docs__/spm-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,28 @@ A floor set in an `.xcconfig` your configuration is based on is honored,
manifests (they never create new ones) — if you persisted a scaffold with
`patch-package`, re-run `npx patch-package <dep>` afterwards.

### C++ stable API (`RN_STRICT_API`)

`RN_STRICT_API=1` and `RN_ALLOW_FRAMEWORKS=1` on `add`/`update` turn on React
Native's C++ stable API checks:

```bash
RN_STRICT_API=1 npx react-native spm update
```

Each one defines the macro of the same name for the app target
(`GCC_PREPROCESSOR_DEFINITIONS`), the packages generated for your
[local native modules](#local-native-modules), and the app codegen package
(`build/generated/ios/Package.swift`), which also always defines
`RN_ALLOW_FRAMEWORKS`. Libraries build from their own `Package.swift`, so they
are only checked if they define the macros themselves.

The choice is recorded in `.spm-injected.json` (as `cxxApiDefines`), because the
**Sync SPM Autolinking** build phase regenerates those packages on every build
without your shell's environment. Unlike the pins above, every `add`/`update`
reads the variables again: run `update` without one to turn it off. `deinit`
removes the app-target definitions with the rest of the injection.

## Files the tool touches

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

### In your repo — generated, gitignored

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,25 @@ describe('generateSynthPackageSwift', () => {
);
});

it('defines its C++ API macros once each, next to the config-gated ABI defines', () => {
expect(generateSynthPackageSwift(baseSpec())).not.toMatch(
/RN_(STRICT_API|ALLOW_FRAMEWORKS)/,
);
const result = generateSynthPackageSwift(
baseSpec({
cxxApiDefines: [
'RN_STRICT_API',
'RN_ALLOW_FRAMEWORKS',
'RN_ALLOW_FRAMEWORKS',
],
}),
);
expect(result).toMatch(
/cxxSettings: \[[^\]]*\.define\("NDEBUG", \.when\(configuration: \.release\)\), \.define\("RN_STRICT_API"\), \.define\("RN_ALLOW_FRAMEWORKS"\)[,\]]/,
);
expect(result.match(/\.define\("RN_ALLOW_FRAMEWORKS"\)/g)).toHaveLength(1);
});

it('depends on ReactNative via a fixed relative path (default synth depth)', () => {
const result = generateSynthPackageSwift(baseSpec({hasReactDep: true}));
expect(result).toContain(
Expand Down Expand Up @@ -538,6 +557,27 @@ describe('main() — --ios-deployment-target', () => {
return {appRoot, rnRoot, autolinkDir};
}

it("defines the app's C++ API macros (--cxx-api-define) in the synth manifest", () => {
const {appRoot, rnRoot, autolinkDir} = buildApp();
main([
'--app-root',
appRoot,
'--react-native-root',
rnRoot,
'--cxx-api-define',
'RN_STRICT_API',
'--cxx-api-define',
'RN_ALLOW_FRAMEWORKS',
]);
const synth = fs.readFileSync(
path.join(autolinkDir, 'packages', 'MyNativeModule', 'Package.swift'),
'utf8',
);
expect(synth).toMatch(
/cxxSettings: \[[^\]]*\.define\("RN_STRICT_API"\), \.define\("RN_ALLOW_FRAMEWORKS"\)/,
);
});

it('sanitizes the flag into both the aggregator and the synth manifest', () => {
const {appRoot, rnRoot, autolinkDir} = buildApp();
main([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const {
SPM_INJECTED_MARKER,
injectSpmIntoExistingXcodeproj,
readArtifactsVersionOverride,
readCxxApiDefines,
readPinnedConfigCommand,
removeSpmInjection,
} = require('../generate-spm-xcodeproj');
Expand Down Expand Up @@ -212,6 +213,52 @@ describe('injectSpmIntoExistingXcodeproj — HERMES_CLI_PATH', () => {
});
});

describe('injectSpmIntoExistingXcodeproj — C++ stable API defines', () => {
const BOTH = ['RN_STRICT_API', 'RN_ALLOW_FRAMEWORKS'];
const inject = (ctx, cxxApiDefines) =>
injectSpmIntoExistingXcodeproj({
appRoot: ctx.appRoot,
reactNativeRoot: ctx.rnRoot,
xcodeprojPath: ctx.xcodeprojPath,
cxxApiDefines,
});

it('defines the opted-in macros in every app-target config and records them', () => {
const ctx = scaffoldApp();
expect(inject(ctx, BOTH).status).toBe('injected');
// Debug + Release, each seeded with $(inherited) so project-level
// definitions (DEBUG=1) still apply.
expect(
pbxprojOf(ctx.xcodeprojPath).match(
/GCC_PREPROCESSOR_DEFINITIONS = \(\s*"\$\(inherited\)",\s*RN_STRICT_API,\s*RN_ALLOW_FRAMEWORKS,\s*\);/g,
),
).toHaveLength(2);
expect(readCxxApiDefines(ctx.appRoot)).toEqual(BOTH);
});

it('removes them on an update without the opt-ins', () => {
const ctx = scaffoldApp();
inject(ctx, BOTH);
inject(ctx, []);
expect(pbxprojOf(ctx.xcodeprojPath)).not.toContain('RN_STRICT_API');
expect(pbxprojOf(ctx.xcodeprojPath)).not.toContain('RN_ALLOW_FRAMEWORKS');
expect(readCxxApiDefines(ctx.appRoot)).toEqual([]);
});

it('round-trips add (opted in) → deinit byte-for-byte', () => {
const ctx = scaffoldApp();
const before = pbxprojOf(ctx.xcodeprojPath);
inject(ctx, BOTH);
expect(
removeSpmInjection({
appRoot: ctx.appRoot,
xcodeprojPath: ctx.xcodeprojPath,
}).status,
).toBe('removed');
expect(pbxprojOf(ctx.xcodeprojPath)).toBe(before);
});
});

describe('removeSpmInjection — the surgical inverse of add', () => {
it('round-trips: add then deinit restores the pbxproj byte-for-byte', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
Expand Down
Loading
Loading