From 84a46686cfd7cb770170f65bb51f5f85ce767af0 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 14 Oct 2026 14:19:24 +0200 Subject: [PATCH] Make C++ stable API guards textual in the ReactNativeHeaders module map The prebuild declares every ReactNativeHeaders header that reaches no C++ as a member of its namespace module. That includes the react/cxxstableapi guards and the headers that include them (react/debug/flags.h, react/runtime/JSRuntimeFactory*.h, ...). Clang compiles module members on their own, with the importer's -D macros and without RN_BUILDING or RN_UMBRELLA_CONTEXT, so when an app defines RN_STRICT_API the guards fire while building ReactNativeHeaders_react and `import React` fails. Declare the guards, and every member that reaches one through unguarded includes, as `textual header`. They still belong to the module, so React.framework may include them, but they are expanded at each include site, where the guard sees its real includer. headers-verify now also type-checks the Swift `import React` fixture with RN_STRICT_API defined. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/headers-spec-test.js | 118 ++++++++++++++++-- .../scripts/ios-prebuild/headers-compose.js | 5 +- .../scripts/ios-prebuild/headers-spec.js | 66 +++++++++- .../scripts/ios-prebuild/headers-verify.js | 76 +++++++---- 4 files changed, 230 insertions(+), 35 deletions(-) diff --git a/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js index 057362ccc3dc..e4a9eba55694 100644 --- a/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js +++ b/packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js @@ -19,13 +19,15 @@ const { const fs = require('node:fs'); /*:: +type TestInventoryHeader = { + naturalPath: string, + bucket: string, + lang: string, + identities: Array<{source: string}>, + includes?: {internal: Array<{naturalPath: string, cxxGuarded: boolean}>}, +}; type TestInventoryManifest = { - headers: Array<{ - naturalPath: string, - bucket: string, - lang: string, - identities: Array<{source: string}>, - }>, + headers: Array, }; */ @@ -45,7 +47,7 @@ const entry = ( naturalPath /*: string */, bucket /*: string */, source /*:: ?: string */, -) => ({ +) /*: TestInventoryHeader */ => ({ naturalPath, bucket, lang: 'objc', @@ -209,6 +211,108 @@ describe('R5 invalid-identifier exemption assert (H5)', () => { }); }); +describe('R13 stable API guards are textual module members', () => { + const includes = ( + ...deps /*: Array<[string, boolean]> */ + ) /*: {internal: Array<{naturalPath: string, cxxGuarded: boolean}>} */ => ({ + internal: deps.map(([naturalPath, cxxGuarded]) => ({ + naturalPath, + cxxGuarded, + })), + }); + const guardedManifest = () => { + const m = validManifest(); + m.headers.push( + entry('react/cxxstableapi/UmbrellaGuard.h', 'objc-modular-candidate'), + { + ...entry('react/debug/flags.h', 'objc-modular-candidate'), + includes: includes(['react/cxxstableapi/UmbrellaGuard.h', false]), + }, + { + ...entry('react/debug/UsesFlags.h', 'objc-modular-candidate'), + includes: includes(['react/debug/flags.h', false]), + }, + { + ...entry('react/runtime/CApi.h', 'objc-modular-candidate'), + includes: includes(['react/cxxstableapi/UmbrellaGuard.h', true]), + }, + entry('react/runtime/Plain.h', 'objc-modular-candidate'), + ); + return m; + }; + + test('guards and members that reach one stay in the module, marked textual', () => { + const plan = planFromInventoryForTest(guardedManifest()); + expect(plan.namespaceModules.react).toEqual([ + 'react/cxxstableapi/UmbrellaGuard.h', + 'react/debug/UsesFlags.h', + 'react/debug/flags.h', + 'react/runtime/CApi.h', + 'react/runtime/Plain.h', + ]); + expect(plan.textualNamespaceHeaders).toEqual([ + 'react/cxxstableapi/UmbrellaGuard.h', + 'react/debug/UsesFlags.h', + 'react/debug/flags.h', + ]); + }); + + test('a guard reached only under #ifdef __cplusplus does not count', () => { + const plan = planFromInventoryForTest(guardedManifest()); + expect(plan.textualNamespaceHeaders).not.toContain('react/runtime/CApi.h'); + }); + + test('module map declares them `textual header`', () => { + const plan = planFromInventoryForTest(guardedManifest()); + const mm = renderNamespaceModuleMap( + plan.namespaceModules, + plan.textualNamespaceHeaders, + ); + expect(mm).toContain('module ReactNativeHeaders_react {'); + expect(mm).toContain( + ' textual header "react/cxxstableapi/UmbrellaGuard.h"', + ); + expect(mm).toContain(' textual header "react/debug/flags.h"'); + expect(mm).not.toMatch(/^\s*header "react\/debug\/flags\.h"/m); + expect(mm).toContain(' header "react/runtime/Plain.h"'); + }); + + test('without stable API guards every member stays modular', () => { + const plan = planFromInventoryForTest(validManifest()); + expect(plan.textualNamespaceHeaders).toEqual([]); + expect( + renderNamespaceModuleMap( + plan.namespaceModules, + plan.textualNamespaceHeaders, + ), + ).not.toContain('textual header'); + }); + + test('ObjC verify fixture imports a modular member, never a textual one', () => { + const {renderObjcFixture} = require('../headers-verify'); + const m = guardedManifest(); + m.headers.push( + entry('reactperflogger/Guarded.h', 'objc-modular-candidate'), + ); + const guardedOnly = m.headers.find( + x => x.naturalPath === 'reactperflogger/Guarded.h', + ); + if (guardedOnly == null) { + throw new Error('fixture missing reactperflogger/Guarded.h'); + } + guardedOnly.includes = includes([ + 'react/cxxstableapi/UmbrellaGuard.h', + false, + ]); + const tu = renderObjcFixture(planFromInventoryForTest(m)); + // First modular member in sort order (the textual ones sort before it). + expect(tu).toContain('#import '); + expect(tu).not.toContain('#import { test('RNH spelling of a source that also ships as React/ becomes a shim', () => { const m = validManifest(); diff --git a/packages/react-native/scripts/ios-prebuild/headers-compose.js b/packages/react-native/scripts/ios-prebuild/headers-compose.js index 6f2dae7fd4ae..426d8b45b1e7 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-compose.js +++ b/packages/react-native/scripts/ios-prebuild/headers-compose.js @@ -292,7 +292,10 @@ function buildReactNativeHeadersXcframework( } fs.writeFileSync( path.join(stage, 'module.modulemap'), - renderNamespaceModuleMap(plan.namespaceModules), + renderNamespaceModuleMap( + plan.namespaceModules, + plan.textualNamespaceHeaders, + ), ); // ---- compose (stub archives + create-xcframework) ---- diff --git a/packages/react-native/scripts/ios-prebuild/headers-spec.js b/packages/react-native/scripts/ios-prebuild/headers-spec.js index 8696ea836bfa..5b24e11eb394 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-spec.js +++ b/packages/react-native/scripts/ios-prebuild/headers-spec.js @@ -47,10 +47,9 @@ * non-modular framework headers; yoga + RCTDeprecation found * empirically). Namespaces whose name is not a valid module identifier * (e.g. jsinspector-modern) are exempt — they have no candidates today; - * planFromInventory FAILS CLOSED if that changes. `react/` is also exempt: its few - * objc-modular-candidates stay textual (as they already were inside - * React.framework) so no `react` module aliases the `React` framework - * module. + * planFromInventory FAILS CLOSED if that changes. `react/` gets one + * too, named ReactNativeHeaders_react so it cannot alias the `React` + * framework module on a case-insensitive filesystem. * R6. Bare root aliases are servable only as `` — bare angle forms * (`#import `) have no framework spelling. This is the * accepted, measured consumer migration (~4 lines ecosystem-wide). @@ -93,6 +92,16 @@ * within one artifact, so no module edge is created. These umbrellas are * objc-blocked by construction (they re-export their module's C++ * surface), so they were never R4 umbrella or R5 module members anyway. + * R13. The C++ stable API guards (react/cxxstableapi/*Guard.h) are + * `textual header` members of their R5 module, as is every R5 member that + * reaches one through unguarded includes. A guard decides at each + * #include, from the includer's RN_BUILDING / RN_UMBRELLA_CONTEXT, whether + * that include is allowed. A module member is instead compiled once, on + * its own, with the consumer's -D macros and no includer at all, so under + * RN_STRICT_API the guard fires against the module itself and every + * `import React` fails to build. A textual member still belongs to the + * module, so a framework module may include it (R5), but it is expanded + * at each include site, where the guard sees its real includer. */ const fs = require('node:fs'); @@ -124,6 +133,8 @@ export type HeadersSpecPlan = { umbrella: Array, // R5: plain modules for ReactNativeHeaders' module.modulemap namespaceModules: {[ns: string]: Array}, + // R13: the namespaceModules members declared `textual header`. + textualNamespaceHeaders: Array, // R10: per-namespace umbrella headers emitted into ReactNativeHeaders. namespaceUmbrellas: Array<{relPath: string, content: string}>, // R9: private headers added to the React module map (allowlist). @@ -268,6 +279,39 @@ function isUmbrellaSafe(h /*: any */, rnRoot /*: string */) /*: boolean */ { } } +// R13: natural-path prefix of the C++ stable API guard headers. +const STABLE_API_GUARD_PREFIX = 'react/cxxstableapi/'; + +// R13: the inventory headers that are a stable API guard or reach one through +// unguarded includes — what compiling the header on its own in Objective-C +// (a namespace module build) evaluates. +function stableApiGuardedHeaders(manifest /*: any */) /*: Set */ { + const includes /*: Map> */ = new Map(); + for (const h of manifest.headers) { + includes.set( + h.naturalPath, + (h.includes?.internal ?? []) + .filter(i => !i.cxxGuarded) + .map(i => i.naturalPath), + ); + } + const isGuard = (np /*: string */) => np.startsWith(STABLE_API_GUARD_PREFIX); + const guarded /*: Set */ = new Set( + Array.from(includes.keys()).filter(isGuard), + ); + let changed = true; + while (changed) { + changed = false; + for (const [np, deps] of includes) { + if (!guarded.has(np) && deps.some(d => isGuard(d) || guarded.has(d))) { + guarded.add(np); + changed = true; + } + } + } + return guarded; +} + // R10: per-namespace umbrella headers. Some consumers (e.g. Expo's // RCTAppDelegateUmbrella.h) probe // `` via __has_include. @@ -319,6 +363,8 @@ function planFromInventory( const reactNativeHeaders /*: Array */ = []; const umbrella /*: Array */ = []; const namespaceModules /*: {[string]: Array} */ = {}; + const textualNamespaceHeaders /*: Array */ = []; + const guardedHeaders = stableApiGuardedHeaders(manifest); const collisions /*: Array */ = []; const seen /*: Map */ = new Map(); @@ -409,6 +455,9 @@ function planFromInventory( namespaceModules[ns] = []; } namespaceModules[ns].push(np); + if (guardedHeaders.has(np)) { + textualNamespaceHeaders.push(np); + } } } } @@ -417,6 +466,7 @@ function planFromInventory( for (const ns of Object.keys(namespaceModules)) { namespaceModules[ns].sort(); } + textualNamespaceHeaders.sort(); // R11: assign redirect shims for duplicate spellings of one source. // Owner precedence: the React/ form (module React / canonical textual home) @@ -486,6 +536,7 @@ function planFromInventory( depsNamespaces: DEPS_NAMESPACES, umbrella, namespaceModules, + textualNamespaceHeaders, namespaceUmbrellas, privateReactHeaders: PRIVATE_REACT_HEADERS, collisions, @@ -525,10 +576,13 @@ function renderUmbrellaHeader(umbrella /*: Array */) /*: string */ { * modules, one per namespace with modular candidates — discovered implicitly * by clang via the auto-added header search path. Headers are referenced by * their path relative to the Headers root (= the modulemap's directory). + * Members in `textualHeaders` (R13) are declared `textual header`. */ function renderNamespaceModuleMap( namespaceModules /*: {[string]: Array} */, + textualHeaders /*: ReadonlyArray */ = [], ) /*: string */ { + const textual = new Set(textualHeaders); // The module NAME is internal to clang's module graph (consumers never // `@import` these; they `#import ` and clang maps the header to its // module). It only has to be unique and must not alias the `React` framework @@ -539,7 +593,9 @@ function renderNamespaceModuleMap( ns === 'react' ? 'ReactNativeHeaders_react' : ns; const blocks = []; for (const ns of Object.keys(namespaceModules).sort()) { - const headerLines = namespaceModules[ns].map(hh => ` header "${hh}"`); + const headerLines = namespaceModules[ns].map(hh => + textual.has(hh) ? ` textual header "${hh}"` : ` header "${hh}"`, + ); // R10: the per-namespace umbrella is itself a module member, so importing // it stays modular (otherwise it re-trips -Wnon-modular-include inside the // consumer's framework module). diff --git a/packages/react-native/scripts/ios-prebuild/headers-verify.js b/packages/react-native/scripts/ios-prebuild/headers-verify.js index 8806102bddaa..960bed8ac796 100644 --- a/packages/react-native/scripts/ios-prebuild/headers-verify.js +++ b/packages/react-native/scripts/ios-prebuild/headers-verify.js @@ -30,7 +30,9 @@ * b. A privileged-consumer ObjC++ TU (the Expo shape): textually imports * every R9 textual Fabric header against ReactNativeHeaders. * c. A Swift TU: `import React` + `RCTBridge.moduleRegistry` — the Expo - * Swift case, proving the R9 modular header is module-visible. + * Swift case, proving the R9 modular header is module-visible. It is + * compiled twice, the second time with RN_STRICT_API defined, so the + * modules also build for apps that opt into the C++ stable API (R13). * d. Swift C++-interop TUs that import React plus every importable module * declared by the composed ReactNativeHeaders module map, then the * shipped inspector umbrella as an isolated probe module, forcing @@ -233,7 +235,10 @@ function verifyStructural( const rnhHeaders = path.join(rnhSlice, 'Headers'); assertFileEquals( path.join(rnhHeaders, 'module.modulemap'), - renderNamespaceModuleMap(plan.namespaceModules), + renderNamespaceModuleMap( + plan.namespaceModules, + plan.textualNamespaceHeaders, + ), 'ReactNativeHeaders module map', ); for (const u of plan.namespaceUmbrellas) { @@ -290,9 +295,16 @@ function renderObjcFixture(plan /*: HeadersSpecPlan */) /*: string */ { for (const name of plan.privateReactHeaders.modular) { lines.push(`#import `); } - // One header per R5 namespace module precompiles the whole module. + // One header per R5 namespace module precompiles the whole module. A textual + // member (R13) is expanded in place rather than importing its module, so + // pick a modular one; a module with only textual members has nothing to + // precompile. + const textual = new Set(plan.textualNamespaceHeaders); for (const ns of Object.keys(plan.namespaceModules).sort()) { - lines.push(`#import <${plan.namespaceModules[ns][0]}>`); + const member = plan.namespaceModules[ns].find(h => !textual.has(h)); + if (member != null) { + lines.push(`#import <${member}>`); + } } for (const u of plan.namespaceUmbrellas) { lines.push(`#import <${u.relPath}>`); @@ -438,27 +450,47 @@ function runCompileGates( const swift = path.join(tmp, 'gate-swift.swift'); fs.writeFileSync(swift, renderSwiftFixture()); - xcrun( - [ - 'swiftc', - '-typecheck', - '-sdk', - sdk, - '-target', - SIM_TARGET, - '-module-cache-path', - path.join(tmp, 'mc-swift'), - '-F', - reactSlice, - '-I', - rnhHeaders, - '-I', - depsHeaders, - swift, - ], + const swiftGate = ( + moduleCache /*: string */, + extraArgs /*: Array */, + what /*: string */, + ) => + xcrun( + [ + 'swiftc', + '-typecheck', + '-sdk', + sdk, + '-target', + SIM_TARGET, + '-module-cache-path', + path.join(tmp, moduleCache), + '-F', + reactSlice, + '-I', + rnhHeaders, + '-I', + depsHeaders, + ...extraArgs, + swift, + ], + what, + ); + swiftGate( + 'mc-swift', + [], 'Swift gate (import React + RCTBridge.moduleRegistry)', ); log('compile: Swift moduleRegistry fixture OK.'); + // R13: an app that opts into the C++ stable API defines RN_STRICT_API for + // its own code, and the modules it imports are built with that define. A + // guard compiled as a module member would fail this build. + swiftGate( + 'mc-swift-strict', + ['-Xcc', '-DRN_STRICT_API'], + 'Swift strict C++ stable API gate (import React with RN_STRICT_API)', + ); + log('compile: Swift fixture with RN_STRICT_API OK.'); const moduleMap = path.join(rnhHeaders, 'module.modulemap'); const moduleNames = parseModuleNames(moduleMap);