Skip to content

Commit 3a57ac3

Browse files
chrfalchclaude
andcommitted
fix(ios-prebuild): address review on prebuilt framework resources
Review feedback on embedding React.framework's non-header resources: - Privacy-manifest merge no longer fabricates an empty NSPrivacyAccessedAPITypeReasons key when a source omitted it (tracks per-category key presence), so a single manifest truly passes through unchanged. - NSPrivacyCollectedDataTypes dedup canonicalizes (recursively sorts) keys before hashing, so two pods declaring the same dict in different key order dedup. - RCTI18nStrings.bundle is now built ONCE into a temp stage and cloned into each slice (mirrors the privacy manifest) instead of rebuilt inside the slice loop; i18nLocales is computed once. - i18n bundle Info.plist gains CFBundleShortVersionString / CFBundleVersion so Apple validation tooling doesn't warn on a version-less bundle. - Fixed stale "see" pointers ios-prebuild/{i18n,privacy}.js -> framework-resources.js (React-Core.podspec + rncore_facades.rb). - Tests: serialize->readPrivacyManifest round-trip, order-insensitive collected-type dedup, omitted-reasons-key regression, CFBundleVersion presence, and an emitReactFrameworkHeaders integration test asserting PrivacyInfo.xcprivacy + RCTI18nStrings.bundle land in EVERY slice. Note: resources are written after _CodeSignature is stripped (R7 — signed after compose), so no signature invalidation; readdirSync({recursive})/cpSync are safe on the repo's Node >=22 engine. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bfa5524 commit 3a57ac3

5 files changed

Lines changed: 182 additions & 19 deletions

File tree

packages/react-native/React-Core.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ Pod::Spec.new do |s|
125125
# (not merge) the first. RCTI18nStrings holds React-Core's localized strings
126126
# (loaded by RCTLocalizedString); React-Core_privacy is the privacy manifest.
127127
# (Prebuilt/SwiftPM get both from inside React.xcframework instead — see
128-
# scripts/ios-prebuild/{i18n,privacy}.js — but source builds ship them here.)
128+
# scripts/ios-prebuild/framework-resources.js — but source builds ship them here.)
129129
s.resource_bundles = {
130130
'RCTI18nStrings' => ['React/I18n/strings/*.lproj'],
131131
'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy',

packages/react-native/scripts/cocoapods/rncore_facades.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def self.generate(react_native_path, install_root, version, ios_version)
136136

137137
# NOTE: the facade carries NO resources. The pods' non-code resources
138138
# (e.g. the privacy manifest) are embedded directly in the prebuilt
139-
# React.xcframework by the ios-prebuild compose (see ios-prebuild/privacy.js),
139+
# React.xcframework by the ios-prebuild compose (see ios-prebuild/framework-resources.js),
140140
# so they reach both CocoaPods-prebuilt and SwiftPM from the artifact —
141141
# the facade only needs to declare the React-Core-prebuilt dependency.
142142

packages/react-native/scripts/ios-prebuild/__tests__/framework-resources-test.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ const {
1717
collectReactPrivacyManifestPaths,
1818
i18nBundleInfoPlist,
1919
mergePrivacyManifests,
20+
readPrivacyManifest,
21+
serializePrivacyManifest,
2022
} = require('../framework-resources');
23+
const {emitReactFrameworkHeaders} = require('../headers-compose');
2124
const fs = require('fs');
2225
const os = require('os');
2326
const path = require('path');
@@ -128,6 +131,57 @@ describe('mergePrivacyManifests', () => {
128131
]);
129132
expect(merged.NSPrivacyCollectedDataTypes).toHaveLength(1);
130133
});
134+
135+
it('dedups collected data types regardless of source key order', () => {
136+
// Same dict, different key order (order comes from each plist) must dedup.
137+
const merged = mergePrivacyManifests([
138+
{
139+
NSPrivacyCollectedDataTypes: [
140+
{
141+
NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeCrashData',
142+
NSPrivacyCollectedDataTypeLinked: false,
143+
},
144+
],
145+
},
146+
{
147+
NSPrivacyCollectedDataTypes: [
148+
{
149+
NSPrivacyCollectedDataTypeLinked: false,
150+
NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeCrashData',
151+
},
152+
],
153+
},
154+
]);
155+
expect(merged.NSPrivacyCollectedDataTypes).toHaveLength(1);
156+
});
157+
158+
it('does not fabricate a reasons key when the source omitted it', () => {
159+
const merged = mergePrivacyManifests([
160+
{
161+
NSPrivacyAccessedAPITypes: [
162+
{NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryOther'},
163+
],
164+
},
165+
]);
166+
const entry = merged.NSPrivacyAccessedAPITypes[0];
167+
expect(entry.NSPrivacyAccessedAPIType).toBe(
168+
'NSPrivacyAccessedAPICategoryOther',
169+
);
170+
expect('NSPrivacyAccessedAPITypeReasons' in entry).toBe(false);
171+
});
172+
});
173+
174+
describe('serialize/read privacy-manifest round-trip', () => {
175+
it('serialize -> readPrivacyManifest yields the same object (guards the plist.build byte shape)', () => {
176+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'privacy-rt-'));
177+
const file = path.join(tmp, 'PrivacyInfo.xcprivacy');
178+
try {
179+
fs.writeFileSync(file, serializePrivacyManifest(reactCore));
180+
expect(readPrivacyManifest(file)).toEqual(reactCore);
181+
} finally {
182+
fs.rmSync(tmp, {recursive: true, force: true});
183+
}
184+
});
131185
});
132186

