Skip to content

Commit 26bebea

Browse files
chrfalchclaude
andcommitted
fix(ios): harden prebuilt header layout consumption (review findings)
- copy facade resources into the facade pod dir: CocoaPods file accessors cannot glob past the pod root, so the ..-escaping globs shipped the privacy-manifest / i18n resource bundles empty - quote -fmodule-map-file so a PODS_ROOT containing spaces stays a single clang argument (matches the quoted HEADER_SEARCH_PATHS beside it) - fail closed in React-Core-prebuilt's prepare_command and in replace-rncore-version.js when the tarball lacks ReactNativeHeaders.xcframework, instead of silently leaving an empty or deleted Headers/ with the module-map flag dangling - thread rnRoot through planFromInventory/isUmbrellaSafe instead of the hardcoded hosting-package root (the one spot that didn't take the inventory's root) - drop the dead ios-prebuild templates/ files (their only consumers were removed by the headers-spec compose) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5c8897f commit 26bebea

9 files changed

Lines changed: 104 additions & 346 deletions

File tree

packages/react-native/React-Core-prebuilt.podspec

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,25 @@ Pod::Spec.new do |s|
4747
XCFRAMEWORK_PATH="${CURRENT_PATH}/React.xcframework"
4848
4949
# Flatten ReactNativeHeaders' headers (identical across slices) into Headers/
50-
# BEFORE we sweep stray root entries into React.xcframework.
50+
# BEFORE we sweep stray root entries into React.xcframework. Fail closed:
51+
# a tarball without ReactNativeHeaders.xcframework (an artifact published
52+
# before the headers-spec layout, or a truncated download) would otherwise
53+
# yield a green install with an empty Headers/ and every <react/...> or
54+
# <yoga/...> include failing much later, far from the cause.
5155
mkdir -p Headers
5256
RNH_XCFRAMEWORK_PATH=$(find "$CURRENT_PATH" -type d -name "ReactNativeHeaders.xcframework" | head -n 1)
53-
if [ -n "$RNH_XCFRAMEWORK_PATH" ]; then
54-
RNH_HEADERS_PATH=$(find "$RNH_XCFRAMEWORK_PATH" -type d -name "Headers" | head -n 1)
55-
if [ -n "$RNH_HEADERS_PATH" ]; then
56-
cp -R "$RNH_HEADERS_PATH/." Headers
57-
fi
58-
rm -rf "$RNH_XCFRAMEWORK_PATH"
57+
if [ -z "$RNH_XCFRAMEWORK_PATH" ]; then
58+
echo "[React-Core-prebuilt] ERROR: ReactNativeHeaders.xcframework not found in the prebuilt tarball." >&2
59+
echo "The artifact predates the headers-spec layout or is incomplete; use a matching react-native version." >&2
60+
exit 1
61+
fi
62+
RNH_HEADERS_PATH=$(find "$RNH_XCFRAMEWORK_PATH" -type d -name "Headers" | head -n 1)
63+
if [ -z "$RNH_HEADERS_PATH" ]; then
64+
echo "[React-Core-prebuilt] ERROR: no Headers directory inside $RNH_XCFRAMEWORK_PATH." >&2
65+
exit 1
5966
fi
67+
cp -R "$RNH_HEADERS_PATH/." Headers
68+
rm -rf "$RNH_XCFRAMEWORK_PATH"
6069
6170
mkdir -p "${XCFRAMEWORK_PATH}"
6271
find "$CURRENT_PATH" -mindepth 1 -maxdepth 1 \

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,8 @@ def self.add_prebuilt_header_search_paths(attributes, headers_search_path)
563563
# (`yoga`, `RCTDeprecation`, `ReactNativeHeaders_react`, ...) are modular —
564564
# otherwise the React framework's clang explicit-module precompile trips
565565
# -Wnon-modular-include-in-framework-module on `<yoga/...>` / `<react/...>`.
566-
module_map_flag = " -fmodule-map-file=$(PODS_ROOT)/React-Core-prebuilt/Headers/module.modulemap"
566+
# Quoted so a $(PODS_ROOT) containing spaces stays a single clang argument.
567+
module_map_flag = " \"-fmodule-map-file=$(PODS_ROOT)/React-Core-prebuilt/Headers/module.modulemap\""
567568
ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_CFLAGS", module_map_flag)
568569
ReactNativePodsUtils.add_flag_to_map_with_inheritance(attributes, "OTHER_SWIFT_FLAGS", " -Xcc" + module_map_flag)
569570
end

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

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,11 @@ def self.generate(react_native_path, install_root, version, ios_version)
135135

