Skip to content

Commit c39ab01

Browse files
Use asset catalog for iOS images
Load packager image assets from an iOS asset catalog (RNAssets.xcassets) instead of loose files in the app bundle. The CLI bundler emits imagesets into the catalog at build time (via --asset-catalog-dest, already in @react-native/community-cli-plugin); Xcode compiles them into Assets.car and the native image loader resolves them by name with [UIImage imageNamed:]. The catalog path is gated on the RCTUseAssetCatalog Info.plist key, a build-time constant read once. Apps that have not migrated are unaffected and the catalog path is a single lookup with no filesystem fallback; a mis-bundled asset logs an RCTLogError instead of failing silently. The native side only resolves catalog names for what the CLI actually emits into the catalog (png/jpg/jpeg under main-bundle assets/, mirroring isCatalogAsset), so gif/webp packager assets, sub-bundles and OTA assets outside the main bundle fall through to the existing loader. jpeg is also added to RCTIsImageAssetsPath so jpeg assets are routed to the bundle-asset loaders. react-native-xcode.sh passes --asset-catalog-dest when an RNAssets.xcassets exists next to the app's Info.plist. In rn-tester the "Build JS Bundle" phase is ordered before Resources and declares the catalog as an output so the new Xcode build system compiles it after it is populated (asset symbol generation is disabled on the target to avoid a dependency cycle with that step). Cold image loads in RNTester are ~15x faster (median 47us vs 719us) and Apple app thinning can strip unused scales.
1 parent e04ff69 commit c39ab01

8 files changed

Lines changed: 185 additions & 2 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ DerivedData
2222
project.xcworkspace
2323
**/.xcode.env.local
2424

