Skip to content

Commit e96a2d5

Browse files
coadofacebook-github-bot
authored andcommitted
Make C++ stable API guards textual in the ReactNativeHeaders module map (#59068)
Summary: Apps that use React Native through Swift Package Manager and define `RN_STRICT_API` (the C++ stable API opt-in, see #59043) cannot build `import React`. The prebuilt `ReactNativeHeaders.xcframework` ships a module map with one module per namespace. The prebuild makes every header that reaches no C++ a member of that module, and the `react/cxxstableapi` guards qualify because they are pure preprocessor code. So `ReactNativeHeaders_react` lists `FrameworksGuard.h`, `PrivateGuard.h` and `UmbrellaGuard.h` themselves, plus `react/debug/flags.h` and the `JSRuntimeFactory` headers that include them. Clang compiles module members on their own, using the importing target's `-D` macros, with neither `RN_BUILDING` nor `RN_UMBRELLA_CONTEXT` set. With `RN_STRICT_API` defined, `PrivateGuard.h` and `UmbrellaGuard.h` hit their `#error` while building the module. `import React` needs that module (`RCTHermesInstanceFactory.h` imports `<react/runtime/JSRuntimeFactoryCAPI.h>`), so every Swift file importing React, and every Objective-C++ file importing `<React/...>` with modules on, fails. This change declares the guards, and every module member that reaches one through unguarded includes, as `textual header` (new rule R13 in `headers-spec.js`). They still belong to the module, so `React.framework` may keep including them without a non-modular include error, but they are expanded at each include site, where the guard sees its real includer. On `main` this covers 8 headers across `ReactNativeHeaders_react`, `ReactCommon` and `reactperflogger`. Other changes: - `headers-verify.js` runs the Swift `import React` gate a second time with `RN_STRICT_API` defined. Its module import fixture now picks a non-textual member of each module. - The R5 comment said `react/` gets no module. It does, named `ReactNativeHeaders_react`. ## Changelog: [Internal] Test Plan: Prebuild end to end (Debug, iOS simulator slice), from `packages/react-native`: ``` RN_DEP_VERSION=nightly node scripts/ios-prebuild.js -s -f Debug node scripts/ios-prebuild.js -b -f Debug -p ios-simulator node scripts/ios-prebuild.js -c -f Debug -p ios-simulator node scripts/ios-prebuild/headers-verify.js --flavor Debug ``` - The composed module map has the 8 `textual header` entries, and `headers-verify.js` reports `ALL GATES PASSED`, including the new strict gate. RNTester through SwiftPM, with #59043 applied and the artifact above packaged as CI does (`ReactCoreDebug.xcframework.tar.gz`): ``` RN_CORE_TARBALL_PATH=/path/to/ReactCoreDebug.xcframework.tar.gz npx react-native spm download --artifacts /tmp/spm-artifacts npx react-native spm scaffold --artifacts /tmp/spm-artifacts --download skip || true RN_STRICT_API=1 npx react-native spm add --deintegrate --artifacts /tmp/spm-artifacts --download skip xcodebuild -project RNTesterPods.xcodeproj -scheme RNTester -configuration Debug -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build ``` | Scenario | Before | After | | --- | --- | --- | | `RN_STRICT_API` on | Fails: `ReactNativeHeaders_react` won't precompile, Swift target fails | Build succeeds, app links | | `RN_STRICT_API` on, private include in app code | — | error | | `RN_STRICT_API` on, direct public include | — | error | | `RN_STRICT_API` on, "for frameworks" include | — | warning | | `RN_STRICT_API` on, umbrella include | — | builds | | `RN_STRICT_API` off (`spm update` without it) | Builds | Builds, no guard diagnostics | The "Before" row was built against the published nightly. Swift with C++ interop also builds with `RN_STRICT_API` once `main`'s `RCTCallInvoker.h` (which now includes `<React/CallInvoker.h>`) is in the artifact. Not run: Release flavor, device and Mac Catalyst slices. React Native's dependencies and Hermes came from the nightly rather than being built locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) This was also tested locally on rn-tester. Differential Revision: D125444181 Pulled By: coado
1 parent 62a1fc1 commit e96a2d5

4 files changed

Lines changed: 230 additions & 35 deletions

File tree

‎packages/react-native/scripts/ios-prebuild/__tests__/headers-spec-test.js‎

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ const {
1919
const fs = require('node:fs');
2020

2121
/*::
22+
type TestInventoryHeader = {
23+
naturalPath: string,
24+
bucket: string,
25+
lang: string,
26+
identities: Array<{source: string}>,
27+
includes?: {internal: Array<{naturalPath: string, cxxGuarded: boolean}>},
28+
};
2229
type TestInventoryManifest = {
23-
headers: Array<{
24-
naturalPath: string,
25-
bucket: string,
26-
lang: string,
27-
identities: Array<{source: string}>,
28-
}>,
30+
headers: Array<TestInventoryHeader>,
2931
};
3032
*/
3133

@@ -45,7 +47,7 @@ const entry = (
4547
naturalPath /*: string */,
4648
bucket /*: string */,
4749
source /*:: ?: string */,
48-
) => ({
50+
) /*: TestInventoryHeader */ => ({
4951
naturalPath,
5052
bucket,
5153
lang: 'objc',
@@ -209,6 +211,108 @@ describe('R5 invalid-identifier exemption assert (H5)', () => {
209211
});
210212
});
211213

214+
describe('R13 stable API guards are textual module members', () => {
215+
const includes = (
216+
...deps /*: Array<[string, boolean]> */
217+
) /*: {internal: Array<{naturalPath: string, cxxGuarded: boolean}>} */ => ({
218+
internal: deps.map(([naturalPath, cxxGuarded]) => ({
219+
naturalPath,
220+
cxxGuarded,
221+
})),
222+
});
223+
const guardedManifest = () => {
224+
const m = validManifest();
225+
m.headers.push(
226+
entry('react/cxxstableapi/UmbrellaGuard.h', 'objc-modular-candidate'),
227+
{
228+
...entry('react/debug/flags.h', 'objc-modular-candidate'),
229+
includes: includes(['react/cxxstableapi/UmbrellaGuard.h', false]),
230+
},
231+
{
232+
...entry('react/debug/UsesFlags.h', 'objc-modular-candidate'),
233+
includes: includes(['react/debug/flags.h', false]),
234+
},
235+
{
236+
...entry('react/runtime/CApi.h', 'objc-modular-candidate'),
237+
includes: includes(['react/cxxstableapi/UmbrellaGuard.h', true]),
238+
},
239+
entry('react/runtime/Plain.h', 'objc-modular-candidate'),
240+
);
241+
return m;
242+
};
243+
244+
test('guards and members that reach one stay in the module, marked textual', () => {
245+
const plan = planFromInventoryForTest(guardedManifest());
246+
expect(plan.namespaceModules.react).toEqual([
247+
'react/cxxstableapi/UmbrellaGuard.h',
248+
'react/debug/UsesFlags.h',
249+
'react/debug/flags.h',
250+
'react/runtime/CApi.h',
251+
'react/runtime/Plain.h',
252+
]);
253+
expect(plan.textualNamespaceHeaders).toEqual([
254+
'react/cxxstableapi/UmbrellaGuard.h',
255+
'react/debug/UsesFlags.h',
256+
'react/debug/flags.h',
257+
]);
258+
});
259+
260+
test('a guard reached only under #ifdef __cplusplus does not count', () => {
261+
const plan = planFromInventoryForTest(guardedManifest());
262+
expect(plan.textualNamespaceHeaders).not.toContain('react/runtime/CApi.h');
263+
});
264+
265+
test('module map declares them `textual header`', () => {
266+
const plan = planFromInventoryForTest(guardedManifest());
267+
const mm = renderNamespaceModuleMap(
268+
plan.namespaceModules,
269+
plan.textualNamespaceHeaders,
270+
);
271+
expect(mm).toContain('module ReactNativeHeaders_react {');
272+
expect(mm).toContain(
273+
' textual header "react/cxxstableapi/UmbrellaGuard.h"',
274+
);
275+
expect(mm).toContain(' textual header "react/debug/flags.h"');
276+
expect(mm).not.toMatch(/^\s*header "react\/debug\/flags\.h"/m);
277+
expect(mm).toContain(' header "react/runtime/Plain.h"');
278+
});
279+
280+
test('without stable API guards every member stays modular', () => {
281+
const plan = planFromInventoryForTest(validManifest());
282+
expect(plan.textualNamespaceHeaders).toEqual([]);
283+
expect(
284+
renderNamespaceModuleMap(
285+
plan.namespaceModules,
286+
plan.textualNamespaceHeaders,
287+
),
288+
).not.toContain('textual header');
289+
});
290+
291+
test('ObjC verify fixture imports a modular member, never a textual one', () => {
292+
const {renderObjcFixture} = require('../headers-verify');
293+
const m = guardedManifest();
294+
m.headers.push(
295+
entry('reactperflogger/Guarded.h', 'objc-modular-candidate'),
296+
);
297+
const guardedOnly = m.headers.find(
298+
x => x.naturalPath === 'reactperflogger/Guarded.h',
299+
);
300+
if (guardedOnly == null) {
301+
throw new Error('fixture missing reactperflogger/Guarded.h');
302+
}
303+
guardedOnly.includes = includes([
304+
'react/cxxstableapi/UmbrellaGuard.h',
305+
false,
306+
]);
307+
const tu = renderObjcFixture(planFromInventoryForTest(m));
308+
// First modular member in sort order (the textual ones sort before it).
309+
expect(tu).toContain('#import <react/runtime/CApi.h>');
310+
expect(tu).not.toContain('#import <react/cxxstableapi/');
311+
// A module whose members are all textual has nothing to precompile.
312+
expect(tu).not.toContain('#import <reactperflogger/');
313+
});
314+
});
315+
212316
describe('R11 redirect shims for dual-identity headers', () => {
213317
test('RNH spelling of a source that also ships as React/ becomes a shim', () => {
214318
const m = validManifest();

‎packages/react-native/scripts/ios-prebuild/headers-compose.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,10 @@ function buildReactNativeHeadersXcframework(
292292
}
293293
fs.writeFileSync(
294294
path.join(stage, 'module.modulemap'),
295-
renderNamespaceModuleMap(plan.namespaceModules),
295+
renderNamespaceModuleMap(
296+
plan.namespaceModules,
297+
plan.textualNamespaceHeaders,
298+
),
296299
);
297300

298301
// ---- compose (stub archives + create-xcframework) ----

‎packages/react-native/scripts/ios-prebuild/headers-spec.js‎

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,9 @@
4747
* non-modular framework headers; yoga + RCTDeprecation found
4848
* empirically). Namespaces whose name is not a valid module identifier
4949
* (e.g. jsinspector-modern) are exempt — they have no candidates today;
50-
* planFromInventory FAILS CLOSED if that changes. `react/` is also exempt: its few
51-
* objc-modular-candidates stay textual (as they already were inside
52-
* React.framework) so no `react` module aliases the `React` framework
53-
* module.
50+
* planFromInventory FAILS CLOSED if that changes. `react/` gets one
51+
* too, named ReactNativeHeaders_react so it cannot alias the `React`
52+
* framework module on a case-insensitive filesystem.
5453
* R6. Bare root aliases are servable only as `<React/X>` — bare angle forms
5554
* (`#import <RCTAppDelegate.h>`) have no framework spelling. This is the
5655
* accepted, measured consumer migration (~4 lines ecosystem-wide).
@@ -93,6 +92,16 @@
9392
* within one artifact, so no module edge is created. These umbrellas are
9493
* objc-blocked by construction (they re-export their module's C++
9594
* surface), so they were never R4 umbrella or R5 module members anyway.
95+
* R13. The C++ stable API guards (react/cxxstableapi/*Guard.h) are
96+
* `textual header` members of their R5 module, as is every R5 member that
97+
* reaches one through unguarded includes. A guard decides at each
98+
* #include, from the includer's RN_BUILDING / RN_UMBRELLA_CONTEXT, whether
99+
* that include is allowed. A module member is instead compiled once, on
100+
* its own, with the consumer's -D macros and no includer at all, so under
101+
* RN_STRICT_API the guard fires against the module itself and every
102+
* `import React` fails to build. A textual member still belongs to the
103+
* module, so a framework module may include it (R5), but it is expanded
104+
* at each include site, where the guard sees its real includer.
96105
*/
97106

98107
const fs = require('node:fs');
@@ -124,6 +133,8 @@ export type HeadersSpecPlan = {
124133
umbrella: Array<string>,
125134
// R5: plain modules for ReactNativeHeaders' module.modulemap
126135
namespaceModules: {[ns: string]: Array<string>},
136+
// R13: the namespaceModules members declared `textual header`.
137+
textualNamespaceHeaders: Array<string>,
127138
// R10: per-namespace umbrella headers emitted into ReactNativeHeaders.
128139
namespaceUmbrellas: Array<{relPath: string, content: string}>,
129140
// R9: private headers added to the React module map (allowlist).
@@ -268,6 +279,39 @@ function isUmbrellaSafe(h /*: any */, rnRoot /*: string */) /*: boolean */ {
268279
}
269280
}
270281

282+
// R13: natural-path prefix of the C++ stable API guard headers.
283+
const STABLE_API_GUARD_PREFIX = 'react/cxxstableapi/';
284+
285+
// R13: the inventory headers that are a stable API guard or reach one through
286+
// unguarded includes — what compiling the header on its own in Objective-C
287+
// (a namespace module build) evaluates.
288+
function stableApiGuardedHeaders(manifest /*: any */) /*: Set<string> */ {
289+
const includes /*: Map<string, Array<string>> */ = new Map();
290+
for (const h of manifest.headers) {
291+
includes.set(
292+
h.naturalPath,
293+
(h.includes?.internal ?? [])
294+
.filter(i => !i.cxxGuarded)
295+
.map(i => i.naturalPath),
296+
);
297+
}
298+
const isGuard = (np /*: string */) => np.startsWith(STABLE_API_GUARD_PREFIX);
299+
const guarded /*: Set<string> */ = new Set(
300+
Array.from(includes.keys()).filter(isGuard),
301+
);
302+
let changed = true;
303+
while (changed) {
304+
changed = false;
305+
for (const [np, deps] of includes) {
306+
if (!guarded.has(np) && deps.some(d => isGuard(d) || guarded.has(d))) {
307+
guarded.add(np);
308+
changed = true;
309+
}
310+
}
311+
}
312+
return guarded;
313+
}
314+
271315
// R10: per-namespace umbrella headers. Some consumers (e.g. Expo's
272316
// RCTAppDelegateUmbrella.h) probe
273317
// `<React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h>` via __has_include.
@@ -319,6 +363,8 @@ function planFromInventory(
319363
const reactNativeHeaders /*: Array<SpecEntry> */ = [];
320364
const umbrella /*: Array<string> */ = [];
321365
const namespaceModules /*: {[string]: Array<string>} */ = {};
366+
const textualNamespaceHeaders /*: Array<string> */ = [];
367+
const guardedHeaders = stableApiGuardedHeaders(manifest);
322368
const collisions /*: Array<string> */ = [];
323369
const seen /*: Map<string, string> */ = new Map();
324370

@@ -409,6 +455,9 @@ function planFromInventory(
409455
namespaceModules[ns] = [];
410456
}
411457
namespaceModules[ns].push(np);
458+
if (guardedHeaders.has(np)) {
459+
textualNamespaceHeaders.push(np);
460+
}
412461
}
413462
}
414463
}
@@ -417,6 +466,7 @@ function planFromInventory(
417466
for (const ns of Object.keys(namespaceModules)) {
418467
namespaceModules[ns].sort();
419468
}
469+
textualNamespaceHeaders.sort();
420470

421471
// R11: assign redirect shims for duplicate spellings of one source.
422472
// Owner precedence: the React/ form (module React / canonical textual home)
@@ -486,6 +536,7 @@ function planFromInventory(
486536
depsNamespaces: DEPS_NAMESPACES,
487537
umbrella,
488538
namespaceModules,
539+
textualNamespaceHeaders,
489540
namespaceUmbrellas,
490541
privateReactHeaders: PRIVATE_REACT_HEADERS,
491542
collisions,
@@ -525,10 +576,13 @@ function renderUmbrellaHeader(umbrella /*: Array<string> */) /*: string */ {
525576
* modules, one per namespace with modular candidates — discovered implicitly
526577
* by clang via the auto-added header search path. Headers are referenced by
527578
* their path relative to the Headers root (= the modulemap's directory).
579+
* Members in `textualHeaders` (R13) are declared `textual header`.
528580
*/
529581
function renderNamespaceModuleMap(
530582
namespaceModules /*: {[string]: Array<string>} */,
583+
textualHeaders /*: ReadonlyArray<string> */ = [],
531584
) /*: string */ {
585+
const textual = new Set(textualHeaders);
532586
// The module NAME is internal to clang's module graph (consumers never
533587
// `@import` these; they `#import <ns/...>` and clang maps the header to its
534588
// module). It only has to be unique and must not alias the `React` framework
@@ -539,7 +593,9 @@ function renderNamespaceModuleMap(
539593
ns === 'react' ? 'ReactNativeHeaders_react' : ns;
540594
const blocks = [];
541595
for (const ns of Object.keys(namespaceModules).sort()) {
542-
const headerLines = namespaceModules[ns].map(hh => ` header "${hh}"`);
596+
const headerLines = namespaceModules[ns].map(hh =>
597+
textual.has(hh) ? ` textual header "${hh}"` : ` header "${hh}"`,
598+
);
543599
// R10: the per-namespace umbrella is itself a module member, so importing
544600
// it stays modular (otherwise it re-trips -Wnon-modular-include inside the
545601
// consumer's framework module).

‎packages/react-native/scripts/ios-prebuild/headers-verify.js‎

Lines changed: 54 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
* b. A privileged-consumer ObjC++ TU (the Expo shape): textually imports
3131
* every R9 textual Fabric header against ReactNativeHeaders.
3232
* c. A Swift TU: `import React` + `RCTBridge.moduleRegistry` — the Expo
33-
* Swift case, proving the R9 modular header is module-visible.
33+
* Swift case, proving the R9 modular header is module-visible. It is
34+
* compiled twice, the second time with RN_STRICT_API defined, so the
35+
* modules also build for apps that opt into the C++ stable API (R13).
3436
* d. Swift C++-interop TUs that import React plus every importable module
3537
* declared by the composed ReactNativeHeaders module map, then the
3638
* shipped inspector umbrella as an isolated probe module, forcing
@@ -233,7 +235,10 @@ function verifyStructural(
233235
const rnhHeaders = path.join(rnhSlice, 'Headers');
234236
assertFileEquals(
235237
path.join(rnhHeaders, 'module.modulemap'),
236-
renderNamespaceModuleMap(plan.namespaceModules),
238+
renderNamespaceModuleMap(
239+
plan.namespaceModules,
240+
plan.textualNamespaceHeaders,
241+
),
237242
'ReactNativeHeaders module map',
238243
);
239244
for (const u of plan.namespaceUmbrellas) {
@@ -290,9 +295,16 @@ function renderObjcFixture(plan /*: HeadersSpecPlan */) /*: string */ {
290295
for (const name of plan.privateReactHeaders.modular) {
291296
lines.push(`#import <React/${name}>`);
292297
}
293-
// One header per R5 namespace module precompiles the whole module.
298+
// One header per R5 namespace module precompiles the whole module. A textual
299+
// member (R13) is expanded in place rather than importing its module, so
300+
// pick a modular one; a module with only textual members has nothing to
301+
// precompile.
302+
const textual = new Set(plan.textualNamespaceHeaders);
294303
for (const ns of Object.keys(plan.namespaceModules).sort()) {
295-
lines.push(`#import <${plan.namespaceModules[ns][0]}>`);
304+
const member = plan.namespaceModules[ns].find(h => !textual.has(h));
305+
if (member != null) {
306+
lines.push(`#import <${member}>`);
307+
}
296308
}
297309
for (const u of plan.namespaceUmbrellas) {
298310
lines.push(`#import <${u.relPath}>`);
@@ -438,27 +450,47 @@ function runCompileGates(
438450

439451
const swift = path.join(tmp, 'gate-swift.swift');
440452
fs.writeFileSync(swift, renderSwiftFixture());
441-
xcrun(
442-
[
443-
'swiftc',
444-
'-typecheck',
445-
'-sdk',
446-
sdk,
447-
'-target',
448-
SIM_TARGET,
449-
'-module-cache-path',
450-
path.join(tmp, 'mc-swift'),
451-
'-F',
452-
reactSlice,
453-
'-I',
454-
rnhHeaders,
455-
'-I',
456-
depsHeaders,
457-
swift,
458-
],
453+
const swiftGate = (
454+
moduleCache /*: string */,
455+
extraArgs /*: Array<string> */,
456+
what /*: string */,
457+
) =>
458+
xcrun(
459+
[
460+
'swiftc',
461+
'-typecheck',
462+
'-sdk',
463+
sdk,
464+
'-target',
465+
SIM_TARGET,
466+
'-module-cache-path',
467+
path.join(tmp, moduleCache),
468+
'-F',
469+
reactSlice,
470+
'-I',
471+
rnhHeaders,
472+
'-I',
473+
depsHeaders,
474+
...extraArgs,
475+
swift,
476+
],
477+
what,
478+
);
479+
swiftGate(
480+
'mc-swift',
481+
[],
459482
'Swift gate (import React + RCTBridge.moduleRegistry)',
460483
);
461484
log('compile: Swift moduleRegistry fixture OK.');
485+
// R13: an app that opts into the C++ stable API defines RN_STRICT_API for
486+
// its own code, and the modules it imports are built with that define. A
487+
// guard compiled as a module member would fail this build.
488+
swiftGate(
489+
'mc-swift-strict',
490+
['-Xcc', '-DRN_STRICT_API'],
491+
'Swift strict C++ stable API gate (import React with RN_STRICT_API)',
492+
);
493+
log('compile: Swift fixture with RN_STRICT_API OK.');
462494

463495
const moduleMap = path.join(rnhHeaders, 'module.modulemap');
464496
const moduleNames = parseModuleNames(moduleMap);

0 commit comments

Comments
 (0)