136136
# Preserve non-code RESOURCES (privacy manifest, i18n bundles, ...). They
137137
# don't shadow headers, and React-Core-prebuilt doesn't vend them, so the
138-
# facade must carry them or prebuilt installs lose them. Globs are made
139-
# relative to the facade dir so they resolve back to the real source tree.
138+
# facade must carry them or prebuilt installs lose them. The matched
139+
# files are COPIED into the facade dir (like the re-exposed headers):
140+
# CocoaPods file accessors only match globs against files under the pod
141+
# root, so a `..`-escaping glob back into the source tree would silently
142+
# match nothing and the bundles would ship empty.
140143
resource_bundles = derive_resource_bundles(real, podspec_dir, dir)
141144
spec["resource_bundles"] = resource_bundles unless resource_bundles.empty?
142145
resources = derive_resources(real, podspec_dir, dir)
@@ -209,16 +212,16 @@ def self.derive_subspecs(real)
209212
end
210213
private_class_method :derive_subspecs
211214

212-
# Effective resource_bundles of the real spec (e.g. React-Core_privacy), with
213-
# globs rewritten relative to the facade dir so they point back at the real
214-
# source files. Unions the `resource_bundle` (singular) and `resource_bundles`
215-
# (plural) DSL forms.
215+
# Effective resource_bundles of the real spec (e.g. React-Core_privacy),
216+
# copied into the facade dir under resources/<bundle>/. Unions the
217+
# `resource_bundle` (singular) and `resource_bundles` (plural) DSL forms.
216218
def self.derive_resource_bundles(real, podspec_dir, facade_dir)
217219
out = {}
218220
[real.attributes_hash["resource_bundle"], real.attributes_hash["resource_bundles"]].each do |rb|
219221
next unless rb.is_a?(Hash)
220222
rb.each do |bundle, globs|
221-
out[bundle] = Array(globs).map { |g| rel_glob(g, podspec_dir, facade_dir) }
223+
copied = copy_resources(Array(globs), podspec_dir, facade_dir, File.join("resources", bundle))
224+
out[bundle] = copied unless copied.empty?
222225
end
223226
end
224227
out
@@ -248,19 +251,32 @@ def self.copy_reexposed_headers(globs, podspec_dir, facade_dir, name)
248251
end
249252
private_class_method :copy_reexposed_headers
250253

251-
# Loose `resources` of the real spec, rewritten relative to the facade dir.
254+
# Loose `resources` of the real spec, copied into the facade dir.
252255
def self.derive_resources(real, podspec_dir, facade_dir)
253-
Array(real.attributes_hash["resources"]).map { |g| rel_glob(g, podspec_dir, facade_dir) }
256+
copy_resources(Array(real.attributes_hash["resources"]), podspec_dir, facade_dir, "resources")
254257
end
255258
private_class_method :derive_resources
256259