25+
# Generated iOS asset catalog imagesets (produced at build time from JS assets)
26+
**/RNAssets.xcassets/*.imageset
27+
2528
# Gradle
2629
/build/
2730
/packages/rn-tester/build

packages/react-native/React/Base/RCTUtils.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ RCT_EXTERN NSData *__nullable RCTDecompressGzipData(NSData *__nullable data, NSU
139139
// (or nil, if the URL does not specify a path within the main bundle)
140140
RCT_EXTERN NSString *__nullable RCTBundlePathForURL(NSURL *__nullable URL);
141141

142+
// Returns the asset catalog image name for a packager asset URL, or nil if the
143+
// URL is not a main-bundle packager asset. The name matches the identifier the
144+
// CLI uses when generating the catalog (see assetPathUtils.getResourceIdentifier).
145+
RCT_EXTERN NSString *__nullable RCTAssetCatalogNameForURL(NSURL *__nullable URL);
146+
142147
// Returns the Path of Library directory
143148
RCT_EXTERN NSString *__nullable RCTLibraryPath(void);
144149

packages/react-native/React/Base/RCTUtils.mm

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -886,7 +886,8 @@ BOOL RCTIsGzippedData(NSData *__nullable data)
886886
static BOOL RCTIsImageAssetsPath(NSString *path)
887887
{
888888
NSString *extension = [path pathExtension];
889-
return [extension isEqualToString:@"png"] || [extension isEqualToString:@"jpg"];
889+
return [extension isEqualToString:@"png"] || [extension isEqualToString:@"jpg"] ||
890+
[extension isEqualToString:@"jpeg"];
890891
}
891892

892893
BOOL RCTIsBundleAssetURL(NSURL *__nullable imageURL)
@@ -939,6 +940,75 @@ BOOL RCTIsLocalAssetURL(NSURL *__nullable imageURL)
939940
return bundleCache[key];
940941
}
941942

943+
static BOOL RCTUseAssetCatalog(void)
944+
{
945+
static BOOL useAssetCatalog = NO;
946+
static dispatch_once_t onceToken;
947+
dispatch_once(&onceToken, ^{
948+
useAssetCatalog = [[[NSBundle mainBundle] objectForInfoDictionaryKey:@"RCTUseAssetCatalog"] boolValue];
949+
});
950+
return useAssetCatalog;
951+
}
952+
953+
NSString *__nullable RCTAssetCatalogNameForURL(NSURL *__nullable URL)
954+
{
955+
NSString *path = RCTBundlePathForURL(URL);
956+
// Packager assets always live under "assets/". Anything else (sub-bundles,
957+
// CodePush/OTA assets outside the main bundle) is not in the catalog.
958+
if (path == nil || ![path hasPrefix:@"assets/"]) {
959+
return nil;
960+
}
961+
962+
// Only image types the CLI emits into the catalog (see isCatalogAsset in
963+
// @react-native/community-cli-plugin). Other packager assets (gif, webp, ...)
964+
// are copied as plain files and must use the regular loader.
965+
if (!RCTIsImageAssetsPath(path)) {
966+
return nil;
967+
}
968+
969+
// Strip the optional "@Nx" scale suffix and the file extension in a single
970+
// pass. Asset catalog images resolve the correct scale by name at runtime.
971+
// See https://developer.apple.com/documentation/xcode/asset-catalogs
972+
static NSRegularExpression *scaleAndExtensionRegex;
973+
static dispatch_once_t scaleOnceToken;
974+
dispatch_once(&scaleOnceToken, ^{
975+
// Scales are not necessarily integers (e.g. "@1.5x").
976+
scaleAndExtensionRegex = [NSRegularExpression regularExpressionWithPattern:@"(@\\d+(\\.\\d+)?x)?\\.\\w+$"
977+
options:0
978+
error:nil];
979+
});
980+
path = [scaleAndExtensionRegex stringByReplacingMatchesInString:path
981+
options:0
982+
range:NSMakeRange(0, path.length)
983+
withTemplate:@""];
984+
985+
// Lowercase and encode the folder structure into the name.
986+
path = [path.lowercaseString stringByReplacingOccurrencesOfString:@"/" withString:@"_"];
987+
988+
// Remove characters that are not allowed in an asset catalog identifier.
989+
static NSCharacterSet *illegalCharacters;
990+
static dispatch_once_t illegalOnceToken;
991+
dispatch_once(&illegalOnceToken, ^{
992+
illegalCharacters =
993+
[[NSCharacterSet characterSetWithCharactersInString:@"abcdefghijklmnopqrstuvwxyz0123456789_"] invertedSet];
994+
});
995+
path = [[path componentsSeparatedByCharactersInSet:illegalCharacters] componentsJoinedByString:@""];
996+
997+
// Remove the "assets_" (or "assetsunstable_path_") prefix.
998+
static NSRegularExpression *prefixRegex;
999+
static dispatch_once_t prefixOnceToken;
1000+
dispatch_once(&prefixOnceToken, ^{
1001+
prefixRegex = [NSRegularExpression regularExpressionWithPattern:@"^(assetsunstable_path|assets)_" options:0
1002+
error:nil];
1003+
});
1004+
path = [prefixRegex stringByReplacingMatchesInString:path
1005+
options:0
1006+
range:NSMakeRange(0, path.length)
1007+
withTemplate:@""];
1008+
1009+
return path;
1010+
}
1011+
9421012
UIImage *__nullable RCTImageFromLocalBundleAssetURL(NSURL *imageURL)
9431013
{
9441014
if (![imageURL.scheme isEqualToString:@"file"]) {
@@ -955,6 +1025,25 @@ BOOL RCTIsLocalAssetURL(NSURL *__nullable imageURL)
9551025

9561026
UIImage *__nullable RCTImageFromLocalAssetURL(NSURL *imageURL)
9571027
{
1028+
if (RCTUseAssetCatalog()) {
1029+
NSString *catalogName = RCTAssetCatalogNameForURL(imageURL);
1030+
if (catalogName != nil) {
1031+
// The app opted into the asset catalog and this is a packager asset, so it
1032+
// was compiled into RNAssets.xcassets at build time. Trust the catalog and
1033+
// return directly, keeping the common path a single lookup with no
1034+
// filesystem fallback. Non-catalog assets (nil name) fall through below.
1035+
UIImage *image = [UIImage imageNamed:catalogName];
1036+
if (image == nil) {
1037+
RCTLogError(
1038+
@"Image \"%@\" (%@) was not found in the asset catalog. RCTUseAssetCatalog is enabled, "
1039+
"so image assets must be bundled into the app's RNAssets.xcassets at build time.",
1040+
catalogName,
1041+
imageURL);
1042+
}
1043+
return image;
1044+
}
1045+
}
1046+
9581047
NSString *imageName = RCTBundlePathForURL(imageURL);
9591048

9601049
NSBundle *bundle = nil;

packages/react-native/scripts/react-native-xcode.sh

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,19 @@ else
151151
EXTRA_ARGS+=("--config-cmd" "'$NODE_BINARY' $NODE_ARGS '$REACT_NATIVE_DIR/cli.js' config")
152152
fi
153153

154+
# iOS asset catalog: if the app has an RNAssets.xcassets next to its Info.plist,
155+
# emit image assets into it so Xcode compiles them into Assets.car and Apple app
156+
# thinning can strip unused scales. Presence of the catalog is the opt-in signal,
157+
# so apps that have not migrated are unaffected. This also covers Mac Catalyst
158+
# (BUNDLE_PLATFORM is forced to "ios" above). Pair with RCTUseAssetCatalog=YES in
159+
# the app's Info.plist so the native side loads from the catalog.
160+
if [[ "$BUNDLE_PLATFORM" == "ios" && -n "$PRODUCT_SETTINGS_PATH" ]]; then
161+
ASSET_CATALOG_DIR="$(dirname "$PRODUCT_SETTINGS_PATH")"
162+
if [[ -d "$ASSET_CATALOG_DIR/RNAssets.xcassets" ]]; then
163+
EXTRA_ARGS+=("--asset-catalog-dest" "$ASSET_CATALOG_DIR")
164+
fi
165+
fi
166+
154167
# shellcheck disable=SC2086
155168
"$NODE_BINARY" $NODE_ARGS "$CLI_PATH" $BUNDLE_COMMAND \
156169
$CONFIG_ARG \

packages/rn-tester/RNTester/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
<string>You need to add NSPhotoLibraryUsageDescription key in Info.plist to enable photo library usage, otherwise it is going to *fail silently*!</string>
4949
<key>RCTNewArchEnabled</key>
5050
<true/>
51+
<key>RCTUseAssetCatalog</key>
52+
<true/>
5153
<key>UILaunchStoryboardName</key>
5254
<string>LaunchScreen</string>
5355
<key>UIRequiredDeviceCapabilities</key>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"info" : {
3+
"author" : "xcode",
4+
"version" : 1
5+
}
6+
}

packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
0CF641B628ECB21C00DCDD11 /* RNAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CF641B528ECB21C00DCDD11 /* RNAssets.xcassets */; };
1011
0EA618032BE537D3001875EF /* RNTesterBundle.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 0EA618022BE537D3001875EF /* RNTesterBundle.bundle */; };
1112
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
1213
2DDEF0101F84BF7B00DBDF73 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2DDEF00F1F84BF7B00DBDF73 /* Images.xcassets */; };
@@ -74,6 +75,7 @@
7475
/* End PBXContainerItemProxy section */
7576

