Skip to content

Commit 98c1ada

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 98c1ada

8 files changed

Lines changed: 367 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: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -883,10 +883,27 @@ BOOL RCTIsGzippedData(NSData *__nullable data)
883883
return RCTRelativePathForURL(RCTHomePath(), URL);
884884
}
885885

886+
static BOOL RCTUseAssetCatalog(void);
887+
888+
// Image types the CLI emits into the asset catalog (see isCatalogAsset in
889+
// @react-native/community-cli-plugin).
890+
static BOOL RCTIsCatalogImagePath(NSString *path)
891+
{
892+
NSString *extension = [path pathExtension];
893+
return [extension isEqualToString:@"png"] || [extension isEqualToString:@"jpg"] ||
894+
[extension isEqualToString:@"jpeg"];
895+
}
896+
886897
static BOOL RCTIsImageAssetsPath(NSString *path)
887898
{
888899
NSString *extension = [path pathExtension];
889-
return [extension isEqualToString:@"png"] || [extension isEqualToString:@"jpg"];
900+
if ([extension isEqualToString:@"png"] || [extension isEqualToString:@"jpg"]) {
901+
return YES;
902+
}
903+
// jpeg assets are only routed through the bundle-asset loaders when the
904+
// asset catalog serves them; apps that have not opted in keep the previous
905+
// behavior (jpeg handled by the file request handler).
906+
return RCTUseAssetCatalog() && [extension isEqualToString:@"jpeg"];
890907
}
891908

892909
BOOL RCTIsBundleAssetURL(NSURL *__nullable imageURL)
@@ -939,6 +956,113 @@ BOOL RCTIsLocalAssetURL(NSURL *__nullable imageURL)
939956
return bundleCache[key];
940957
}
941958

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

