Skip to content

Commit ca4f6ca

Browse files
committed
feat(ios): emit per-namespace umbrella for React_RCTAppDelegate (R10)
The flattened ReactNativeHeaders layout ships the individual React_RCTAppDelegate/*.h headers but no per-namespace umbrella. Consumers like Expo probe `<React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h>` via __has_include (RCTAppDelegateUmbrella.h); with the umbrella gone the probe fails and RCTReactNativeFactory / RCTRootViewFactory are never declared, breaking the Expo pod's clang module. Add R10: emit a per-namespace umbrella (content DERIVED from namespaceModules so it can't drift — e.g. RCTArchConfiguratorProtocol.h, gone from this branch, is correctly omitted) and add it to that namespace's module so the import stays modular under explicit modules. Targeted via UMBRELLA_NAMESPACES (currently just React_RCTAppDelegate, the only umbrella Expo imports); fails closed if a listed namespace loses its modular headers.
1 parent bb1ce58 commit ca4f6ca

3 files changed

Lines changed: 123 additions & 6 deletions

File tree

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

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,27 @@
1010

1111
'use strict';
1212

13-
const {planFromInventory, renderReactModuleMap} = require('../headers-spec');
13+
const fs = require('fs');
14+
const {
15+
planFromInventory,
16+
renderNamespaceModuleMap,
17+
renderReactModuleMap,
18+
} = require('../headers-spec');
19+
20+
// isUmbrellaSafe reads each header's source to reject extern-inline defs. Stub
21+
// it to empty so synthetic objc-modular-candidate headers count as umbrella-safe
22+
// (and thus land in namespaceModules), making these tests deterministic.
23+
jest.spyOn(fs, 'readFileSync').mockReturnValue('');
1424

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.
1825
const entry = (naturalPath /*: string */, bucket /*: string */) => ({
1926
naturalPath,
2027
bucket,
2128
lang: 'objc',
2229
identities: [{source: `does/not/exist/${naturalPath}`}],
2330
});
2431

25-
// A manifest that satisfies the R9 private-header allowlist.
32+
// A manifest satisfying both the R9 private-header allowlist and the R10
33+
// umbrella-namespace allowlist (React_RCTAppDelegate).
2634
const validManifest = () => ({
2735
headers: [
2836
entry('React/RCTBridge+Private.h', 'objc-modular-candidate'),
@@ -32,6 +40,15 @@ const validManifest = () => ({
3240
entry('React/RCTMountingManager.h', 'objc-blocked'),
3341
entry('React/RCTSurfacePresenter.h', 'objc-blocked'),
3442
entry('React/RCTViewComponentView.h', 'objc-blocked'),
43+
entry(
44+
'React_RCTAppDelegate/RCTReactNativeFactory.h',
45+
'objc-modular-candidate',
46+
),
47+
entry(
48+
'React_RCTAppDelegate/RCTRootViewFactory.h',
49+
'objc-modular-candidate',
50+
),
51+
entry('React_RCTAppDelegate/RCTAppDelegate.h', 'objc-modular-candidate'),
3552
],
3653
});
3754

@@ -84,3 +101,42 @@ describe('planFromInventory R9 validation', () => {
84101
expect(() => planFromInventory(m)).toThrow(/not 'objc-modular-candidate'/);
85102
});
86103
});
104+
105+
describe('R10 per-namespace umbrella (React_RCTAppDelegate)', () => {
106+
test('emits a derived umbrella for the namespace', () => {
107+
const plan = planFromInventory(validManifest());
108+
const u = plan.namespaceUmbrellas.find(
109+
x => x.relPath === 'React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h',
110+
);
111+
expect(u).toBeDefined();
112+
if (u == null) {
113+
return;
114+
}
115+
// Imports are relative to the namespace dir, derived from the live set.
116+
expect(u.content).toContain('#import "RCTReactNativeFactory.h"');
117+
expect(u.content).toContain('#import "RCTRootViewFactory.h"');
118+
expect(u.content).toContain('#import "RCTAppDelegate.h"');
119+
expect(u.content).toContain('#ifdef __OBJC__');
120+
// No CocoaPods version boilerplate.
121+
expect(u.content).not.toContain('FOUNDATION_EXPORT');
122+
});
123+
124+
test('module map lists the umbrella so the import stays modular', () => {
125+
const plan = planFromInventory(validManifest());
126+
const mm = renderNamespaceModuleMap(plan.namespaceModules);
127+
expect(mm).toContain('module React_RCTAppDelegate {');
128+
expect(mm).toContain(
129+
'header "React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h"',
130+
);
131+
});
132+
133+
test('fails closed when the umbrella namespace lost its modular headers', () => {
134+
const m = validManifest();
135+
m.headers = m.headers.filter(
136+
x => !x.naturalPath.startsWith('React_RCTAppDelegate/'),
137+
);
138+
expect(() => planFromInventory(m)).toThrow(
139+
/umbrella namespace 'React_RCTAppDelegate'/,
140+
);
141+
});
142+
});

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,14 @@ function buildReactNativeHeadersXcframework(
180180
console.warn(`headers-compose: hermes headers missing at ${src}`);
181181
}
182182
}
183+
// R10: per-namespace umbrella headers (e.g. React_RCTAppDelegate-umbrella.h)
184+
// that consumers like Expo probe via __has_include. Must be staged before the
185+
// module map references them.
186+
for (const u of plan.namespaceUmbrellas) {
187+
const dest = path.join(stage, u.relPath);
188+
fs.mkdirSync(path.dirname(dest), {recursive: true});
189+
fs.writeFileSync(dest, u.content);
190+
}
183191
fs.writeFileSync(
184192
path.join(stage, 'module.modulemap'),
185193
renderNamespaceModuleMap(plan.namespaceModules),

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

Lines changed: 54 additions & 1 deletion
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+
// R10: per-namespace umbrella headers emitted into ReactNativeHeaders.
85+
namespaceUmbrellas: Array<{relPath: string, content: string}>,
8486
// R9: private headers added to the React module map (allowlist).
8587
privateReactHeaders: {modular: Array<string>, textual: Array<string>},
8688
collisions: Array<string>,
@@ -175,6 +177,32 @@ function isUmbrellaSafe(h /*: any */) /*: boolean */ {
175177
}
176178
}
177179

