Skip to content

Commit bb1ce58

Browse files
committed
feat(ios): expose allowlisted private React headers in the React module (R9)
The public-umbrella model (which replaced the VFS overlay) excludes `+Private` and objc-blocked headers from React.framework's module, so privileged framework consumers (e.g. Expo) that `#import <React/RCTBridge+Private.h>`, `<React/RCTMountingManager.h>`, etc. fail to compile under explicit modules even though the headers still ship in React.framework/Headers. Add R9: a curated allowlist appended to the React module map — `RCTBridge+Private.h` as a real `header` (objc-modular-candidate, reaches no C++) and the six Fabric headers as `textual header` (objc-blocked; a real member would re-trip -Wnon-modular-include, and their <react/...> C++ includes resolve at the consumer's use site). Backwards-compatible: existing `#import <React/...>` (and Swift `import React`) sites are unchanged. Fails closed if an allowlisted header is removed/renamed or drifts bucket. Note: RCTUIKit.h / RCTRootContentView.h are absent from source entirely and need restoration, not exposure — out of scope here.
1 parent 1f6cdb2 commit bb1ce58

3 files changed

Lines changed: 166 additions & 4 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow strict-local
8+
* @format
9+
*/
10+
11+
'use strict';
12+
13+
const {planFromInventory, renderReactModuleMap} = require('../headers-spec');
14+
15+
// Minimal inventory entry. isUmbrellaSafe reads the source off disk for React/
16+
// headers; the synthetic paths don't exist so it falls back to false — which is
17+
// fine here, these tests exercise the R9 allowlist, not umbrella membership.
18+
const entry = (naturalPath /*: string */, bucket /*: string */) => ({
19+
naturalPath,
20+
bucket,
21+
lang: 'objc',
22+
identities: [{source: `does/not/exist/${naturalPath}`}],
23+
});
24+
25+
// A manifest that satisfies the R9 private-header allowlist.
26+
const validManifest = () => ({
27+
headers: [
28+
entry('React/RCTBridge+Private.h', 'objc-modular-candidate'),
29+
entry('React/RCTComponentViewFactory.h', 'objc-blocked'),
30+
entry('React/RCTComponentViewProtocol.h', 'objc-blocked'),
31+
entry('React/RCTComponentViewRegistry.h', 'objc-blocked'),
32+
entry('React/RCTMountingManager.h', 'objc-blocked'),
33+
entry('React/RCTSurfacePresenter.h', 'objc-blocked'),
34+
entry('React/RCTViewComponentView.h', 'objc-blocked'),
35+
],
36+
});
37+
38+
describe('renderReactModuleMap (R9 private headers)', () => {
39+
test('appends modular allowlist as `header` and objc-blocked as `textual header`', () => {
40+
const out = renderReactModuleMap({
41+
modular: ['RCTBridge+Private.h'],
42+
textual: ['RCTMountingManager.h'],
43+
});
44+
expect(out).toContain('umbrella header "React-umbrella.h"');
45+
expect(out).toContain(' header "RCTBridge+Private.h"');
46+
expect(out).toContain(' textual header "RCTMountingManager.h"');
47+
// A textual header must NOT also appear as a plain modular `header`.
48+
expect(out).not.toMatch(/^\s*header "RCTMountingManager\.h"/m);
49+
});
50+
51+
test('with no private headers renders just the umbrella (backwards compatible)', () => {
52+
const out = renderReactModuleMap();
53+
expect(out).toContain('umbrella header "React-umbrella.h"');
54+
expect(out).not.toContain('textual header');
55+
});
56+
});
57+
58+
describe('planFromInventory R9 validation', () => {
59+
test('passes for a valid allowlist and exposes privateReactHeaders', () => {
60+
const plan = planFromInventory(validManifest());
61+
expect(plan.privateReactHeaders.modular).toContain('RCTBridge+Private.h');
62+
expect(plan.privateReactHeaders.textual).toContain('RCTMountingManager.h');
63+
});
64+
65+
test('throws when an allowlisted header is absent from the inventory', () => {
66+
const m = validManifest();
67+
m.headers = m.headers.filter(
68+
x => x.naturalPath !== 'React/RCTBridge+Private.h',
69+
);
70+
expect(() => planFromInventory(m)).toThrow(
71+
/RCTBridge\+Private\.h is absent/,
72+
);
73+
});
74+
75+
test('throws when a modular allowlist header is no longer objc-modular-candidate', () => {
76+
const m = validManifest();
77+
const h = m.headers.find(
78+
x => x.naturalPath === 'React/RCTBridge+Private.h',
79+
);
80+
if (h == null) {
81+
throw new Error('fixture missing RCTBridge+Private.h');
82+
}
83+
h.bucket = 'objc-blocked';
84+
expect(() => planFromInventory(m)).toThrow(/not 'objc-modular-candidate'/);
85+
});
86+
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ function emitReactFrameworkHeaders(
9898
fs.mkdirSync(path.join(fwk, 'Modules'), {recursive: true});
9999
fs.writeFileSync(
100100
path.join(fwk, 'Modules', 'module.modulemap'),
101-
renderReactModuleMap(),
101+
renderReactModuleMap(plan.privateReactHeaders),
102102
);
103103
}
104104
fs.rmSync(stage, {recursive: true, force: true});

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

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export type HeadersSpecPlan = {
8181
umbrella: Array<string>,
8282
// R5: plain modules for ReactNativeHeaders' module.modulemap
8383
namespaceModules: {[ns: string]: Array<string>},
84+
// R9: private headers added to the React module map (allowlist).
85+
privateReactHeaders: {modular: Array<string>, textual: Array<string>},
8486
collisions: Array<string>,
8587
};
8688
*/
@@ -101,6 +103,65 @@ const EXTERN_INLINE_RE /*: RegExp */ =
101103

102104
const MODULE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
103105

106+
// R9: Private React headers — a curated allowlist of `<React/...>` headers that
107+
// privileged framework consumers (e.g. Expo) import, but which the public
108+
// umbrella (R4) excludes (they are `+`-suffixed and/or objc-blocked). They are
109+
// already shipped in React.framework/Headers; adding them to the React module
110+
// map keeps the existing `#import <React/...>` sites MODULAR under explicit
111+
// modules — backwards-compatible, no consumer import (or Swift) changes. Split
112+
// by inventory bucket:
113+
// - modular: objc-modular-candidate (reach no C++) -> real `header`.
114+
// - textual: objc-blocked (reach C++ via `<react/...>`) -> `textual header`
115+
// (a real member would re-trip -Wnon-modular-include; the C++ includes
116+
// resolve at the consumer's use site, exactly as under the old VFS overlay).
117+
// Privacy is by convention (the `+Private`/internal naming): a single binary
118+
// artifact cannot hard-gate apps from headers a framework legitimately needs.
119+
const PRIVATE_REACT_HEADERS /*: {modular: Array<string>, textual: Array<string>} */ =
120+
{
121+
modular: ['RCTBridge+Private.h'],
122+
textual: [
123+
'RCTComponentViewFactory.h',
124+
'RCTComponentViewProtocol.h',
125+
'RCTComponentViewRegistry.h',
126+
'RCTMountingManager.h',
127+
'RCTSurfacePresenter.h',
128+
'RCTViewComponentView.h',
129+
],
130+
};
131+
132+
// Fail closed if an allowlisted private header drifts: it must exist in the
133+
// inventory (else it was removed/renamed in source — e.g. RCTUIKit.h /
134+
// RCTRootContentView.h, which need restoration, NOT this allowlist), and a
135+
// `modular` entry must really be objc-modular-candidate (else it now reaches
136+
// C++/third-party and must move to `textual`).
137+
function validatePrivateReactHeaders(manifest /*: any */) /*: void */ {
138+
const byNatural = new Map(manifest.headers.map(h => [h.naturalPath, h]));
139+
const requireShipped = (name /*: string */) => {
140+
const e = byNatural.get(`React/${name}`);
141+
if (e == null) {
142+
throw new Error(
143+
`Private React header allowlist: React/${name} is absent from the ` +
144+
`inventory (removed/renamed in source?). Restore the header or remove ` +
145+
`it from PRIVATE_REACT_HEADERS.`,
146+
);
147+
}
148+
return e;
149+
};
150+
for (const name of PRIVATE_REACT_HEADERS.modular) {
151+
const e = requireShipped(name);
152+
if (e.bucket !== 'objc-modular-candidate') {
153+
throw new Error(
154+
`Private React header React/${name} is bucket '${e.bucket}', not ` +
155+
`'objc-modular-candidate' — it now reaches C++/third-party. Move it ` +
156+
`to PRIVATE_REACT_HEADERS.textual.`,
157+
);
158+
}
159+
}
160+
for (const name of PRIVATE_REACT_HEADERS.textual) {
161+
requireShipped(name);
162+
}
163+
}
164+
104165
function isUmbrellaSafe(h /*: any */) /*: boolean */ {
105166
if (h.bucket !== 'objc-modular-candidate' || h.naturalPath.includes('+')) {
106167
return false;
@@ -119,6 +180,7 @@ function isUmbrellaSafe(h /*: any */) /*: boolean */ {
119180
* (build/header-inventory.json — regenerate with header-inventory.js).
120181
*/
121182
function planFromInventory(manifest /*: any */) /*: HeadersSpecPlan */ {
183+
validatePrivateReactHeaders(manifest); // R9: fail closed on allowlist drift
122184
const react /*: Array<SpecEntry> */ = [];
123185
const reactNativeHeaders /*: Array<SpecEntry> */ = [];
124186
const umbrella /*: Array<string> */ = [];
@@ -190,14 +252,28 @@ function planFromInventory(manifest /*: any */) /*: HeadersSpecPlan */ {
190252
depsNamespaces: DEPS_NAMESPACES,
191253
umbrella,
192254
namespaceModules,
255+
privateReactHeaders: PRIVATE_REACT_HEADERS,
193256
collisions,
194257
};
195258
}
196259

197-
/** Renders React.framework's module map (R4). */
198-
function renderReactModuleMap() /*: string */ {
260+
/**
261+
* Renders React.framework's module map (R4 + R9). The umbrella covers the
262+
* public modular surface; the allowlisted private headers (R9) are appended as
263+
* explicit `header` (modular) / `textual header` (objc-blocked) entries so
264+
* `#import <React/...>` of them stays modular without polluting the umbrella.
265+
*/
266+
function renderReactModuleMap(
267+
privateReactHeaders /*:: ?: {modular: Array<string>, textual: Array<string>} */,
268+
) /*: string */ {
269+
const pv = privateReactHeaders ?? {modular: [], textual: []};
270+
const extra = [
271+
...pv.modular.map(h => ` header "${h}"`),
272+
...pv.textual.map(h => ` textual header "${h}"`),
273+
];
274+
const extraBlock = extra.length > 0 ? '\n' + extra.join('\n') : '';
199275
return `framework module React {
200-
umbrella header "React-umbrella.h"
276+
umbrella header "React-umbrella.h"${extraBlock}
201277
export *
202278
module * { export * }
203279
}

0 commit comments

Comments
 (0)