9561080
UIImage *__nullable RCTImageFromLocalAssetURL(NSURL *imageURL)
9571081
{
1082+
if (RCTUseAssetCatalog()) {
1083+
NSString *catalogName = RCTAssetCatalogNameForURL(imageURL);
1084+
if (catalogName != nil) {
1085+
// The app opted into the asset catalog and this is a packager asset, so it
1086+
// was compiled into RNAssets.bundle at build time. Trust the catalog and
1087+
// return directly, keeping the common path a single lookup with no
1088+
// filesystem fallback. Non-catalog assets (nil name) fall through below.
1089+
NSBundle *assetCatalogBundle = RCTAssetCatalogBundle();
1090+
if (assetCatalogBundle == nil) {
1091+
// Passing a nil bundle to imageNamed:inBundle: would silently search the
1092+
// main bundle instead, potentially resolving an unrelated app image.
1093+
RCTLogError(
1094+
@"RCTUseAssetCatalog is enabled but RNAssets.bundle was not found in the app. Image assets must be "
1095+
"bundled by react-native-xcode.sh, which compiles them into the app at build time. (loading %@)",
1096+
imageURL);
1097+
return nil;
1098+
}
1099+
UIImage *image = [UIImage imageNamed:catalogName
1100+
inBundle:assetCatalogBundle
1101+
compatibleWithTraitCollection:nil];
1102+
if (image == nil) {
1103+
RCTLogError(
1104+
@"Image \"%@\" (%@) was not found in the asset catalog. RCTUseAssetCatalog is enabled, "
1105+
"so image assets must be compiled into the app's RNAssets.bundle at build time.",
1106+
catalogName,
1107+
imageURL);
1108+
}
1109+
return image;
1110+
}
1111+
}
1112+
9581113
NSString *imageName = RCTBundlePathForURL(imageURL);
9591114

9601115
NSBundle *bundle = nil;

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,14 @@ 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 asset catalog compiled into
156+
# RNAssets.bundle inside the app (see scripts/xcode/asset-catalog.sh). This also
157+
# covers Mac Catalyst (BUNDLE_PLATFORM is forced to "ios" above).
158+
# shellcheck source=/dev/null
159+
source "$REACT_NATIVE_DIR/scripts/xcode/asset-catalog.sh"
160+
asset_catalog_prepare
161+
154162
# shellcheck disable=SC2086
155163
"$NODE_BINARY" $NODE_ARGS "$CLI_PATH" $BUNDLE_COMMAND \
156164
$CONFIG_ARG \
@@ -163,6 +171,8 @@ fi
163171
"${EXTRA_ARGS[@]}" \
164172
$EXTRA_PACKAGER_ARGS
165173

174+
asset_catalog_compile
175+
166176
if [[ $USE_HERMES == false ]]; then
167177
cp "$BUNDLE_FILE" "$DEST/"
168178
BUNDLE_FILE="$DEST/$BUNDLE_NAME.jsbundle"
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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+
# Decides whether the app opted in and stages an empty catalog for the bundle
23+
# command to emit imagesets into. Sets USE_ASSET_CATALOG and
24+
# ASSET_CATALOG_STAGING_DIR, and appends --asset-catalog-dest to EXTRA_ARGS.
25+
asset_catalog_prepare() {
26+
USE_ASSET_CATALOG=""
27+
if [[ "$BUNDLE_PLATFORM" == "ios" && -n "$PRODUCT_SETTINGS_PATH" ]]; then
28+
USE_ASSET_CATALOG="$(/usr/libexec/PlistBuddy -c 'Print :RCTUseAssetCatalog' "$PRODUCT_SETTINGS_PATH" 2>/dev/null || true)"
29+
# Accept the value forms NSBundle's boolValue treats as true, so this check
30+
# cannot disagree with the native runtime check.
31+
case "$(echo "$USE_ASSET_CATALOG" | tr '[:upper:]' '[:lower:]')" in
32+
true | yes | 1) USE_ASSET_CATALOG="true" ;;
33+
*) USE_ASSET_CATALOG="" ;;
34+
esac
35+
if [[ "$USE_ASSET_CATALOG" == "true" ]]; then
36+
ASSET_CATALOG_STAGING_DIR="${DERIVED_FILE_DIR:-$(mktemp -d)}/rn-assets"
37+
rm -rf "$ASSET_CATALOG_STAGING_DIR"
38+
mkdir -p "$ASSET_CATALOG_STAGING_DIR/RNAssets.xcassets"
39+
EXTRA_ARGS+=("--asset-catalog-dest" "$ASSET_CATALOG_STAGING_DIR")
40+
fi
41+
fi
42+
}
43+
44+
# Compiles the staged catalog into RNAssets.bundle inside the app. The compiled
45+
# Assets.car resolves the 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 rn_assets_bundle="$DEST/RNAssets.bundle"
49+
# Always remove first so a stale bundle from a previous build does not ship
50+
# when the app opts out or has no image assets.
51+
rm -rf "$rn_assets_bundle"
52+
if [[ "$USE_ASSET_CATALOG" != "true" ]]; then
53+
return
54+
fi
55+
if [[ -z "$(find "$ASSET_CATALOG_STAGING_DIR/RNAssets.xcassets" -maxdepth 1 -name '*.imageset' -print -quit)" ]]; then
56+
return
57+
fi
58+
59+
mkdir -p "$rn_assets_bundle"
60+
local actool_args=("--platform" "${PLATFORM_NAME:-iphoneos}")
61+
actool_args+=("--minimum-deployment-target" "${IPHONEOS_DEPLOYMENT_TARGET:-${MACOSX_DEPLOYMENT_TARGET:-15.1}}")
62+
case "${TARGETED_DEVICE_FAMILY:-1}" in *1*) actool_args+=("--target-device" "iphone") ;; esac
63+
case "${TARGETED_DEVICE_FAMILY:-1}" in *2*) actool_args+=("--target-device" "ipad") ;; esac
64+
if [[ "${IS_MACCATALYST:-NO}" == "YES" ]]; then
65+
actool_args+=("--ui-framework-family" "uikit")
66+
fi
67+
68+
# Surface actool diagnostics in the build log: without these flags actool
69+
# suppresses them entirely, and it exits 0 even when it drops an imageset.
70+
local actool_output
71+
actool_output="$(xcrun actool "$ASSET_CATALOG_STAGING_DIR/RNAssets.xcassets" \
72+
--compile "$rn_assets_bundle" \
73+
--output-format human-readable-text \
74+
--errors --warnings --notices \
75+
"${actool_args[@]}" 2>&1)" || true
76+
echo "$actool_output"
77+
if [[ ! -f "$rn_assets_bundle/Assets.car" ]]; then
78+
echo "error: failed to compile image assets into RNAssets.bundle. See actool output above." >&2
79+
exit 2
80+
fi
81+
82+
cat > "$rn_assets_bundle/Info.plist" <<'RN_ASSETS_PLIST'
83+
<?xml version="1.0" encoding="UTF-8"?>
84+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
85+
<plist version="1.0">
86+
<dict>
87+
<key>CFBundleIdentifier</key>
88+
<string>org.reactjs.RNAssets</string>
89+
<key>CFBundleInfoDictionaryVersion</key>
90+
<string>6.0</string>
91+
<key>CFBundleName</key>
92+
<string>RNAssets</string>
93+
<key>CFBundlePackageType</key>
94+
<string>BNDL</string>
95+
</dict>
96+
</plist>
97+
RN_ASSETS_PLIST
98+
}

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)