Skip to content

Commit 30878fc

Browse files
committed
feat(ios-prebuild): generator-time headers gate + drift hardening (R11, verify, ratchet)
Hardens the header layout against new/changed headers so consumer-facing regressions fail the PREBUILD instead of a downstream (rn-tester/Expo/ community) build: - headers-verify.js (new; runs in the prebuild compose CI job): include-health ratchet against a committed baseline (notShipped/unresolved/quoted- unresolvable includes in shipped headers — 27 baselined today); structural byte-compare of the composed module maps/umbrellas against the spec render; compile smokes — an ObjC TU precompiling the React module (every umbrella header) + all 14 R5 namespace modules + the R10 umbrella + __has_include asserts, an Expo-shaped ObjC++ TU (the R9 textual Fabric surface), and a Swift TU (import React + RCTBridge.moduleRegistry). - R11: one source, one content location. 116 sources ship under multiple spellings (React/X.h + CoreModules/RCTImage/RCTAnimation/... forms, bare React_RCTAppDelegate aliases). The flattened layout duplicated their declarations, so any -fmodules consumer touching two spellings (even transitively via a legacy spelling) hit redefinition errors — found by the gate's first run. The module-owned spelling keeps the content; every other spelling is emitted as a one-line redirect shim. - Single source of truth for third-party namespaces: the inventory's include classifier now derives from DEPS_NAMESPACES, and compose enforces set-equality with the deps artifact in BOTH directions (missing OR undeclared namespace fails). The undeclared direction immediately surfaced SocketRocket, which the deps artifact ships but was never relocated. - R5 exemption assert: an invalid-module-identifier namespace gaining a modular-candidate header now fails the plan instead of silently shipping a non-modular header. - Inventory records quoted includes that don't resolve to a shipped header (quotedNotShipped) instead of dropping them. Docs: __docs__/headers-rules.md documents rules R1-R11, the emission pipeline, and the resilience model.
1 parent 26bebea commit 30878fc

8 files changed

Lines changed: 1140 additions & 17 deletions

File tree

.github/workflows/prebuild-ios-core.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,16 @@ jobs:
189189
run: |
190190
cd packages/react-native
191191
node scripts/ios-prebuild -c -f "${{ matrix.flavor }}" -i "React Org"
192+
- name: Verify composed headers
193+
if: steps.restore-ios-xcframework.outputs.cache-hit != 'true'
194+
run: |
195+
# Generator-time header gate: include-health ratchet, structural
196+
# byte-compare of module maps/umbrellas against the spec render, and
197+
# compile smokes (React module + every namespace module + the
198+
# privileged-consumer/Expo fixtures). Catches consumer-facing header
199+
# regressions here instead of in downstream builds.
200+
cd packages/react-native
201+
node scripts/ios-prebuild/headers-verify.js --flavor "${{ matrix.flavor }}"
192202
- name: Compress and Rename XCFramework
193203
if: steps.restore-ios-xcframework.outputs.cache-hit != 'true'
194204
run: |

packages/react-native/scripts/ios-prebuild/__docs__/headers-rules.md

Lines changed: 384 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,15 @@ const fs = require('fs');
2222
// (and thus land in namespaceModules), making these tests deterministic.
2323
jest.spyOn(fs, 'readFileSync').mockReturnValue('');
2424

