Skip to content

Commit e60c9cf

Browse files
Use asset catalog for iOS images
Load packager image assets from a compiled asset catalog instead of loose files in the app bundle. At build time react-native-xcode.sh has the CLI emit imagesets into a staging catalog (--asset-catalog-dest, already in @react-native/community-cli-plugin), compiles it with actool into an RNAssets.bundle inside the app, and the native image loader resolves images by name from that bundle with [UIImage imageNamed:inBundle:]. 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. The script owns the catalog end to end, so migrating an app is adding the one Info.plist key: no .xcassets to create and no Xcode project changes. The app's own asset pipeline (Images.xcassets, asset symbol generation, CompileAssetCatalog) is untouched, and 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 opt in via their Info.plists. 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).
1 parent e04ff69 commit e60c9cf

8 files changed

Lines changed: 354 additions & 2 deletions

File tree

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

9561064
UIImage *__nullable RCTImageFromLocalAssetURL(NSURL *imageURL)
9571065
{
1066+
if (RCTUseAssetCatalog()) {
1067+
NSString *catalogName = RCTAssetCatalogNameForURL(imageURL);
1068+
if (catalogName != nil) {
1069+
// The app opted into the asset catalog and this is a packager asset, so it
1070+
// was compiled into RNAssets.bundle at build time. Trust the catalog and
1071+
// return directly, keeping the common path a single lookup with no
1072+
// filesystem fallback. Non-catalog assets (nil name) fall through below.
1073+
NSBundle *assetCatalogBundle = RCTAssetCatalogBundle();
1074+
if (assetCatalogBundle == nil) {
1075+
// Passing a nil bundle to imageNamed:inBundle: would silently search the
1076+
// main bundle instead, potentially resolving an unrelated app image.
1077+
RCTLogError(
1078+
@"RCTUseAssetCatalog is enabled but RNAssets.bundle was not found in the app. Image assets must be "
1079+
"bundled by react-native-xcode.sh, which compiles them into the app at build time. (loading %@)",
1080+
imageURL);
1081+
return nil;
1082+
}
1083+
UIImage *image = [UIImage imageNamed:catalogName
1084+
inBundle:assetCatalogBundle
1085+
compatibleWithTraitCollection:nil];
1086+
if (image == nil) {
1087+
RCTLogError(
1088+
@"Image \"%@\" (%@) was not found in the asset catalog. RCTUseAssetCatalog is enabled, "
1089+
"so image assets must be compiled into the app's RNAssets.bundle at build time.",
1090+
catalogName,
1091+
imageURL);
1092+
}
1093+
return image;
1094+
}
1095+
}
1096+
9581097
NSString *imageName = RCTBundlePathForURL(imageURL);
9591098

9601099
NSBundle *bundle = nil;

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

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

154+
# shellcheck source=/dev/null
155+
source "$REACT_NATIVE_DIR/scripts/xcode/asset-catalog.sh"
156+
ASSET_CATALOG_STAGING_DIR="$(asset_catalog_staging_dir)"
157+
if [[ -n "$ASSET_CATALOG_STAGING_DIR" ]]; then
158+
EXTRA_ARGS+=("--asset-catalog-dest" "$ASSET_CATALOG_STAGING_DIR")
159+
fi
160+
154161
# shellcheck disable=SC2086
155162
"$NODE_BINARY" $NODE_ARGS "$CLI_PATH" $BUNDLE_COMMAND \
156163
$CONFIG_ARG \
@@ -163,6 +170,8 @@ fi
163170
"${EXTRA_ARGS[@]}" \
164171
$EXTRA_PACKAGER_ARGS
165172

173+
asset_catalog_compile "$ASSET_CATALOG_STAGING_DIR"
174+
166175
if [[ $USE_HERMES == false ]]; then
167176
cp "$BUNDLE_FILE" "$DEST/"
168177
BUNDLE_FILE="$DEST/$BUNDLE_NAME.jsbundle"
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/bin/bash
2+
# Copyright (c) Meta Platforms, Inc. and affiliates.
3+
#
4+
# This source code is licensed under the MIT license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
# Bundles packager image assets into a compiled asset catalog (RNAssets.bundle)
8+
# inside the app, where the native image loader resolves them by name. Sourced
9+
# by react-native-xcode.sh.
10+
#
11+
# The feature is opt-in via the RCTUseAssetCatalog key in the app's Info.plist,
12+
# which is the same key the native image loader reads, so bundling and runtime
13+
# cannot disagree on where image assets live. The catalog is fully owned by
14+
# these functions (staged in derived files, compiled with actool into the app's
15+
# resources next to the js bundle), so the app's Xcode project needs no
16+
# changes. Apps that have not migrated are unaffected.
17+
#
18+
# After changing the RCTUseAssetCatalog key, do a clean build: incremental
19+
# builds do not remove image assets a previous build placed in the app with the
20+
# other setting (they are unused but add dead weight).
21+
22+
# Prints the directory the bundler should emit image assets into (via
23+
# --asset-catalog-dest), or nothing if the app has not opted into the asset
24+
# catalog with the RCTUseAssetCatalog Info.plist key.
25+
asset_catalog_staging_dir() {
26+
[[ "$BUNDLE_PLATFORM" == "ios" && -n "$PRODUCT_SETTINGS_PATH" ]] || return 0
27+
28+
local use_asset_catalog
29+
use_asset_catalog="$(/usr/libexec/PlistBuddy -c 'Print :RCTUseAssetCatalog' "$PRODUCT_SETTINGS_PATH" 2>/dev/null || true)"
30+
# Accept the value forms NSBundle's boolValue treats as true, so this check
31+
# cannot disagree with the native runtime check.
32+
case "$(echo "$use_asset_catalog" | tr '[:upper:]' '[:lower:]')" in
33+
true | yes | 1) ;;
34+
*) return 0 ;;
35+
esac
36+
37+
local staging_dir="${DERIVED_FILE_DIR:-$(mktemp -d)}/rn-assets"
38+
rm -rf "$staging_dir"
39+
mkdir -p "$staging_dir/RNAssets.xcassets"
40+
printf '%s\n' "$staging_dir"
41+
}
42+
43+
# Compiles the staging catalog ($1, as returned by asset_catalog_staging_dir)
44+
# into RNAssets.bundle inside the app. The compiled Assets.car resolves the
45+
# right scale by name at runtime, see
46+
# https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs
47+
asset_catalog_compile() {
48+
local staging_dir="$1"
49+
local rn_assets_bundle="$DEST/RNAssets.bundle"
50+
# Always remove first so a stale bundle from a previous build does not ship
51+
# when the app opts out or has no image assets.
52+
rm -rf "$rn_assets_bundle"
53+
[[ -n "$staging_dir" ]] || return 0
54+
if [[ -z "$(find "$staging_dir/RNAssets.xcassets" -maxdepth 1 -name '*.imageset' -print -quit)" ]]; then
55+
return 0
56+
fi
57+
58+
mkdir -p "$rn_assets_bundle"
59+
local actool_args=("--platform" "${PLATFORM_NAME:-iphoneos}")
60+
# These are always iOS-family image assets (this runs only for BUNDLE_PLATFORM
61+
# "ios", which includes Mac Catalyst), so the deployment target must be an iOS
62+
# version. On Catalyst the platform is macosx but MACOSX_DEPLOYMENT_TARGET is a
63+
# macOS version (e.g. 10.15); passing that as the target makes actool silently
64+
# emit loose files instead of a compiled Assets.car, so it must not be used.
65+
actool_args+=("--minimum-deployment-target" "${IPHONEOS_DEPLOYMENT_TARGET:-15.1}")
66+
case "${TARGETED_DEVICE_FAMILY:-1}" in *1*) actool_args+=("--target-device" "iphone") ;; esac
67+
case "${TARGETED_DEVICE_FAMILY:-1}" in *2*) actool_args+=("--target-device" "ipad") ;; esac
68+
if [[ "${IS_MACCATALYST:-NO}" == "YES" ]]; then
69+
actool_args+=("--ui-framework-family" "uikit")
70+
fi
71+
72+
# Surface actool diagnostics in the build log: without these flags actool
73+
# suppresses them entirely, and it exits 0 even when it drops an imageset.
74+
local actool_output
75+
actool_output="$(xcrun actool "$staging_dir/RNAssets.xcassets" \
76+
--compile "$rn_assets_bundle" \
77+
--output-format human-readable-text \
78+
--errors --warnings --notices \
79+
"${actool_args[@]}" 2>&1)" || true
80+
echo "$actool_output"
81+
if [[ ! -f "$rn_assets_bundle/Assets.car" ]]; then
82+
echo "error: failed to compile image assets into RNAssets.bundle. See actool output above." >&2
83+
exit 2
84+
fi
85+
86+
cat > "$rn_assets_bundle/Info.plist" <<'RN_ASSETS_PLIST'
87+
<?xml version="1.0" encoding="UTF-8"?>
88+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
89+
<plist version="1.0">
90+
<dict>
91+
<key>CFBundleIdentifier</key>
92+
<string>org.reactjs.RNAssets</string>
93+
<key>CFBundleInfoDictionaryVersion</key>
94+
<string>6.0</string>
95+
<key>CFBundleName</key>
96+
<string>RNAssets</string>
97+
<key>CFBundlePackageType</key>
98+
<string>BNDL</string>
99+
</dict>
100+
</plist>
101+
RN_ASSETS_PLIST
102+
}

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>

0 commit comments

Comments
 (0)