7677
/* Begin PBXFileReference section */
78+
0CF641B528ECB21C00DCDD11 /* RNAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = RNAssets.xcassets; path = RNTester/RNAssets.xcassets; sourceTree = "<group>"; };
7779
0EA618022BE537D3001875EF /* RNTesterBundle.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; name = RNTesterBundle.bundle; path = RNTester/RNTesterBundle.bundle; sourceTree = "<group>"; };
7880
13B07F961A680F5B00A75B9A /* RNTester.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RNTester.app; sourceTree = BUILT_PRODUCTS_DIR; };
7981
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = RNTester/AppDelegate.h; sourceTree = "<group>"; };
@@ -211,6 +213,7 @@
211213
13B07FB71A68108700A75B9A /* main.m */,
212214
832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */,
213215
2DDEF00F1F84BF7B00DBDF73 /* Images.xcassets */,
216+
0CF641B528ECB21C00DCDD11 /* RNAssets.xcassets */,
214217
8145AE05241172D900A3F8DA /* LaunchScreen.storyboard */,
215218
680759612239798500290469 /* Fabric */,
216219
272E6B3A1BEA846C001FCF37 /* NativeExampleViews */,
@@ -366,8 +369,8 @@
366369
F28F13DD10D40D98C0BB7BE8 /* [CP] Check Pods Manifest.lock */,
367370
13B07F871A680F5B00A75B9A /* Sources */,
368371
13B07F8C1A680F5B00A75B9A /* Frameworks */,
369-
13B07F8E1A680F5B00A75B9A /* Resources */,
370372
68CD48B71D2BCB2C007E06A9 /* Build JS Bundle */,
373+
13B07F8E1A680F5B00A75B9A /* Resources */,
371374
79E8BE2B119D4C5CCD2F04B3 /* [RN] Copy Hermes Framework */,
372375
17FE348EDF12252D972FFC2F /* [CP] Embed Pods Frameworks */,
373376
DFEE284B22AD5E88BBF1026A /* [CP] Copy Pods Resources */,
@@ -470,6 +473,7 @@
470473
buildActionMask = 2147483647;
471474
files = (
472475
2DDEF0101F84BF7B00DBDF73 /* Images.xcassets in Resources */,
476+
0CF641B628ECB21C00DCDD11 /* RNAssets.xcassets in Resources */,
473477
8145AE06241172D900A3F8DA /* LaunchScreen.storyboard in Resources */,
474478
0EA618032BE537D3001875EF /* RNTesterBundle.bundle in Resources */,
475479
F0D621C32BBB9E38005960AC /* PrivacyInfo.xcprivacy in Resources */,
@@ -582,6 +586,7 @@
582586
};
583587
68CD48B71D2BCB2C007E06A9 /* Build JS Bundle */ = {
584588
isa = PBXShellScriptBuildPhase;
589+
alwaysOutOfDate = 1;
585590
buildActionMask = 2147483647;
586591
files = (
587592
);
@@ -591,6 +596,7 @@
591596
);
592597
name = "Build JS Bundle";
593598
outputPaths = (
599+
"$(SRCROOT)/RNTester/RNAssets.xcassets",
594600
);
595601
runOnlyForDeploymentPostprocessing = 0;
596602
shellPath = /bin/sh;
@@ -801,6 +807,7 @@
801807
baseConfigurationReference = CA59C9994B1822826D8983F0 /* Pods-RNTester.debug.xcconfig */;
802808
buildSettings = {
803809
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
810+
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO;
804811
CLANG_ENABLE_MODULES = YES;
805812
DEVELOPMENT_TEAM = "";
806813
HEADER_SEARCH_PATHS = (
@@ -839,6 +846,7 @@
839846
baseConfigurationReference = 51BC9297B6C3163C14532020 /* Pods-RNTester.release.xcconfig */;
840847
buildSettings = {
841848
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
849+
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO;
842850
CLANG_ENABLE_MODULES = YES;
843851
DEVELOPMENT_TEAM = "";
844852
EXCLUDED_ARCHS = "";

packages/rn-tester/RNTesterUnitTests/RCTURLUtilsTests.m

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,61 @@ - (void)testIsLocalAssetsURLParam
100100
XCTAssertFalse(RCTIsLocalAssetURL(otherAssetsURL));
101101
}
102102

103+
- (void)testAssetCatalogNameForURL
104+
{
105+
NSString *resourcePath = [[NSBundle mainBundle] resourcePath];
106+
107+
// Nested folder + "@2x" scale suffix: folders are encoded into the name, the
108+
// scale suffix and extension are stripped, and the "assets_" prefix removed.
109+
NSURL *scaledURL =
110+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/icon@2x.png"]];
111+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(scaledURL), @"awesomemodule_icon");
112+
113+
// Same asset without a scale suffix resolves to the same name.
114+
NSURL *unscaledURL =
115+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/icon.png"]];
116+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(unscaledURL), @"awesomemodule_icon");
117+
118+
// Illegal characters (e.g. "-") are stripped, matching the CLI identifier.
119+
NSURL *illegalCharsURL =
120+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/my-module/my-icon@3x.png"]];
121+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(illegalCharsURL), @"mymodule_myicon");
122+
123+
// Non-integer scale suffixes are also stripped.
124+
NSURL *fractionalScaleURL =
125+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/icon@1.5x.png"]];
126+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(fractionalScaleURL), @"awesomemodule_icon");
127+
128+
// ".jpeg" is a catalog image type (matching the CLI's isCatalogAsset).
129+
NSURL *jpegURL =
130+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/photo@2x.jpeg"]];
131+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(jpegURL), @"awesomemodule_photo");
132+
133+
// Non-catalog image types (the CLI only emits png/jpg/jpeg into the catalog)
134+
// are not catalog assets and use the regular loader.
135+
NSURL *gifURL =
136+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/anim.gif"]];
137+
XCTAssertNil(RCTAssetCatalogNameForURL(gifURL));
138+
139+
// Assets outside the project root are encoded with "_" by the packager
140+
// (e.g. "assets/../../shared" -> "assets/__shared") and resolve to the same
141+
// identifier the CLI generates.
142+
NSURL *outOfRootURL =
143+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/__shared/icon@2x.png"]];
144+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(outOfRootURL), @"__shared_icon");
145+
146+
// Query strings are ignored, same as the legacy loader.
147+
NSURL *queryURL = [NSURL
148+
URLWithString:[NSString stringWithFormat:@"file://%@/assets/AwesomeModule/icon@2x.png?platform=ios&hash=abc",
149+
resourcePath]];
150+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(queryURL), @"awesomemodule_icon");
151+
152+
// A non-packager path (not under "assets/") is not a catalog asset.
153+
NSURL *notPackagerURL = [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"icon.png"]];
154+
XCTAssertNil(RCTAssetCatalogNameForURL(notPackagerURL));
155+
156+
// A nil URL is handled gracefully.
157+
XCTAssertNil(RCTAssetCatalogNameForURL(nil));
158+
}
159+
103160
@end

0 commit comments

Comments
 (0)