25-
const entry = (naturalPath /*: string */, bucket /*: string */) => ({
25+
const entry = (
26+
naturalPath /*: string */,
27+
bucket /*: string */,
28+
source /*:: ?: string */,
29+
) => ({
2630
naturalPath,
2731
bucket,
2832
lang: 'objc',
29-
identities: [{source: `does/not/exist/${naturalPath}`}],
33+
identities: [{source: source ?? `does/not/exist/${naturalPath}`}],
3034
});
3135

3236
// A manifest satisfying both the R9 private-header allowlist and the R10
@@ -140,3 +144,113 @@ describe('R10 per-namespace umbrella (React_RCTAppDelegate)', () => {
140144
);
141145
});
142146
});
147+
148+
describe('R5 invalid-identifier exemption assert (H5)', () => {
149+
test('throws when an invalid-identifier namespace gains a modular candidate', () => {
150+
const m = validManifest();
151+
m.headers.push(entry('bad-namespace/Foo.h', 'objc-modular-candidate'));
152+
expect(() => planFromInventory(m)).toThrow(
153+
/namespace 'bad-namespace' is not a valid module identifier/,
154+
);
155+
});
156+
157+
test('invalid-identifier namespaces with only non-modular headers stay exempt', () => {
158+
const m = validManifest();
159+
m.headers.push(entry('jsinspector-modern/Foo.h', 'objcxx'));
160+
expect(() => planFromInventory(m)).not.toThrow();
161+
});
162+
});
163+
164+
describe('R11 redirect shims for dual-identity headers', () => {
165+
test('RNH spelling of a source that also ships as React/ becomes a shim', () => {
166+
const m = validManifest();
167+
m.headers.push(
168+
entry('React/RCTClipboard.h', 'objc-modular-candidate', 'src/clip.h'),
169+
entry(
170+
'CoreModules/RCTClipboard.h',
171+
'objc-modular-candidate',
172+
'src/clip.h',
173+
),
174+
);
175+
const plan = planFromInventory(m);
176+
const shim = plan.reactNativeHeaders.find(
177+
e => e.naturalPath === 'CoreModules/RCTClipboard.h',
178+
);
179+
expect(shim?.redirectTo).toBe('React/RCTClipboard.h');
180+
// The React/ owner keeps its content.
181+
const owner = plan.react.find(
182+
e => e.naturalPath === 'React/RCTClipboard.h',
183+
);
184+
expect(owner?.redirectTo).toBeUndefined();
185+
// The shim stays a namespace-module member (imports the owning module).
186+
expect(plan.namespaceModules.CoreModules).toContain(
187+
'CoreModules/RCTClipboard.h',
188+
);
189+
});
190+
191+
test('bare root alias shims to its RNH namespaced owner', () => {
192+
const m = validManifest();
193+
m.headers.push(
194+
entry('RCTAppDelegate.h', 'objc-modular-candidate', 'src/appdelegate.h'),
195+
);
196+
// Same source as the namespaced form.
197+
const ns = m.headers.find(
198+
x => x.naturalPath === 'React_RCTAppDelegate/RCTAppDelegate.h',
199+
);
200+
if (ns == null) {
201+
throw new Error('fixture missing namespaced RCTAppDelegate.h');
202+
}
203+
ns.identities[0].source = 'src/appdelegate.h';
204+
const plan = planFromInventory(m);
205+
const bare = plan.react.find(e => e.naturalPath === 'RCTAppDelegate.h');
206+
expect(bare?.redirectTo).toBe('React_RCTAppDelegate/RCTAppDelegate.h');
207+
const owner = plan.reactNativeHeaders.find(
208+
e => e.naturalPath === 'React_RCTAppDelegate/RCTAppDelegate.h',
209+
);
210+
expect(owner?.redirectTo).toBeUndefined();
211+
});
212+
213+
test('single-identity headers get no redirect', () => {
214+
const plan = planFromInventory(validManifest());
215+
for (const e of [...plan.react, ...plan.reactNativeHeaders]) {
216+
expect(e.redirectTo).toBeUndefined();
217+
}
218+
});
219+
});
220+
221+
describe('headers-verify gate pieces', () => {
222+
const {
223+
diffAgainstBaseline,
224+
renderObjcFixture,
225+
renderPrivilegedFixture,
226+
} = require('../headers-verify');
227+
228+
test('diffAgainstBaseline ratchets: new offenders fail, resolved reported', () => {
229+
const {newOffenders, resolved} = diffAgainstBaseline(
230+
['a', 'c'],
231+
['a', 'b'],
232+
);
233+
expect(newOffenders).toEqual(['c']);
234+
expect(resolved).toEqual(['b']);
235+
});
236+
237+
test('ObjC fixture asserts and imports the R9/R10 + module surfaces', () => {
238+
const plan = planFromInventory(validManifest());
239+
const tu = renderObjcFixture(plan);
240+
expect(tu).toContain('__has_include(<React/RCTBridge+Private.h>)');
241+
expect(tu).toContain(
242+
'__has_include(<React_RCTAppDelegate/React_RCTAppDelegate-umbrella.h>)',
243+
);
244+
expect(tu).toContain('#import <React/RCTBridge.h>');
245+
expect(tu).toContain('#import <React/RCTBridge+Private.h>');
246+
// One import per namespace module (fixture has React_RCTAppDelegate).
247+
expect(tu).toMatch(/#import <React_RCTAppDelegate\//);
248+
});
249+
250+
test('privileged fixture imports every R9 textual header', () => {
251+
const plan = planFromInventory(validManifest());
252+
const tu = renderPrivilegedFixture(plan);
253+
expect(tu).toContain('#import <React/RCTMountingManager.h>');
254+
expect(tu).toContain('#import <React/RCTViewComponentView.h>');
255+
});
256+
});

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,19 @@ function stageEntries(
5858
for (const e of entries) {
5959
const dest = path.join(stage, e.relPath);
6060
fs.mkdirSync(path.dirname(dest), {recursive: true});
61-
fs.copyFileSync(path.join(rnRoot, e.source), dest);
61+
if (e.redirectTo != null) {
62+
// R11: duplicate spelling of a source that lives elsewhere — emit a
63+
// redirect shim so the declarations exist in exactly one file (module
64+
// ownership and #import-once stay coherent across spellings).
65+
fs.writeFileSync(
66+
dest,
67+
`// @generated by headers-compose (R11): this spelling redirects to\n` +
68+
`// the content's single home so clang modules stay coherent.\n` +
69+
`#import <${e.redirectTo}>\n`,
70+
);
71+
} else {
72+
fs.copyFileSync(path.join(rnRoot, e.source), dest);
73+
}
6274
}
6375
}
6476

@@ -176,6 +188,25 @@ function buildReactNativeHeadersXcframework(
176188
}
177189
execSync(`/bin/cp -Rc "${src}" "${path.join(stage, ns)}"`);
178190
}
191+
// Set equality with the deps artifact: a namespace dir present in the
192+
// artifact but NOT declared in DEPS_NAMESPACES means a new third-party dep
193+
// was added upstream and would silently not be relocated — consumers'
194+
// `<newdep/...>` includes would break. Declare it once in headers-spec.js
195+
// (the include classifier derives from the same list).
196+
const foundDepsDirs = fs
197+
.readdirSync(depsHeaders, {withFileTypes: true})
198+
.filter(e => e.isDirectory())
199+
.map(e => String(e.name));
200+
const undeclared = foundDepsDirs.filter(
201+
d => !plan.depsNamespaces.includes(d),
202+
);
203+
if (undeclared.length > 0) {
204+
throw new Error(
205+
`headers-compose: deps artifact ships undeclared namespace(s): ` +
206+
`${undeclared.join(', ')}. Add them to DEPS_NAMESPACES in ` +
207+
`headers-spec.js so they are relocated into ReactNativeHeaders.`,
208+
);
209+
}
179210
// Hermes public headers (separate source from the deps namespaces — they
180211
// come from the hermes-ios tarball, not ReactNativeDependencies). Vend only
181212
// the `hermes/` namespace; `jsi/` is already provided elsewhere, so copying
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
[
2+
"notShipped react/nativemodule/intersectionobserver/NativeIntersectionObserver.h -> react/renderer/observers/intersection/IntersectionObserverManager.h",
3+
"notShipped react/nativemodule/mutationobserver/NativeMutationObserver.h -> react/renderer/observers/mutation/MutationObserverManager.h",
4+
"notShipped react/renderer/animated/InterpolationAnimatedNode.h -> react/renderer/animated/internal/primitives.h",
5+
"notShipped react/renderer/animated/NativeAnimatedNodesManager.h -> react/renderer/animated/event_drivers/EventAnimationDriver.h",
6+
"notShipped react/renderer/animated/PropsAnimatedNode.h -> react/renderer/animated/internal/primitives.h",
7+
"notShipped react/renderer/mounting/MountingCoordinator.h -> react/renderer/mounting/stubs/stubs.h",
8+
"notShipped react/renderer/mounting/StubViewTree.h -> react/renderer/mounting/stubs/StubView.h",
9+
"notShipped react/renderer/mounting/stubs.h -> react/renderer/mounting/stubs/StubView.h",
10+
"notShipped react/renderer/mounting/stubs.h -> react/renderer/mounting/stubs/StubViewTree.h",
11+
"quotedNotShipped RCTAnimation/RCTEventAnimation.h -> \"RCTValueAnimatedNode.h\"",
12+
"quotedNotShipped RCTAnimation/RCTNativeAnimatedModule.h -> \"RCTValueAnimatedNode.h\"",
13+
"quotedNotShipped RCTAnimation/RCTNativeAnimatedTurboModule.h -> \"RCTValueAnimatedNode.h\"",
14+
"quotedNotShipped React/RCTEventAnimation.h -> \"RCTValueAnimatedNode.h\"",
15+
"quotedNotShipped React/RCTNativeAnimatedModule.h -> \"RCTValueAnimatedNode.h\"",
16+
"quotedNotShipped React/RCTNativeAnimatedTurboModule.h -> \"RCTValueAnimatedNode.h\"",
17+
"quotedNotShipped react/nativemodule/dom/NativeDOM.h -> \"FBReactNativeSpecJSI.h\"",
18+
"quotedNotShipped react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h -> \"FBReactNativeSpecJSI.h\"",
19+
"quotedNotShipped react/nativemodule/idlecallbacks/NativeIdleCallbacks.h -> \"FBReactNativeSpecJSI.h\"",
20+
"quotedNotShipped react/nativemodule/intersectionobserver/NativeIntersectionObserver.h -> \"FBReactNativeSpecJSI.h\"",
21+
"quotedNotShipped react/nativemodule/microtasks/NativeMicrotasks.h -> \"FBReactNativeSpecJSI.h\"",
22+
"quotedNotShipped react/nativemodule/mutationobserver/NativeMutationObserver.h -> \"FBReactNativeSpecJSI.h\"",
23+
"quotedNotShipped react/nativemodule/viewtransition/NativeViewTransition.h -> \"FBReactNativeSpecJSI.h\"",
24+
"quotedNotShipped react/nativemodule/webperformance/NativePerformance.h -> \"FBReactNativeSpecJSI.h\"",
25+
"quotedNotShipped react/nativemodule/webperformance/NativePerformance.h -> \"rncoreJSI.h\"",
26+
"quotedNotShipped react/renderer/animated/AnimatedModule.h -> \"FBReactNativeSpecJSI.h\"",
27+
"quotedNotShipped react/renderer/animated/NativeAnimatedNodesManager.h -> \"FBReactNativeSpecJSI.h\"",
28+
"quotedNotShipped reactperflogger/FuseboxTracer.h -> \"folly/json/dynamic.h\""
29+
]

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

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,23 @@ type HeaderEntry = {
6262
otherPlatform: Array<string>,
6363
notShipped: Array<string>,
6464
unresolved: Array<string>,
65+
// Quoted includes that do not resolve to a shipped header. Fine inside the
66+
// framework binary's own compilation, but a consumer compiling the shipped
67+
// header hits "file not found" (source builds mask this via pod header
68+
// maps). Gated by the include-health ratchet (headers-verify.js).
69+
quotedNotShipped: Array<string>,
6570
},
6671
};
6772
*/
6873