133187
describe('buildReactPrivacyManifest (against the real source tree)', () => {
@@ -164,6 +218,9 @@ describe('i18nBundleInfoPlist', () => {
164218
expect(typeof info.CFBundleIdentifier).toBe('string');
165219
expect(info.CFBundleIdentifier.length).toBeGreaterThan(0);
166220
expect(typeof info.CFBundleDevelopmentRegion).toBe('string');
221+
// Versioned so Apple validation tooling doesn't warn on a version-less bundle.
222+
expect(info.CFBundleShortVersionString).toBeDefined();
223+
expect(info.CFBundleVersion).toBeDefined();
167224
});
168225
});
169226

@@ -213,3 +270,65 @@ describe('buildI18nStringsBundle', () => {
213270
fs.rmSync(emptyRn, {recursive: true, force: true});
214271
});
215272
});
273+
274+
describe('emitReactFrameworkHeaders resource landing (integration)', () => {
275+
let tmp;
276+
let rnRoot;
277+
let xcfw;
278+
279+
beforeEach(() => {
280+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'emit-res-'));
281+
282+
// Minimal fake source tree: one privacy manifest + one locale.
283+
rnRoot = path.join(tmp, 'rn');
284+
const privacyDir = path.join(rnRoot, 'React', 'Resources');
285+
fs.mkdirSync(privacyDir, {recursive: true});
286+
fs.writeFileSync(
287+
path.join(privacyDir, 'PrivacyInfo.xcprivacy'),
288+
serializePrivacyManifest(reactCore),
289+
);
290+
const lproj = path.join(rnRoot, 'React', 'I18n', 'strings', 'en.lproj');
291+
fs.mkdirSync(lproj, {recursive: true});
292+
fs.writeFileSync(
293+
path.join(lproj, 'Localizable.strings'),
294+
'"key" = "value";\n',
295+
);
296+
297+
// Two-slice xcframework, each carrying an empty React.framework.
298+
xcfw = path.join(tmp, 'React.xcframework');
299+
for (const slice of ['ios-arm64', 'ios-arm64_x86_64-simulator']) {
300+
fs.mkdirSync(path.join(xcfw, slice, 'React.framework'), {
301+
recursive: true,
302+
});
303+
}
304+
});
305+
306+
afterEach(() => {
307+
fs.rmSync(tmp, {recursive: true, force: true});
308+
});
309+
310+
it('lands PrivacyInfo.xcprivacy and RCTI18nStrings.bundle into EVERY slice', () => {
311+
// Empty header plan — this test targets the non-header resources only.
312+
emitReactFrameworkHeaders(
313+
xcfw,
314+
{
315+
react: [],
316+
umbrella: [],
317+
privateReactHeaders: {modular: [], textual: []},
318+
},
319+
rnRoot,
320+
);
321+
322+
for (const slice of ['ios-arm64', 'ios-arm64_x86_64-simulator']) {
323+
const fwk = path.join(xcfw, slice, 'React.framework');
324+
expect(fs.existsSync(path.join(fwk, 'PrivacyInfo.xcprivacy'))).toBe(true);
325+
const bundle = path.join(fwk, 'RCTI18nStrings.bundle');
326+
expect(fs.existsSync(path.join(bundle, 'Info.plist'))).toBe(true);
327+
expect(fs.existsSync(path.join(bundle, 'en.lproj'))).toBe(true);
328+
// The composed module map is present too (proves the slice was rewritten).
329+
expect(fs.existsSync(path.join(fwk, 'Modules', 'module.modulemap'))).toBe(
330+
true,
331+
);
332+
}
333+
});
334+
});