180+
// R10: per-namespace umbrella headers. Some consumers (e.g. Expo's
181+
// RCTAppDelegateUmbrella.h) probe
182+
// `<React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h>` via __has_include.
183+
// The flattened ReactNativeHeaders layout (R2/R5) ships the individual
184+
// namespace headers but no umbrella, so the probe fails and e.g.
185+
// RCTReactNativeFactory / RCTRootViewFactory are never declared. Re-emit a
186+
// per-namespace umbrella for the namespaces consumers probe — content DERIVED
187+
// from namespaceModules (R5) so it can't drift — and add it to that
188+
// namespace's module so the import stays modular under explicit modules.
189+
// Targeted (not all namespaces): only those a consumer imports as
190+
// `<ns/ns-umbrella.h>`. Extend as the ecosystem surfaces more.
191+
const UMBRELLA_NAMESPACES /*: Array<string> */ = ['React_RCTAppDelegate'];
192+
193+
// Renders a per-namespace umbrella that re-imports the namespace's modular
194+
// headers. Paths are relative to the namespace dir (where the umbrella lives),
195+
// so the first `<ns>/` segment is stripped.
196+
function renderNamespaceUmbrella(
197+
ns /*: string */,
198+
headers /*: Array<string> */,
199+
) /*: string */ {
200+
const imports = headers
201+
.map(np => `#import "${np.slice(ns.length + 1)}"`)
202+
.join('\n');
203+
return `#ifdef __OBJC__\n#import <UIKit/UIKit.h>\n#endif\n\n${imports}\n`;
204+
}
205+
178206
/**
179207
* Computes the full layout plan from the header inventory manifest
180208
* (build/header-inventory.json — regenerate with header-inventory.js).
@@ -246,12 +274,30 @@ function planFromInventory(manifest /*: any */) /*: HeadersSpecPlan */ {
246274
namespaceModules[ns].sort();
247275
}
248276

277+
// R10: fail closed if a probed umbrella namespace lost all its modular
278+
// headers (removed/renamed) — the umbrella would silently vanish and
279+
// re-break consumers like Expo.
280+
const namespaceUmbrellas = UMBRELLA_NAMESPACES.map(ns => {
281+
const headers = namespaceModules[ns];
282+
if (headers == null || headers.length === 0) {
283+
throw new Error(
284+
`R10: umbrella namespace '${ns}' has no modular headers in the ` +
285+
`inventory (removed/renamed?). Update UMBRELLA_NAMESPACES.`,
286+
);
287+
}
288+
return {
289+
relPath: `${ns}/${ns}-umbrella.h`,
290+
content: renderNamespaceUmbrella(ns, headers),
291+
};
292+
});
293+
249294
return {
250295
react,
251296
reactNativeHeaders,
252297
depsNamespaces: DEPS_NAMESPACES,
253298
umbrella,
254299
namespaceModules,
300+
namespaceUmbrellas,
255301
privateReactHeaders: PRIVATE_REACT_HEADERS,
256302
collisions,
257303
};
@@ -304,9 +350,16 @@ function renderNamespaceModuleMap(
304350
ns === 'react' ? 'ReactNativeHeaders_react' : ns;
305351
const blocks = [];
306352
for (const ns of Object.keys(namespaceModules).sort()) {
353+
const headerLines = namespaceModules[ns].map(hh => ` header "${hh}"`);
354+
// R10: the per-namespace umbrella is itself a module member, so importing
355+
// it stays modular (otherwise it re-trips -Wnon-modular-include inside the
356+
// consumer's framework module).
357+
if (UMBRELLA_NAMESPACES.includes(ns)) {
358+
headerLines.push(` header "${ns}/${ns}-umbrella.h"`);
359+
}
307360
blocks.push(
308361
`module ${moduleNameFor(ns)} {\n` +
309-
namespaceModules[ns].map(hh => ` header "${hh}"`).join('\n') +
362+
headerLines.join('\n') +
310363
`\n export *\n}`,
311364
);
312365
}

0 commit comments

Comments
 (0)