Skip to content

Commit 0fc530b

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 feature is opt-in via the RCTUseAssetCatalog Info.plist key, which is the single source of truth: the native loader reads it (a build-time constant, read once) and react-native-xcode.sh reads the same key to decide whether to bundle images into the catalog, so build and runtime cannot disagree on where image assets live. Enabling the key without an RNAssets.xcassets in the app fails the build with an actionable error. Apps that have not migrated are unaffected. 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. rn-tester and private/helloworld are both migrated: the 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 targets to avoid a dependency cycle with that step). helloworld's bundle phase now also substitutes REACT_NATIVE_PATH in CONFIG_JSON, which any bundling build requires. Cold image loads in RNTester are ~15x faster (median 47us vs 719us) and Apple app thinning can strip unused scales.
1 parent e04ff69 commit 0fc530b

11 files changed

Lines changed: 302 additions & 17 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: 114 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,99 @@ 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+
// The "assets/" prefix the packager uses for all image assets. The CLI strips
956+
// it from the identifiers it names the imagesets with.
957+
const NSUInteger assetsPrefixLength = 7;
958+
959+
NSString *path = RCTBundlePathForURL(URL);
960+
// Packager assets always live under "assets/". Anything else (sub-bundles,
961+
// CodePush/OTA assets outside the main bundle) is not in the catalog.
962+
if (path == nil || ![path hasPrefix:@"assets/"]) {
963+
return nil;
964+
}
965+
966+
// Only image types the CLI emits into the catalog (see isCatalogAsset in
967+
// @react-native/community-cli-plugin). Other packager assets (gif, webp, ...)
968+
// are copied as plain files and must use the regular loader.
969+
if (!RCTIsImageAssetsPath(path)) {
970+
return nil;
971+
}
972+
973+
// File system paths come back decomposed (NFD); restore the precomposed form
974+
// the packager saw on disk so non-ASCII characters filter out the same way
975+
// they do in the CLI's identifier.
976+
path = path.precomposedStringWithCanonicalMapping;
977+
978+
const NSUInteger length = path.length;
979+
unichar stackBuffer[256];
980+
unichar *chars = length <= 256 ? stackBuffer : (unichar *)malloc(length * sizeof(unichar));
981+
[path getCharacters:chars range:NSMakeRange(0, length)];
982+
983+
// Strip the file extension (guaranteed present by RCTIsImageAssetsPath) and
984+
// an optional "@<scale>x" suffix (integer or fractional, e.g. "@2x",
985+
// "@1.5x"). The catalog stores a single imageset per image and resolves the
986+
// scale by name at runtime, see
987+
// https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs
988+
NSUInteger end = length - 1;
989+
while (end > assetsPrefixLength && chars[end] != '.') {
990+
end--;
991+
}
992+
if (end > assetsPrefixLength && chars[end - 1] == 'x') {
993+
// Walk back over "@<digits>(.<digits>)?" ending at the "x".
994+
NSUInteger cursor = end - 1;
995+
while (cursor > assetsPrefixLength && chars[cursor - 1] >= '0' && chars[cursor - 1] <= '9') {
996+
cursor--;
997+
}
998+
if (cursor < end - 1) {
999+
if (chars[cursor - 1] == '@') {
1000+
end = cursor - 1;
1001+
} else if (chars[cursor - 1] == '.') {
1002+
NSUInteger integerPart = cursor - 1;
1003+
while (integerPart > assetsPrefixLength && chars[integerPart - 1] >= '0' && chars[integerPart - 1] <= '9') {
1004+
integerPart--;
1005+
}
1006+
if (integerPart < cursor - 1 && chars[integerPart - 1] == '@') {
1007+
end = integerPart - 1;
1008+
}
1009+
}
1010+
}
1011+
}
1012+
1013+
// Build the identifier in place in a single pass: skip the "assets/" prefix,
1014+
// lowercase, encode the folder structure with "_" and drop anything that is
1015+
// not a valid identifier character, producing the same identifier as
1016+
// getResourceIdentifier in the CLI, which names the imagesets.
1017+
NSUInteger resultLength = 0;
1018+
for (NSUInteger i = assetsPrefixLength; i < end; i++) {
1019+
unichar c = chars[i];
1020+
if (c == '/') {
1021+
chars[resultLength++] = '_';
1022+
} else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
1023+
chars[resultLength++] = c;
1024+
} else if (c >= 'A' && c <= 'Z') {
1025+
chars[resultLength++] = c + ('a' - 'A');
1026+
}
1027+
}
1028+
1029+
NSString *name = [NSString stringWithCharacters:chars length:resultLength];
1030+
if (chars != stackBuffer) {
1031+
free(chars);
1032+
}
1033+
return name;
1034+
}
1035+
9421036
UIImage *__nullable RCTImageFromLocalBundleAssetURL(NSURL *imageURL)
9431037
{
9441038
if (![imageURL.scheme isEqualToString:@"file"]) {
@@ -955,6 +1049,25 @@ BOOL RCTIsLocalAssetURL(NSURL *__nullable imageURL)
9551049

9561050
UIImage *__nullable RCTImageFromLocalAssetURL(NSURL *imageURL)
9571051
{
1052+
if (RCTUseAssetCatalog()) {
1053+
NSString *catalogName = RCTAssetCatalogNameForURL(imageURL);
1054+
if (catalogName != nil) {
1055+
// The app opted into the asset catalog and this is a packager asset, so it
1056+
// was compiled into RNAssets.xcassets at build time. Trust the catalog and
1057+
// return directly, keeping the common path a single lookup with no
1058+
// filesystem fallback. Non-catalog assets (nil name) fall through below.
1059+
UIImage *image = [UIImage imageNamed:catalogName];
1060+
if (image == nil) {
1061+
RCTLogError(
1062+
@"Image \"%@\" (%@) was not found in the asset catalog. RCTUseAssetCatalog is enabled, "
1063+
"so image assets must be bundled into the app's RNAssets.xcassets at build time.",
1064+
catalogName,
1065+
imageURL);
1066+
}
1067+
return image;
1068+
}
1069+
}
1070+
9581071
NSString *imageName = RCTBundlePathForURL(imageURL);
9591072

9601073
NSBundle *bundle = nil;

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

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

154+
# iOS asset catalog: when the app opts in with the RCTUseAssetCatalog key in its
155+
# Info.plist, emit image assets into an RNAssets.xcassets catalog next to it so
156+
# Xcode compiles them into Assets.car and Apple app thinning can strip unused
157+
# scales. The key is the same one the native image loader reads, so bundling and
158+
# runtime cannot disagree on where image assets live. Apps that have not
159+
# migrated are unaffected. This also covers Mac Catalyst (BUNDLE_PLATFORM is
160+
# forced to "ios" above).
161+
if [[ "$BUNDLE_PLATFORM" == "ios" && -n "$PRODUCT_SETTINGS_PATH" ]]; then
162+
USE_ASSET_CATALOG="$(/usr/libexec/PlistBuddy -c 'Print :RCTUseAssetCatalog' "$PRODUCT_SETTINGS_PATH" 2>/dev/null || true)"
163+
if [[ "$USE_ASSET_CATALOG" == "true" ]]; then
164+
ASSET_CATALOG_DIR="$(dirname "$PRODUCT_SETTINGS_PATH")"
165+
if [[ ! -d "$ASSET_CATALOG_DIR/RNAssets.xcassets" ]]; then
166+
echo "error: RCTUseAssetCatalog is enabled in $PRODUCT_SETTINGS_PATH, but no RNAssets.xcassets asset catalog" \
167+
"exists next to it. Create the catalog and add it to the app target, or remove the RCTUseAssetCatalog key." >&2
168+
exit 2
169+
fi
170+
EXTRA_ARGS+=("--asset-catalog-dest" "$ASSET_CATALOG_DIR")
171+
fi
172+
fi
173+
154174
# shellcheck disable=SC2086
155175
"$NODE_BINARY" $NODE_ARGS "$CLI_PATH" $BUNDLE_COMMAND \
156176
$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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,97 @@ - (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+
// Fractional scale with a leading zero (the packager emits e.g. "@0.5x").
129+
NSURL *zeroFractionalScaleURL =
130+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/icon@0.5x.png"]];
131+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(zeroFractionalScaleURL), @"awesomemodule_icon");
132+
133+
// An uppercase "X" is not a scale suffix (the packager's scale format is
134+
// case-sensitive), so it stays in the name like any other character.
135+
NSURL *uppercaseScaleURL =
136+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/icon@2X.png"]];
137+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(uppercaseScaleURL), @"awesomemodule_icon2x");
138+
139+
// Only the extension is stripped from a name containing dots; the inner dot
140+
// is an illegal identifier character and is removed.
141+
NSURL *multiDotURL =
142+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/photo.small.png"]];
143+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(multiDotURL), @"awesomemodule_photosmall");
144+
145+
// Non-ASCII characters are not valid identifier characters and are removed.
146+
// File system paths are decomposed (NFD), so this also verifies the name is
147+
// normalized back to the precomposed form the CLI derived the identifier
148+
// from ("ü" must not leave a stray "u" behind).
149+
NSURL *unicodeURL =
150+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/ünïcode.png"]];
151+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(unicodeURL), @"awesomemodule_ncode");
152+
153+
// Uppercase extensions are not catalog assets: the CLI's isCatalogAsset
154+
// check is case-sensitive, so these are copied as plain files.
155+
NSURL *uppercaseExtensionURL =
156+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/Icon.PNG"]];
157+
XCTAssertNil(RCTAssetCatalogNameForURL(uppercaseExtensionURL));
158+
159+
// A packager asset without an image extension is not a catalog asset.
160+
NSURL *noExtensionURL =
161+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/data"]];
162+
XCTAssertNil(RCTAssetCatalogNameForURL(noExtensionURL));
163+
164+
// ".jpeg" is a catalog image type (matching the CLI's isCatalogAsset).
165+
NSURL *jpegURL =
166+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/photo@2x.jpeg"]];
167+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(jpegURL), @"awesomemodule_photo");
168+
169+
// Non-catalog image types (the CLI only emits png/jpg/jpeg into the catalog)
170+
// are not catalog assets and use the regular loader.
171+
NSURL *gifURL =
172+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/AwesomeModule/anim.gif"]];
173+
XCTAssertNil(RCTAssetCatalogNameForURL(gifURL));
174+
175+
// Assets outside the project root are encoded with "_" by the packager
176+
// (e.g. "assets/../../shared" -> "assets/__shared") and resolve to the same
177+
// identifier the CLI generates.
178+
NSURL *outOfRootURL =
179+
[NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"assets/__shared/icon@2x.png"]];
180+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(outOfRootURL), @"__shared_icon");
181+
182+
// Query strings are ignored, same as the legacy loader.
183+
NSURL *queryURL = [NSURL
184+
URLWithString:[NSString stringWithFormat:@"file://%@/assets/AwesomeModule/icon@2x.png?platform=ios&hash=abc",
185+
resourcePath]];
186+
XCTAssertEqualObjects(RCTAssetCatalogNameForURL(queryURL), @"awesomemodule_icon");
187+
188+
// A non-packager path (not under "assets/") is not a catalog asset.
189+
NSURL *notPackagerURL = [NSURL fileURLWithPath:[resourcePath stringByAppendingPathComponent:@"icon.png"]];
190+
XCTAssertNil(RCTAssetCatalogNameForURL(notPackagerURL));
191+
192+
// A nil URL is handled gracefully.
193+
XCTAssertNil(RCTAssetCatalogNameForURL(nil));
194+
}
195+
103196
@end

0 commit comments

Comments
 (0)