packages/react-native/scripts/ios-prebuild/framework-resources.js

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,22 @@ type PrivacyManifest = {
5555
// Privacy manifest
5656
// ---------------------------------------------------------------------------
5757

58+
// Recursively sorts object keys so structurally-equal values serialize
59+
// identically regardless of source key order (arrays keep their order).
60+
function canonicalize(value /*: mixed */) /*: mixed */ {
61+
if (Array.isArray(value)) {
62+
return value.map(canonicalize);
63+
}
64+
if (value != null && typeof value === 'object') {
65+
const out /*: {[string]: mixed} */ = {};
66+
for (const k of Object.keys(value).sort()) {
67+
out[k] = canonicalize(value[k]);
68+
}
69+
return out;
70+
}
71+
return value;
72+
}
73+
5874
/**
5975
* Merges Apple privacy manifests into one. Pure; operates on parsed plist
6076
* objects. Semantics:
@@ -67,6 +83,10 @@ function mergePrivacyManifests(
6783
manifests /*: Array<?PrivacyManifest> */,
6884
) /*: PrivacyManifest */ {
6985
const reasonsByType /*: Map<string, Array<string>> */ = new Map();
86+
// Categories where at least one source actually declared the reasons key, so
87+
// a source that omitted it doesn't get a fabricated empty-array key on the way
88+
// out (keeps "a single manifest passes through unchanged" honest).
89+
const typeHadReasonsKey /*: Set<string> */ = new Set();
7090
const typeOrder /*: Array<string> */ = [];
7191
const trackingDomains /*: Set<string> */ = new Set();
7292
const collected /*: Array<unknown> */ = [];
@@ -83,11 +103,14 @@ function mergePrivacyManifests(
83103
reasonsByType.set(category, []);
84104
typeOrder.push(category);
85105
}
86-
const reasons = reasonsByType.get(category);
87-
if (reasons != null) {
88-
for (const reason of entry.NSPrivacyAccessedAPITypeReasons ?? []) {
89-
if (!reasons.includes(reason)) {
90-
reasons.push(reason);
106+
if (entry.NSPrivacyAccessedAPITypeReasons != null) {
107+
typeHadReasonsKey.add(category);
108+
const reasons = reasonsByType.get(category);
109+
if (reasons != null) {
110+
for (const reason of entry.NSPrivacyAccessedAPITypeReasons) {
111+
if (!reasons.includes(reason)) {
112+
reasons.push(reason);
113+
}
91114
}
92115
}
93116
}
@@ -96,7 +119,9 @@ function mergePrivacyManifests(
96119
trackingDomains.add(domain);
97120
}
98121
for (const dataType of manifest.NSPrivacyCollectedDataTypes ?? []) {
99-
const key = JSON.stringify(dataType) ?? '';
122+
// Canonicalize (sort object keys recursively) before keying so two pods
123+
// declaring the same data-type dict in different key order still dedup.
124+
const key = JSON.stringify(canonicalize(dataType)) ?? '';
100125
if (!collectedSeen.has(key)) {
101126
collectedSeen.add(key);
102127
collected.push(dataType);
@@ -108,10 +133,16 @@ function mergePrivacyManifests(
108133
}
109134

110135
const merged /*: PrivacyManifest */ = {
111-
NSPrivacyAccessedAPITypes: typeOrder.map(category => ({
112-
NSPrivacyAccessedAPIType: category,
113-
NSPrivacyAccessedAPITypeReasons: reasonsByType.get(category) ?? [],
114-
})),
136+
NSPrivacyAccessedAPITypes: typeOrder.map(category => {
137+
const entry /*: AccessedAPIType */ = {
138+
NSPrivacyAccessedAPIType: category,
139+
};
140+
if (typeHadReasonsKey.has(category)) {
141+
entry.NSPrivacyAccessedAPITypeReasons =
142+
reasonsByType.get(category) ?? [];
143+
}
144+
return entry;
145+
}),
115146
NSPrivacyCollectedDataTypes: collected,
116147
NSPrivacyTracking: tracking,
117148
};
@@ -182,6 +213,9 @@ function i18nBundleInfoPlist() /*: {[string]: string} */ {
182213
CFBundleInfoDictionaryVersion: '6.0',
183214
CFBundleName: 'RCTI18nStrings',
184215
CFBundlePackageType: 'BNDL',
216+
// Present so Apple validation tooling doesn't warn on a version-less bundle.
217+
CFBundleShortVersionString: '1.0',
218+
CFBundleVersion: '1',
185219
};
186220
}
187221

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,15 @@ function emitReactFrameworkHeaders(
145145
// for both CocoaPods-prebuilt and SwiftPM (source builds get them from the
146146
// podspecs instead). Built once, embedded per slice.
147147
const privacyManifest = buildReactPrivacyManifest(rnRoot);
148-
let i18nLocales = 0;
148+
149+
// Build RCTI18nStrings.bundle ONCE into a temp stage, then clone it into each
150+
// slice below — mirrors the privacy manifest (computed once, embedded per
151+
// slice) instead of rebuilding the bundle inside the slice loop.
152+
const i18nStage = fs.mkdtempSync(
153+
path.join(path.dirname(xcfwPath), '.i18n-stage-'),
154+
);
155+
const i18nBundleStage = path.join(i18nStage, 'RCTI18nStrings.bundle');
156+
const i18nLocales = buildI18nStringsBundle(rnRoot, i18nBundleStage);
149157

150158
for (const slice of slices) {
151159
const fwk = path.join(xcfwPath, slice, 'React.framework');
@@ -163,14 +171,16 @@ function emitReactFrameworkHeaders(
163171
serializePrivacyManifest(privacyManifest),
164172
);
165173
}
166-
// Embed React-Core's localized strings as RCTI18nStrings.bundle so the
167-
// framework-aware RCTLocalizedString loader resolves them in prebuilt/SPM.
168-
i18nLocales = buildI18nStringsBundle(
169-
rnRoot,
170-
path.join(fwk, 'RCTI18nStrings.bundle'),
171-
);
174+
// Clone the prebuilt RCTI18nStrings.bundle so the framework-aware
175+
// RCTLocalizedString loader resolves React-Core's strings in prebuilt/SPM.
176+
if (i18nLocales > 0) {
177+
const dest = path.join(fwk, 'RCTI18nStrings.bundle');
178+
fs.rmSync(dest, {recursive: true, force: true});
179+
execFileSync('/bin/cp', ['-Rc', i18nBundleStage, dest]);
180+
}
172181
}
173182
fs.rmSync(stage, {recursive: true, force: true});
183+
fs.rmSync(i18nStage, {recursive: true, force: true});
174184
console.log(
175185
`headers-compose: React.framework spec layout -> ${slices.join(', ')} ` +
176186
`(${plan.react.length} headers, umbrella ${plan.umbrella.length}` +

0 commit comments

Comments
 (0)