Skip to content

Commit 8959ebd

Browse files
chrfalchreact-native-bot
authored andcommitted
refactor(iOS): include resources like bundles into the precompiled XCFrameworks (#57305)
Summary: **Depends on #57285 In source builds, `React-Core` ships non-header resources — its privacy manifest (`PrivacyInfo.xcprivacy`) and its localized strings (RCTI18nStrings) — via the podspec's resource_bundles. In the prebuilt path those source pods aren't installed (CocoaPods facades) or aren't present at all (SwiftPM), so these resources were silently dropped: prebuilt/SwiftPM apps shipped no React Native privacy manifest, and localized strings were unavailable. This embeds them in React.framework at prebuild time so they ship uniformly across CocoaPods-prebuilt and SwiftPM, with source builds unchanged: - **Privacy manifest** — the PrivacyInfo.xcprivacy of the pods baked into React.framework are merged into one manifest at the framework root. React.framework is a dynamic framework, so Xcode's privacy-report aggregation picks it up automatically (no runtime involvement). - **RCTI18nStrings** — rebuilt as RCTI18nStrings.bundle inside React.framework. RCTLocalizedString.mm now resolves the bundle from its own framework first (bundleForClass:), falling back to the app's main bundle for static source builds. It also fixes a latent bug in `React-Core.podspec`: a later resource_bundles = was overwriting the earlier resource_bundle =, so source builds had stopped shipping RCTI18nStrings. Both bundles are now declared together. With the artifact owning these resources, the prebuilt RNCore facade no longer carries them. ## Changelog: [IOS][FIXED] - Ship React-Core's privacy manifest and localized strings (RCTI18nStrings) inside the prebuilt React.xcframework, so CocoaPods-prebuilt and SwiftPM apps include them Pull Request resolved: #57305 Test Plan: - ✅ yarn jest packages/react-native/scripts/ios-prebuild — unit + integration tests for the merge/discovery/bundle-build logic (red/green). - ✅ Built React.xcframework from this branch and confirmed each slice's React.framework carries the merged PrivacyInfo.xcprivacy and RCTI18nStrings.bundle (37 locales). - ✅ Cold-built rn-tester in prebuilt mode and verified the app bundle contains Frameworks/React.framework/{PrivacyInfo.xcprivacy, RCTI18nStrings.bundle}. - ✅ Confirmed React-Core.podspec now reports both resource bundles (RCTI18nStrings, React-Core_privacy) for source builds. Reviewed By: fabriziocucci Differential Revision: D111448862 Pulled By: cipolleschi fbshipit-source-id: a95360f51cc1131510ef03f7e48c689575d1c22d
1 parent 17f7849 commit 8959ebd

6 files changed

Lines changed: 784 additions & 71 deletions

File tree

packages/react-native/React-Core.podspec

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ Pod::Spec.new do |s|
5151
s.author = "Meta Platforms, Inc. and its affiliates"
5252
s.platforms = min_supported_versions
5353
s.source = source
54-
s.resource_bundle = { "RCTI18nStrings" => ["React/I18n/strings/*.lproj"]}
5554
s.compiler_flags = js_engine_flags()
5655
s.header_dir = "React"
5756
s.weak_framework = "JavaScriptCore"
@@ -122,7 +121,15 @@ Pod::Spec.new do |s|
122121
s.dependency "React-hermes"
123122
end
124123

125-
s.resource_bundles = {'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy'}
124+
# Both bundles in one declaration: a second `resource_bundle(s) =` would replace
125+
# (not merge) the first. RCTI18nStrings holds React-Core's localized strings
126+
# (loaded by RCTLocalizedString); React-Core_privacy is the privacy manifest.
127+
# (Prebuilt/SwiftPM get both from inside React.xcframework instead — see
128+
# scripts/ios-prebuild/framework-resources.js — but source builds ship them here.)
129+
s.resource_bundles = {
130+
'RCTI18nStrings' => ['React/I18n/strings/*.lproj'],
131+
'React-Core_privacy' => 'React/Resources/PrivacyInfo.xcprivacy',
132+
}
126133

127134
add_dependency(s, "React-runtimeexecutor", :additional_framework_paths => ["platform/ios"])
128135
add_dependency(s, "React-jsinspector", :framework_name => 'jsinspector_modern')

packages/react-native/React/I18n/RCTLocalizedString.mm

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,45 @@
77

88
#import "RCTLocalizedString.h"
99

10+
#import <React/RCTLog.h>
11+
1012
#if !defined(WITH_FBI18N) || !(WITH_FBI18N)
1113

14+
// Anchors resource lookups to the bundle that contains this code: React.framework
15+
// when React Native is consumed prebuilt / via SwiftPM, or the app's main bundle
16+
// for static source builds.
17+
@interface RCTI18nStringsAnchor : NSObject
18+
@end
19+
@implementation RCTI18nStringsAnchor
20+
@end
21+
22+
// Resolves RCTI18nStrings.bundle wherever it ships: the code's own bundle first
23+
// (prebuilt/SwiftPM embed it inside React.framework), then the app's main bundle
24+
// (source builds copy it there via the podspec resource_bundles). Returns nil
25+
// when absent, so the caller falls back to the untranslated default value.
26+
static NSBundle *RCTI18nStringsBundle(void)
27+
{
28+
NSBundle *codeBundle = [NSBundle bundleForClass:[RCTI18nStringsAnchor class]];
29+
NSURL *url = [codeBundle URLForResource:@"RCTI18nStrings" withExtension:@"bundle"];
30+
if (url != nil) {
31+
return [NSBundle bundleWithURL:url];
32+
}
33+
NSString *mainPath = [[NSBundle mainBundle] pathForResource:@"RCTI18nStrings" ofType:@"bundle"];
34+
if (mainPath != nil) {
35+
return [NSBundle bundleWithPath:mainPath];
36+
}
37+
#if RCT_DEV
38+
// Missing resources are otherwise silent (every lookup falls back to the
39+
// untranslated default and the privacy manifest quietly drops out of the
40+
// app's aggregated privacy report). Called once — the caller caches.
41+
RCTLogWarn(
42+
@"RCTI18nStrings.bundle not found in React.framework or the app bundle. Localized strings will use their "
43+
@"untranslated defaults, and React's PrivacyInfo.xcprivacy may be missing from the app's privacy report. "
44+
@"When consuming the prebuilt React.framework, verify it is embedded into the app with its resources intact.");
45+
#endif
46+
return nil;
47+
}
48+
1249
extern "C" {
1350

1451
static NSString *FBTStringByConvertingIntegerToBase64(uint64_t number)
@@ -33,8 +70,7 @@
3370

3471
NSString *RCTLocalizedStringFromKey(uint64_t key, NSString *defaultValue)
3572
{
36-
static NSBundle *bundle = [NSBundle bundleWithPath:[[NSBundle mainBundle] pathForResource:@"RCTI18nStrings"
37-
ofType:@"bundle"]];
73+
static NSBundle *bundle = RCTI18nStringsBundle();
3874
if (bundle == nil) {
3975
return defaultValue;
4076
} else {

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

Lines changed: 13 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -103,18 +103,19 @@ def self.facade?(name)
103103
# call once per `pod install`.
104104
#
105105
# `react_native_path` locates the real podspecs we mirror. version + subspecs +
106-
# default_subspecs + resources are all DERIVED from the real spec so the facade
107-
# stays graph- and resource-equivalent to the source pod. A facaded pod whose
108-
# real podspec can't be read is a hard error (see load_real_spec) — silently
109-
# shipping an empty facade would hide exactly the drift this guards against.
106+
# default_subspecs are DERIVED from the real spec so the facade stays
107+
# graph-equivalent to the source pod (resources are NOT carried — they live in
108+
# the prebuilt artifact; see the note in the loop). A facaded pod whose real
109+
# podspec can't be read is a hard error (see load_real_spec) — silently shipping
110+
# an empty facade would hide exactly the drift this guards against.
110111
def self.generate(react_native_path, install_root, version, ios_version)
111112
@@install_root = install_root.to_s
112113
abs_base = File.join(@@install_root, FACADE_RELDIR)
113114
FileUtils.mkdir_p(abs_base)
114115
FACADE_PODS.each do |name, podspec_rel_path|
115116
podspec_path = File.join(react_native_path.to_s, podspec_rel_path)
116-
real = load_real_spec(podspec_path, name)
117117
podspec_dir = File.dirname(podspec_path)
118+
real = load_real_spec(podspec_path, name)
118119
dir = File.join(abs_base, name)
119120
FileUtils.mkdir_p(dir)
120121

@@ -133,17 +134,11 @@ def self.generate(react_native_path, install_root, version, ios_version)
133134
"dependencies" => { "React-Core-prebuilt" => [] },
134135
}
135136

136-
# Preserve non-code RESOURCES (privacy manifest, i18n bundles, ...). They
137-
# don't shadow headers, and React-Core-prebuilt doesn't vend them, so the
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.
143-
resource_bundles = derive_resource_bundles(real, podspec_dir, dir)
144-
spec["resource_bundles"] = resource_bundles unless resource_bundles.empty?
145-
resources = derive_resources(real, podspec_dir, dir)
146-
spec["resources"] = resources unless resources.empty?
137+
# NOTE: the facade carries NO resources. The pods' non-code resources
138+
# (e.g. the privacy manifest) are embedded directly in the prebuilt
139+
# React.xcframework by the ios-prebuild compose (see ios-prebuild/framework-resources.js),
140+
# so they reach both CocoaPods-prebuilt and SwiftPM from the artifact —
141+
# the facade only needs to declare the React-Core-prebuilt dependency.
147142

148143
# Re-vend the narrow set of angle-only framework headers that community
149144
# modules quote-import (see FACADE_REEXPOSED_HEADERS). The header is
@@ -190,8 +185,8 @@ def self.facade_path(name)
190185

191186
# Loads the real podspec so we can mirror its structure. A facaded pod MUST have
192187
# a readable real podspec — if it's missing or unparseable we raise rather than
193-
# ship an empty facade, since that would silently drop subspecs/resources (the
194-
# very drift this mechanism exists to prevent).
188+
# ship an empty facade, since that would silently drop subspecs (the very drift
189+
# this mechanism exists to prevent).
195190
def self.load_real_spec(path, name)
196191
unless File.exist?(path)
197192
raise "[RNCoreFacades] Real podspec for facaded pod '#{name}' not found at #{path}. " \
@@ -212,22 +207,6 @@ def self.derive_subspecs(real)
212207
end
213208
private_class_method :derive_subspecs
214209

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.
218-
def self.derive_resource_bundles(real, podspec_dir, facade_dir)
219-
out = {}
220-
[real.attributes_hash["resource_bundle"], real.attributes_hash["resource_bundles"]].each do |rb|
221-
next unless rb.is_a?(Hash)
222-
rb.each do |bundle, globs|
223-
copied = copy_resources(Array(globs), podspec_dir, facade_dir, File.join("resources", bundle))
224-
out[bundle] = copied unless copied.empty?
225-
end
226-
end
227-
out
228-
end
229-
private_class_method :derive_resource_bundles
230-
231210
# Copy the re-exposed header(s) into the facade dir (flat) and return the
232211
# facade-relative source_files entries. `globs` are resolved against the real
233212
# podspec dir. A glob that matches nothing is a hard error: the whole point is
@@ -250,36 +229,4 @@ def self.copy_reexposed_headers(globs, podspec_dir, facade_dir, name)
250229
copied.uniq
251230
end
252231
private_class_method :copy_reexposed_headers
253-
254-
# Loose `resources` of the real spec, copied into the facade dir.
255-
def self.derive_resources(real, podspec_dir, facade_dir)
256-
copy_resources(Array(real.attributes_hash["resources"]), podspec_dir, facade_dir, "resources")
257-
end
258-
private_class_method :derive_resources
259-
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 a hard error: the real spec
264-
# declared a resource, so silently dropping it would make the facade drift.
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-
if matches.empty?
271-
raise "[RNCoreFacades] Resource glob '#{g}' matched no files under #{podspec_dir}. " \
272-
"Update the facade resource derivation if the podspec layout changed."
273-
end
274-
dest_dir = File.join(facade_dir, subdir)
275-
FileUtils.mkdir_p(dest_dir)
276-
matches.each do |src|
277-
FileUtils.rm_rf(File.join(dest_dir, File.basename(src)))
278-
FileUtils.cp_r(src, dest_dir)
279-
copied << File.join(subdir, File.basename(src))
280-
end
281-
end
282-
copied.uniq
283-
end
284-
private_class_method :copy_resources
285232
end

0 commit comments

Comments
 (0)