diff --git a/packages/react-native/scripts/codegen/templates/Package.swift.spm-template b/packages/react-native/scripts/codegen/templates/Package.swift.spm-template index 97cdb78fd54f..f9af7ba5a041 100644 --- a/packages/react-native/scripts/codegen/templates/Package.swift.spm-template +++ b/packages/react-native/scripts/codegen/templates/Package.swift.spm-template @@ -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") @@ -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") diff --git a/packages/react-native/scripts/setup-apple-spm.js b/packages/react-native/scripts/setup-apple-spm.js index 0ace9683a03a..cceedf6ad834 100644 --- a/packages/react-native/scripts/setup-apple-spm.js +++ b/packages/react-native/scripts/setup-apple-spm.js @@ -59,6 +59,14 @@ * [advanced] --skip-codegen Skip the react-native codegen step. * [advanced] --config-command 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 @@ -99,6 +107,7 @@ const { findInjectedXcodeproj, injectSpmIntoExistingXcodeproj, readArtifactsVersionOverride, + readCxxApiDefines, readMarker, readPinnedConfigCommand, removeSpmInjection, @@ -112,6 +121,7 @@ const { scaffoldAll, } = require('./spm/scaffold-package-swift'); const { + CXX_API_DEFINES, RemoteVersionError, buildPerAppHeaderTree, defaultCacheDir, @@ -420,17 +430,24 @@ function runCodegenStep( appRoot /*: string */, reactNativeRoot /*: string */, skipCodegen /*: boolean */, + cxxApiDefines /*: ReadonlyArray */, ) /*: 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...'); } @@ -832,6 +849,7 @@ async function setupXcodeproj( appRoot /*: string */, reactNativeRoot /*: string */, action /*: string */, + cxxApiDefines /*: ReadonlyArray */, ) /*: Promise */ { const target = resolveInjectionTarget(args, appRoot); if (target.error != null) { @@ -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}`); @@ -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 */ { + 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 @@ -1160,6 +1195,18 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { 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). @@ -1184,7 +1231,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { // "Artifacts already present in ...". if (action === 'codegen') { - runCodegenStep(projectRoot, appRoot, reactNativeRoot, false); + runCodegenStep(projectRoot, appRoot, reactNativeRoot, false, cxxApiDefines); return; } @@ -1198,6 +1245,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { reactNativeRoot, '--ios-deployment-target', iosDeploymentTarget, + ...cxxApiDefineArgs, ]); } catch (e) { if (e instanceof MissingManifestError) { @@ -1240,7 +1288,13 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { ); } - 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. @@ -1269,6 +1323,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { reactNativeRoot, '--ios-deployment-target', iosDeploymentTarget, + ...cxxApiDefineArgs, ]); } catch (e) { if (e instanceof MissingManifestError) { @@ -1310,7 +1365,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { } // (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 @@ -1327,7 +1382,7 @@ async function main(argv /*:: ?: Array */) /*: Promise */ { // 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) { @@ -1356,6 +1411,7 @@ module.exports = { resolveAppIosDeploymentTarget, resolveConfigCommandToPin, resolveExplicitConfigCommand, + resolveCxxApiDefines, shouldAutoDeintegrate, ensureBothArtifactFlavors, }; diff --git a/packages/react-native/scripts/spm/__docs__/spm-scripts.md b/packages/react-native/scripts/spm/__docs__/spm-scripts.md index 47059cb79da2..13c8aced037e 100644 --- a/packages/react-native/scripts/spm/__docs__/spm-scripts.md +++ b/packages/react-native/scripts/spm/__docs__/spm-scripts.md @@ -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 ` 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. @@ -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 diff --git a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js index aa2d2170ec73..cea359023675 100644 --- a/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/generate-spm-autolinking-test.js @@ -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( @@ -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([ diff --git a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js index d039a55eb3fd..270dddd1c1a2 100644 --- a/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js +++ b/packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js @@ -14,6 +14,7 @@ const { SPM_INJECTED_MARKER, injectSpmIntoExistingXcodeproj, readArtifactsVersionOverride, + readCxxApiDefines, readPinnedConfigCommand, removeSpmInjection, } = require('../generate-spm-xcodeproj'); @@ -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(); diff --git a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js index 6e6278e4c1a0..02ece867a323 100644 --- a/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js +++ b/packages/react-native/scripts/spm/__tests__/setup-apple-spm-test.js @@ -20,6 +20,7 @@ const { resolveAction, resolveAppIosDeploymentTarget, resolveConfigCommandToPin, + resolveCxxApiDefines, resolveExplicitConfigCommand, shouldAutoDeintegrate, } = require('../../setup-apple-spm'); @@ -175,6 +176,60 @@ describe('generateAutolinkingConfigOrFailClosed', () => { // the env var or the default". // --------------------------------------------------------------------------- +describe('resolveCxxApiDefines', () => { + const ENV_VARS = ['RN_STRICT_API', 'RN_ALLOW_FRAMEWORKS']; + let tempDir; + let prevEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-cxx-api-')); + prevEnv = ENV_VARS.map(name => process.env[name]); + ENV_VARS.forEach(name => delete process.env[name]); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + ENV_VARS.forEach((name, i) => { + if (prevEnv[i] === undefined) { + delete process.env[name]; + } else { + process.env[name] = prevEnv[i]; + } + }); + }); + + it('defines, on add/update, each opt-in whose env var is 1, whatever was recorded', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', { + cxxApiDefines: ['RN_STRICT_API', 'RN_ALLOW_FRAMEWORKS'], + }); + process.env.RN_STRICT_API = '1'; + process.env.RN_ALLOW_FRAMEWORKS = 'true'; + expect(resolveCxxApiDefines('add', tempDir)).toEqual(['RN_STRICT_API']); + delete process.env.RN_STRICT_API; + process.env.RN_ALLOW_FRAMEWORKS = '1'; + expect(resolveCxxApiDefines('update', tempDir)).toEqual([ + 'RN_ALLOW_FRAMEWORKS', + ]); + }); + + it('reuses the recorded macros on sync/codegen, which Xcode runs without the shell env', () => { + mkInjectedXcodeproj(tempDir, 'MyApp.xcodeproj', { + cxxApiDefines: ['RN_ALLOW_FRAMEWORKS', 'NOT_A_CXX_API_MACRO'], + }); + process.env.RN_STRICT_API = '1'; + expect(resolveCxxApiDefines('sync', tempDir)).toEqual([ + 'RN_ALLOW_FRAMEWORKS', + ]); + expect(resolveCxxApiDefines('codegen', tempDir)).toEqual([ + 'RN_ALLOW_FRAMEWORKS', + ]); + }); + + it('defines nothing on sync before anything is injected', () => { + expect(resolveCxxApiDefines('sync', tempDir)).toEqual([]); + }); +}); + describe('resolveExplicitConfigCommand', () => { const ENV = 'RCT_SPM_AUTOLINKING_CONFIG_COMMAND'; const PINNED = ['npx', 'expo-modules-autolinking', 'react-native-config']; diff --git a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js index d11f372a947d..d1a36c90c479 100644 --- a/packages/react-native/scripts/spm/__tests__/spm-utils-test.js +++ b/packages/react-native/scripts/spm/__tests__/spm-utils-test.js @@ -26,6 +26,7 @@ const { buildPerAppHeaderTree, defaultCacheDir, displayPath, + installSpmCodegenTemplate, isPublishableVersion, isValidSwiftName, makeLogger, @@ -656,6 +657,58 @@ describe('runCodegenAndInstallTemplate', () => { }); }); +describe('installSpmCodegenTemplate', () => { + let tempDir; + let appRoot; + let codegenPkgSwift; + // The real react-native package root, so the shipped template is exercised. + const reactNativeRoot = path.resolve(__dirname, '..', '..', '..'); + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-codegen-template-')); + appRoot = path.join(tempDir, 'app'); + fs.mkdirSync(path.join(appRoot, 'build', 'generated', 'ios'), { + recursive: true, + }); + codegenPkgSwift = path.join( + appRoot, + 'build', + 'generated', + 'ios', + 'Package.swift', + ); + }); + + afterEach(() => { + fs.rmSync(tempDir, {recursive: true, force: true}); + }); + + const countOf = (content, define) => + (content.match(new RegExp(`\\.define\\("${define}"\\)`, 'g')) ?? []).length; + + it('defines RN_ALLOW_FRAMEWORKS in every C++ target of the generated code by default, and nothing else', () => { + installSpmCodegenTemplate(appRoot, reactNativeRoot); + const content = fs.readFileSync(codegenPkgSwift, 'utf8'); + const ndebugCount = (content.match(/\.define\("NDEBUG"/g) ?? []).length; + expect(ndebugCount).toBeGreaterThan(0); + expect(countOf(content, 'RN_ALLOW_FRAMEWORKS')).toBe(ndebugCount); + expect(countOf(content, 'RN_STRICT_API')).toBe(0); + }); + + it("adds the app's opt-ins after every NDEBUG define, without repeating RN_ALLOW_FRAMEWORKS", () => { + installSpmCodegenTemplate(appRoot, reactNativeRoot, undefined, { + cxxApiDefines: ['RN_STRICT_API', 'RN_ALLOW_FRAMEWORKS'], + }); + const content = fs.readFileSync(codegenPkgSwift, 'utf8'); + const ndebugCount = (content.match(/\.define\("NDEBUG"/g) ?? []).length; + expect(countOf(content, 'RN_STRICT_API')).toBe(ndebugCount); + expect(countOf(content, 'RN_ALLOW_FRAMEWORKS')).toBe(ndebugCount); + expect(content).toMatch( + /\.define\("NDEBUG", \.when\(configuration: \.release\)\),\n[ \t]*\.define\("RN_STRICT_API"\),/, + ); + }); +}); + // --------------------------------------------------------------------------- // buildMergedHeaderTree // --------------------------------------------------------------------------- diff --git a/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js index b2e81d23e5d0..cad8bea70d61 100644 --- a/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js +++ b/packages/react-native/scripts/spm/__tests__/sync-spm-autolinking-test.js @@ -80,6 +80,7 @@ describe('sync-spm-autolinking main', () => { appRoot, rnRoot, expect.any(Object), + {cxxApiDefines: []}, ); expect(deps.generateAutolinking).toHaveBeenCalledWith([ '--app-root', @@ -110,6 +111,27 @@ describe('sync-spm-autolinking main', () => { expect(withoutFlag.generateAutolinking).toHaveBeenCalledWith(baseArgv()); }); + it('forwards --cxx-api-define to the codegen template and the autolinker', async () => { + const deps = makeDeps(); + const defineArgs = [ + '--cxx-api-define', + 'RN_STRICT_API', + '--cxx-api-define', + 'RN_ALLOW_FRAMEWORKS', + ]; + await main([...baseArgv(), ...defineArgs], deps); + expect(deps.installSpmCodegenTemplate).toHaveBeenCalledWith( + appRoot, + rnRoot, + expect.any(Object), + {cxxApiDefines: ['RN_STRICT_API', 'RN_ALLOW_FRAMEWORKS']}, + ); + expect(deps.generateAutolinking).toHaveBeenCalledWith([ + ...baseArgv(), + ...defineArgs, + ]); + }); + it('continues with existing output when codegen fails', async () => { const deps = makeDeps({ runCodegenAndInstallTemplate: jest.fn(() => { diff --git a/packages/react-native/scripts/spm/generate-spm-autolinking.js b/packages/react-native/scripts/spm/generate-spm-autolinking.js index 17ecbbe7bef9..df59d4ca97f8 100644 --- a/packages/react-native/scripts/spm/generate-spm-autolinking.js +++ b/packages/react-native/scripts/spm/generate-spm-autolinking.js @@ -74,6 +74,7 @@ const { const {findPodspecs, readPodspecCached} = require('./read-podspec'); const { AUTOLINKED_PACKAGE_NAME, + CXX_API_DEFINES, REACT_CODEGEN_PACKAGE_NAME, REACT_CODEGEN_PRODUCTS, REACT_NATIVE_PACKAGE_NAME, @@ -218,6 +219,14 @@ function parseArgs(argv /*: Array */) /*: AutolinkingArgs */ { type: 'string', describe: `Platform floor of the generated manifests (default: ${MIN_IOS_VERSION_SUPPORTED})`, }) + .option('cxx-api-define', { + type: 'string', + array: true, + choices: [...CXX_API_DEFINES], + default: [], + describe: + "React Native C++ stable API macro to define in the app's spm.modules packages (repeatable)", + }) .usage( 'Usage: $0 [options]\n\nGenerates autolinked/Package.swift for SPM autolinking.', ) @@ -233,6 +242,7 @@ function parseArgs(argv /*: Array */) /*: AutolinkingArgs */ { iosDeploymentTarget: sanitizeIosDeploymentTarget( parsed['ios-deployment-target'], ), + cxxApiDefines: parsed['cxx-api-define'], }; } @@ -428,6 +438,17 @@ const REACT_CXX_CONFIG_DEFINES = [ '.define("NDEBUG", .when(configuration: .release))', ]; +// The config-gated defines plus the React Native C++ stable API macros the app +// opted into (CXX_API_DEFINES). +function reactCxxDefines( + cxxApiDefines /*: ReadonlyArray */ = [], +) /*: Array */ { + return [ + ...REACT_CXX_CONFIG_DEFINES, + ...Array.from(new Set(cxxApiDefines)).map(name => `.define("${name}")`), + ]; +} + // Marker the autolinker stamps onto every synth Package.swift it writes. // Files lacking this marker are treated as user-managed (self-managed) and // are referenced directly rather than wrapped — see findSelfManagedPackageDir. @@ -1015,7 +1036,7 @@ function generateAutolinkedPackageSwift( name: "${t.name}", dependencies: [${reactProductDeps()}], path: "${t.path}",${excludeLine}${publicHeadersLine}${resourcesLine} - cxxSettings: [${REACT_CXX_CONFIG_DEFINES.join(', ')}], + cxxSettings: [${reactCxxDefines().join(', ')}], linkerSettings: [.linkedFramework("UIKit"), .linkedFramework("Foundation"), .linkedFramework("CoreGraphics")] )`; }); @@ -1226,7 +1247,10 @@ function generateSynthPackageSwift(spec /*: SynthPackageSpec */) /*: string */ { // Fabric C++ target must compile with the same NDEBUG state as the prebuilt // React.framework, else DebugStringConvertible's vtable / ShadowNode layout // diverges and a Release build fails to link. Mirrors Package.swift. - const cxxEntries = [...REACT_CXX_CONFIG_DEFINES, ...headerSearchPathList]; + const cxxEntries = [ + ...reactCxxDefines(spec.cxxApiDefines), + ...headerSearchPathList, + ]; const cxxSettingsLine = `\n cxxSettings: [${cxxEntries.join(', ')}],`; return `// swift-tools-version: 6.0 @@ -1730,6 +1754,7 @@ function main(argv /*:: ?: Array */) /*: void */ { swiftName, })), hasReactDep, + cxxApiDefines: args.cxxApiDefines, // Relative paths from the synth dir (/packages/) to the // app's React xcframeworks + codegen packages. Computed here because the // synth's depth is fixed and it is regenerated every run — no runtime diff --git a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js index 66755221a888..e4180d9a0a15 100644 --- a/packages/react-native/scripts/spm/generate-spm-xcodeproj.js +++ b/packages/react-native/scripts/spm/generate-spm-xcodeproj.js @@ -47,6 +47,7 @@ const { } = require('./spm-pbxproj'); const { AUTOLINKED_PACKAGE_NAME, + CXX_API_DEFINES, REACT_CODEGEN_APP_PRODUCTS, REACT_CODEGEN_PACKAGE_NAME, REACT_NATIVE_PACKAGE_NAME, @@ -1107,6 +1108,16 @@ const DEBUG_ARRAY_SETTINGS = [ {key: 'SWIFT_ACTIVE_COMPILATION_CONDITIONS', values: ['DEBUG']}, ]; +// The React Native C++ stable API macros the app opted into (CXX_API_DEFINES), +// defined for the app target's own C++. +function cxxApiArraySettings( + cxxApiDefines /*: ReadonlyArray */, +) /*: Array<{key: string, values: Array}> */ { + return cxxApiDefines.length > 0 + ? [{key: 'GCC_PREPROCESSOR_DEFINITIONS', values: [...cxxApiDefines]}] + : []; +} + /** The XCBuildConfiguration UUIDs of a target (via its buildConfigurationList). */ function targetBuildConfigUuids( text /*: string */, @@ -1253,6 +1264,7 @@ function injectSpmIntoPbxproj( generatedSources /*: ReadonlyArray */ = [], flavoredFrameworks /*: ReadonlyArray */ = [], scriptPhases /*: ReadonlyArray */ = [], + cxxApiDefines /*: ReadonlyArray */ = [], ) /*: {text: string, injectedUuids: Array, createdArrayFields: Array, buildSettingChanges: Array, generatedSourceUuids: {[string]: Array}, scriptPhaseUuids: {[string]: string}} */ { let text = input; const mkUuid = (section /*: string */, id /*: string */) => @@ -1350,6 +1362,7 @@ function injectSpmIntoPbxproj( buildConfigurationName(text, configUuid), reactNativePath, flavoredFrameworks, + cxxApiDefines, ); text = merged.text; buildSettingChanges.push(merged.change); @@ -1700,6 +1713,7 @@ function mergeReactBuildSettings( configurationName /*: string */, reactNativePath /*: string */, flavoredFrameworks /*: ReadonlyArray */ = [], + cxxApiDefines /*: ReadonlyArray */ = [], ) /*: {text: string, change: BuildSettingChange} */ { let text = input; const scalars = [ @@ -1731,6 +1745,7 @@ function mergeReactBuildSettings( ...(flavorForBuildConfiguration(configurationName) === 'debug' ? DEBUG_ARRAY_SETTINGS : []), + ...cxxApiArraySettings(cxxApiDefines), ...frameworkArrayBuildSettings(flavoredFrameworks), ]; for (const {key, values} of arraySettings) { @@ -2219,7 +2234,7 @@ function readScriptPhasesManifest( */ function readMarker( xcodeprojPath /*: string */, -) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { +) /*: ?{targetUuid?: ?string, generatedSources?: {[string]: Array}, scriptPhases?: {[string]: string}, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: unknown, buildSettingChanges?: Array, createdArrayFields?: Array, scheme?: {file?: ?string, created?: ?boolean}, ...} */ { const markerPath = path.join(xcodeprojPath, SPM_INJECTED_MARKER); try { // $FlowFixMe[incompatible-return] JSON.parse returns any @@ -2301,6 +2316,25 @@ function readPinnedConfigCommand(appRoot /*: string */) /*: ?Array */ { } } +/** + * The React Native C++ stable API macros the last `spm add`/`update` opted into + * (CXX_API_DEFINES), as recorded in the injected xcodeproj's + * `.spm-injected.json` marker. The build-time `sync` reads them back because + * Xcode builds inherit neither the env vars nor the shell that set them. Never + * throws; empty when nothing is injected or the marker can't be read, and a + * hand-edited marker can only yield known macro names. + */ +function readCxxApiDefines(appRoot /*: string */) /*: Array */ { + const xcodeprojPath = findInjectedXcodeproj(appRoot); + if (xcodeprojPath == null) { + return []; + } + const recorded = readMarker(xcodeprojPath)?.cxxApiDefines; + return Array.isArray(recorded) + ? CXX_API_DEFINES.filter(name => recorded.includes(name)) + : []; +} + /** * Union the array fields RN has created across syncs, deduped by container+key. * @@ -2340,7 +2374,7 @@ function mergeCreatedArrayFields( * when the project can't be safely edited (caller surfaces it; fail-loud). */ function injectSpmIntoExistingXcodeproj( - opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array} */, + opts /*: {appRoot: string, reactNativeRoot: string, xcodeprojPath: string, appName?: ?string, artifactsVersionOverride?: ?string, configCommand?: ?Array, cxxApiDefines?: ReadonlyArray} */, ) /*: {status: 'injected', target: string} | {status: 'refused', reason: string} */ { const {appRoot, reactNativeRoot, xcodeprojPath} = opts; const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj'); @@ -2432,6 +2466,7 @@ function injectSpmIntoExistingXcodeproj( generatedSources, flavoredFrameworks, scriptPhases, + opts.cxxApiDefines ?? [], ); const changed = writeIfChanged(pbxprojPath, text); @@ -2500,6 +2535,10 @@ function injectSpmIntoExistingXcodeproj( scriptPhases: scriptPhaseUuids, artifactsVersionOverride, configCommand, + // Read back by readCxxApiDefines for the build-time sync. Not + // set-or-preserve like the pins above: every `add`/`update` re-reads + // the env vars, so unsetting one turns its macro off. + cxxApiDefines: opts.cxxApiDefines ?? [], scheme: { file: schemeResult.file, // Sticky — see mergeCreatedArrayFields for why a later sync cannot @@ -2772,6 +2811,7 @@ module.exports = { readMarker, readArtifactsVersionOverride, readPinnedConfigCommand, + readCxxApiDefines, readScriptPhasesManifest, SPM_INJECTED_MARKER, }; diff --git a/packages/react-native/scripts/spm/spm-types.js b/packages/react-native/scripts/spm/spm-types.js index a89142f6ada0..eecbeb09fb13 100644 --- a/packages/react-native/scripts/spm/spm-types.js +++ b/packages/react-native/scripts/spm/spm-types.js @@ -69,6 +69,9 @@ export type AutolinkingArgs = { xcframeworksPath: string | null, // Platform floor of every manifest this run writes; sanitized by parseArgs. iosDeploymentTarget: string, + // React Native C++ stable API macros (CXX_API_DEFINES) the app opted into; + // defined in the synth packages of its spm.modules. + cxxApiDefines: ReadonlyArray, }; export type SpmTarget = { @@ -381,6 +384,8 @@ export type SynthPackageSpec = { // dep's own `common/cpp/` subtree. headerSearchPaths?: ?Array, iosDeploymentTarget?: ?string, + // React Native C++ stable API macros defined in the synth target. + cxxApiDefines?: ReadonlyArray, }; diff --git a/packages/react-native/scripts/spm/spm-utils.js b/packages/react-native/scripts/spm/spm-utils.js index fc5313623e71..78b736437421 100644 --- a/packages/react-native/scripts/spm/spm-utils.js +++ b/packages/react-native/scripts/spm/spm-utils.js @@ -608,6 +608,46 @@ function buildPerAppHeaderTree( return {path: outDir, virtualPaths: new Set(linker.seen.keys())}; } +// React Native's C++ stable API opt-ins. Each macro is enabled by the env var +// of the same name (=1) at `spm add`/`update`, and defined for the app's own +// code (app target, spm.modules, app codegen); libraries build with their own +// Package.swift settings. RN_STRICT_API arms the react/cxxstableapi guards; +// RN_ALLOW_FRAMEWORKS silences their "for frameworks" warning. +const CXX_API_DEFINES /*: ReadonlyArray */ = [ + 'RN_STRICT_API', + 'RN_ALLOW_FRAMEWORKS', +]; + +// The codegen template's C++ targets carry the config-gated DEBUG/NDEBUG +// defines; the app's C++ API defines are added after each NDEBUG entry so every +// C++ target in the package gets them. +const NDEBUG_DEFINE_LINE_RE = + /^([ \t]*)\.define\("NDEBUG", \.when\(configuration: \.release\)\),$/gm; + +function withCxxApiDefines( + content /*: string */, + cxxApiDefines /*: ReadonlyArray */, + logger /*: {log: (msg: string) => void} */, +) /*: string */ { + // The template already defines RN_ALLOW_FRAMEWORKS for its generated code. + const missing = cxxApiDefines.filter( + name => !content.includes(`.define("${name}")`), + ); + if (missing.length === 0) { + return content; + } + const updated = content.replace(NDEBUG_DEFINE_LINE_RE, (line, indent) => + [line, ...missing.map(name => `${indent}.define("${name}"),`)].join('\n'), + ); + if (updated === content) { + logger.log( + 'warning: no NDEBUG define found in the SPM codegen template — ' + + `${missing.join(', ')} NOT added to build/generated/ios/Package.swift.`, + ); + } + return updated; +} + /** * Runs React Native codegen and installs the SPM Package.swift template * into build/generated/ios/. Used by both setup-apple-spm.js and @@ -629,6 +669,7 @@ function installSpmCodegenTemplate( appRoot /*: string */, reactNativeRoot /*: string */, logger /*: {log: (msg: string) => void} */ = {log() {}}, + opts /*: {cxxApiDefines?: ReadonlyArray} */ = {}, ) /*: void */ { const spmTemplate = path.join( reactNativeRoot, @@ -672,6 +713,7 @@ function installSpmCodegenTemplate( .split(`package: "${REACT_NATIVE_PACKAGE_NAME}")`) .join(`package: "${remote.identity}")`); } + content = withCxxApiDefines(content, opts.cxxApiDefines ?? [], logger); fs.writeFileSync(codegenPkgSwift, content, 'utf8'); logger.log( 'Installed SPM codegen template' + (remote != null ? ' (remote mode)' : ''), @@ -683,7 +725,7 @@ function runCodegenAndInstallTemplate( appRoot /*: string */, reactNativeRoot /*: string */, logger /*: {log: (msg: string) => void} */ = {log() {}}, - opts /*: {installTemplate?: boolean} */ = {}, + opts /*: {installTemplate?: boolean, cxxApiDefines?: ReadonlyArray} */ = {}, ) /*: void */ { const codegenScript = path.join( reactNativeRoot, @@ -704,7 +746,9 @@ function runCodegenAndInstallTemplate( // themselves once the symlinks are final; they pass installTemplate: false to // avoid a wasted write that the later install would immediately supersede. if (opts.installTemplate !== false) { - installSpmCodegenTemplate(appRoot, reactNativeRoot, logger); + installSpmCodegenTemplate(appRoot, reactNativeRoot, logger, { + cxxApiDefines: opts.cxxApiDefines, + }); } } @@ -750,6 +794,7 @@ function isValidScriptPhaseName(value /*: unknown */) /*: boolean */ { } module.exports = { + CXX_API_DEFINES, REACT_NATIVE_PACKAGE_NAME, REACT_CODEGEN_PACKAGE_NAME, AUTOLINKED_PACKAGE_NAME, diff --git a/packages/react-native/scripts/spm/sync-spm-autolinking.js b/packages/react-native/scripts/spm/sync-spm-autolinking.js index 78e1fcbd43a0..b5016cbe1da5 100644 --- a/packages/react-native/scripts/spm/sync-spm-autolinking.js +++ b/packages/react-native/scripts/spm/sync-spm-autolinking.js @@ -40,6 +40,7 @@ const {main: generateAutolinking} = require('./generate-spm-autolinking'); const { + CXX_API_DEFINES, RemoteVersionError, buildPerAppHeaderTree, findProjectRoot, @@ -86,12 +87,21 @@ async function main( type: 'string', describe: 'Platform floor forwarded to the autolinker', }) + .option('cxx-api-define', { + type: 'string', + array: true, + choices: [...CXX_API_DEFINES], + default: [], + describe: + "React Native C++ stable API macro to define in the app's spm.modules and codegen packages (repeatable)", + }) .help() .parseSync(); const appRoot = path.resolve(parsed['app-root']); const reactNativeRoot = path.resolve(parsed['react-native-root']); const projectRoot = deps.findProjectRoot(appRoot); + const cxxApiDefines /*: Array */ = parsed['cxx-api-define']; // The caller (setup-apple-spm.js) already generated autolinking.json before // invoking this script, so codegen reuses it here. @@ -116,7 +126,12 @@ async function main( // build/generated/ios/Package.swift — breaking every subsequent Xcode // "Resolve Package Graph". Installing it immediately after codegen means a // later throw can never leave that broken manifest in place. - deps.installSpmCodegenTemplate(appRoot, reactNativeRoot, {log}); + deps.installSpmCodegenTemplate( + appRoot, + reactNativeRoot, + {log}, + {cxxApiDefines}, + ); log('Re-generating build/generated/autolinking/Package.swift...'); const autolinkingArgv = [ @@ -129,6 +144,9 @@ async function main( if (iosDeploymentTarget != null) { autolinkingArgv.push('--ios-deployment-target', iosDeploymentTarget); } + for (const name of cxxApiDefines) { + autolinkingArgv.push('--cxx-api-define', name); + } deps.generateAutolinking(autolinkingArgv); // Rebuild the per-app generated-headers farm (vended as the ReactAppHeaders