257-
# Rewrite a glob declared relative to `podspec_dir` into one relative to
258-
# `facade_dir`, so the generated facade (which lives under the app's build/)
259-
# still resolves the resource in the react-native source tree.
260-
def self.rel_glob(glob, podspec_dir, facade_dir)
261-
require "pathname"
262-
abs = File.expand_path(glob, podspec_dir)
263-
Pathname.new(abs).relative_path_from(Pathname.new(facade_dir)).to_s
260+
# Copies everything the resource globs match (files or whole directories,
261+
# e.g. *.lproj bundles) into `<facade_dir>/<subdir>/` and returns
262+
# facade-relative paths for the copies. Globs resolve against the real
263+
# podspec dir. A glob that matches nothing is tolerated — the source pod
264+
# would ship nothing for it either, so the facade stays equivalent.
265+
# rm_rf-before-cp_r keeps the snapshot fresh across repeated `pod install`s.
266+
def self.copy_resources(globs, podspec_dir, facade_dir, subdir)
267+
copied = []
268+
globs.each do |g|
269+
matches = Dir.glob(File.expand_path(g, podspec_dir))
270+
next if matches.empty?
271+
dest_dir = File.join(facade_dir, subdir)
272+
FileUtils.mkdir_p(dest_dir)
273+
matches.each do |src|
274+
FileUtils.rm_rf(File.join(dest_dir, File.basename(src)))
275+
FileUtils.cp_r(src, dest_dir)
276+
copied << File.join(subdir, File.basename(src))
277+
end
278+
end
279+
copied.uniq
264280
end
265-
private_class_method :rel_glob
281+
private_class_method :copy_resources
266282
end

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const path = require('path');
3737
* artifact must not be produced.
3838
*/
3939
function computeSpecPlan(rnRoot /*: string */) /*: HeadersSpecPlan */ {
40-
const plan = planFromInventory(computeInventory(rnRoot));
40+
const plan = planFromInventory(computeInventory(rnRoot), rnRoot);
4141
if (plan.collisions.length > 0) {
4242
throw new Error(
4343
`headers-spec collisions (R8):\n ${plan.collisions.join('\n ')}`,

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
const fs = require('fs');
6262
const path = require('path');
6363

64+
// Fallback root only — inventory `source` paths are relative to the root the
65+
// inventory was computed from, so callers with a different tree (SPM tooling,
66+
// headers-inventory --root) must pass that root to planFromInventory.
6467
const RN_ROOT = path.join(__dirname, '..', '..');
6568

6669
/*::
@@ -164,13 +167,13 @@ function validatePrivateReactHeaders(manifest /*: any */) /*: void */ {
164167
}
165168
}
166169

167-
function isUmbrellaSafe(h /*: any */) /*: boolean */ {
170+
function isUmbrellaSafe(h /*: any */, rnRoot /*: string */) /*: boolean */ {
168171
if (h.bucket !== 'objc-modular-candidate' || h.naturalPath.includes('+')) {
169172
return false;
170173
}
171174
try {
172175
return !EXTERN_INLINE_RE.test(
173-
fs.readFileSync(path.join(RN_ROOT, h.identities[0].source), 'utf8'),
176+
fs.readFileSync(path.join(rnRoot, h.identities[0].source), 'utf8'),
174177
);
175178
} catch {
176179
return false;
@@ -206,8 +209,15 @@ function renderNamespaceUmbrella(
206209
/**
207210
* Computes the full layout plan from the header inventory manifest
208211
* (build/header-inventory.json — regenerate with header-inventory.js).
212+
* `rnRoot` is the tree the inventory's relative `source` paths resolve
213+
* against; defaults to the manifest's recorded root, then to the package
214+
* hosting this script.
209215
*/
210-
function planFromInventory(manifest /*: any */) /*: HeadersSpecPlan */ {
216+
function planFromInventory(
217+
manifest /*: any */,
218+
rnRoot /*:: ?: string */,
219+
) /*: HeadersSpecPlan */ {
220+
const root = rnRoot ?? manifest.root ?? RN_ROOT;
211221
validatePrivateReactHeaders(manifest); // R9: fail closed on allowlist drift
212222
const react /*: Array<SpecEntry> */ = [];
213223
const reactNativeHeaders /*: Array<SpecEntry> */ = [];
@@ -248,7 +258,7 @@ function planFromInventory(manifest /*: any */) /*: HeadersSpecPlan */ {
248258
entryList.push({relPath, source, naturalPath: np});
249259

250260
// R4: React umbrella membership.
251-
if (np.startsWith('React/') && isUmbrellaSafe(h)) {
261+
if (np.startsWith('React/') && isUmbrellaSafe(h, root)) {
252262
umbrella.push(np);
253263
}
254264
// R5: namespace modules (only for ReactNativeHeaders namespaces). Every
@@ -260,7 +270,7 @@ function planFromInventory(manifest /*: any */) /*: HeadersSpecPlan */ {
260270
// case-insensitive filesystem.
261271
if (entryList === reactNativeHeaders) {
262272
const ns = np.split('/')[0];
263-
if (MODULE_IDENT_RE.test(ns) && isUmbrellaSafe(h)) {
273+
if (MODULE_IDENT_RE.test(ns) && isUmbrellaSafe(h, root)) {
264274
if (!namespaceModules[ns]) {
265275
namespaceModules[ns] = [];
266276
}

0 commit comments

Comments
 (0)