Skip to content
Draft
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 @@ -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<TestInventoryHeader>,
};
*/

Expand All @@ -45,7 +47,7 @@ const entry = (
naturalPath /*: string */,
bucket /*: string */,
source /*:: ?: string */,
) => ({
) /*: TestInventoryHeader */ => ({
naturalPath,
bucket,
lang: 'objc',
Expand Down Expand Up @@ -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 <react/runtime/CApi.h>');
expect(tu).not.toContain('#import <react/cxxstableapi/');
// A module whose members are all textual has nothing to precompile.
expect(tu).not.toContain('#import <reactperflogger/');
});
});

describe('R11 redirect shims for dual-identity headers', () => {
test('RNH spelling of a source that also ships as React/ becomes a shim', () => {
const m = validManifest();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) ----
Expand Down
66 changes: 61 additions & 5 deletions packages/react-native/scripts/ios-prebuild/headers-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<React/X>` — bare angle forms
* (`#import <RCTAppDelegate.h>`) have no framework spelling. This is the
* accepted, measured consumer migration (~4 lines ecosystem-wide).
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -124,6 +133,8 @@ export type HeadersSpecPlan = {
umbrella: Array<string>,
// R5: plain modules for ReactNativeHeaders' module.modulemap
namespaceModules: {[ns: string]: Array<string>},
// R13: the namespaceModules members declared `textual header`.
textualNamespaceHeaders: Array<string>,
// R10: per-namespace umbrella headers emitted into ReactNativeHeaders.
namespaceUmbrellas: Array<{relPath: string, content: string}>,
// R9: private headers added to the React module map (allowlist).
Expand Down Expand Up @@ -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<string> */ {
const includes /*: Map<string, Array<string>> */ = 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<string> */ = 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
// `<React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h>` via __has_include.
Expand Down Expand Up @@ -319,6 +363,8 @@ function planFromInventory(
const reactNativeHeaders /*: Array<SpecEntry> */ = [];
const umbrella /*: Array<string> */ = [];
const namespaceModules /*: {[string]: Array<string>} */ = {};
const textualNamespaceHeaders /*: Array<string> */ = [];
const guardedHeaders = stableApiGuardedHeaders(manifest);
const collisions /*: Array<string> */ = [];
const seen /*: Map<string, string> */ = new Map();

Expand Down Expand Up @@ -409,6 +455,9 @@ function planFromInventory(
namespaceModules[ns] = [];
}
namespaceModules[ns].push(np);
if (guardedHeaders.has(np)) {
textualNamespaceHeaders.push(np);
}
}
}
}
Expand All @@ -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)
Expand Down Expand Up @@ -486,6 +536,7 @@ function planFromInventory(
depsNamespaces: DEPS_NAMESPACES,
umbrella,
namespaceModules,
textualNamespaceHeaders,
namespaceUmbrellas,
privateReactHeaders: PRIVATE_REACT_HEADERS,
collisions,
Expand Down Expand Up @@ -525,10 +576,13 @@ function renderUmbrellaHeader(umbrella /*: Array<string> */) /*: 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<string>} */,
textualHeaders /*: ReadonlyArray<string> */ = [],
) /*: string */ {
const textual = new Set(textualHeaders);
// The module NAME is internal to clang's module graph (consumers never
// `@import` these; they `#import <ns/...>` and clang maps the header to its
// module). It only has to be unique and must not alias the `React` framework
Expand All @@ -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).
Expand Down
76 changes: 54 additions & 22 deletions packages/react-native/scripts/ios-prebuild/headers-verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -290,9 +295,16 @@ function renderObjcFixture(plan /*: HeadersSpecPlan */) /*: string */ {
for (const name of plan.privateReactHeaders.modular) {
lines.push(`#import <React/${name}>`);
}
// 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}>`);
Expand Down Expand Up @@ -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<string> */,
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);
Expand Down
Loading