6974
// Third-party C++ libraries that RN's public headers re-expose (Tier 3 of the
70-
// modularization doc). Keyed by the first include-path segment.
71-
const THIRD_PARTY_LIBS /*: Set<string> */ = new Set([
72-
'folly',
73-
'boost',
74-
'fmt',
75-
'glog',
76-
'double-conversion',
77-
'fast_float',
78-
]);
75+
// modularization doc). Keyed by the first include-path segment. Single source
76+
// of truth: the spec's DEPS_NAMESPACES (the namespaces relocated into
77+
// ReactNativeHeaders) — a new third-party dep is declared ONCE and both the
78+
// include classifier and the compose step follow. (headers-spec.js requires
79+
// only fs/path, so this cannot cycle.)
80+
const {DEPS_NAMESPACES} = require('./headers-spec');
81+
const THIRD_PARTY_LIBS /*: Set<string> */ = new Set(DEPS_NAMESPACES);
7982

8083
// Apple SDK / platform include roots (first path segment). Includes resolving
8184
// here are "system": always modular or always available, never our problem.
@@ -295,6 +298,7 @@ function buildInventory(rootFolder /*: string */) /*: {
295298
otherPlatform: [],
296299
notShipped: [],
297300
unresolved: [],
301+
quotedNotShipped: [],
298302
},
299303
};
300304
entries.set(naturalPath, entry);
@@ -424,9 +428,14 @@ function classifyEntries(
424428
naturalPath: naturals[0],
425429
cxxGuarded: inc.cxxGuarded,
426430
});
431+
} else {
432+
// A quoted include in a SHIPPED header that doesn't land on another
433+
// shipped header: works in source builds (pod header maps / sibling
434+
// files) but has no resolution target in the packaged layout when a
435+
// consumer compiles this header. Recorded for the include-health
436+
// ratchet rather than silently dropped.
437+
entry.includes.quotedNotShipped.push(token);
427438
}
428-
// Quoted includes that don't land on a shipped header are
429-
// pod-internal/private — not part of the public surface contract.
430439
continue;
431440
}
432441
if (entries.has(token)) {

0 commit comments

Comments
 (0)