Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/react-native/React/Base/RCTUtils.h
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ RCT_EXTERN NSData *__nullable RCTDecompressGzipData(NSData *__nullable data, NSU
// (or nil, if the URL does not specify a path within the main bundle)
RCT_EXTERN NSString *__nullable RCTBundlePathForURL(NSURL *__nullable URL);

// Returns the asset catalog image name for a packager asset URL, or nil if the
// URL is not a main-bundle packager asset. The name matches the identifier the
// CLI uses when generating the catalog (see assetPathUtils.getResourceIdentifier).
RCT_EXTERN NSString *__nullable RCTAssetCatalogNameForURL(NSURL *__nullable URL);

// Returns the Path of Library directory
RCT_EXTERN NSString *__nullable RCTLibraryPath(void);

Expand Down
157 changes: 156 additions & 1 deletion packages/react-native/React/Base/RCTUtils.mm
Original file line number Diff line number Diff line change
Expand Up @@ -883,10 +883,27 @@ BOOL RCTIsGzippedData(NSData *__nullable data)
return RCTRelativePathForURL(RCTHomePath(), URL);
}

static BOOL RCTUseAssetCatalog(void);

// Image types the CLI emits into the asset catalog (see isCatalogAsset in
// @react-native/community-cli-plugin).
static BOOL RCTIsCatalogImagePath(NSString *path)
{
NSString *extension = [path pathExtension];
return [extension isEqualToString:@"png"] || [extension isEqualToString:@"jpg"] ||
[extension isEqualToString:@"jpeg"];
}

static BOOL RCTIsImageAssetsPath(NSString *path)
{
NSString *extension = [path pathExtension];
return [extension isEqualToString:@"png"] || [extension isEqualToString:@"jpg"];
if ([extension isEqualToString:@"png"] || [extension isEqualToString:@"jpg"]) {
return YES;
}
// jpeg assets are only routed through the bundle-asset loaders when the
// asset catalog serves them; apps that have not opted in keep the previous
// behavior (jpeg handled by the file request handler).
return RCTUseAssetCatalog() && [extension isEqualToString:@"jpeg"];
}

BOOL RCTIsBundleAssetURL(NSURL *__nullable imageURL)
Expand Down Expand Up @@ -939,6 +956,113 @@ BOOL RCTIsLocalAssetURL(NSURL *__nullable imageURL)
return bundleCache[key];
}

static BOOL RCTUseAssetCatalog(void)
{
static BOOL useAssetCatalog = NO;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
useAssetCatalog = [[[NSBundle mainBundle] objectForInfoDictionaryKey:@"RCTUseAssetCatalog"] boolValue];
});
return useAssetCatalog;
}

// The bundle react-native-xcode.sh compiles packager image assets into
// (RNAssets.bundle, an actool-compiled asset catalog inside the app).
static NSBundle *__nullable RCTAssetCatalogBundle(void)
{
static NSBundle *bundle;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSURL *bundleURL = [[NSBundle mainBundle] URLForResource:@"RNAssets" withExtension:@"bundle"];
if (bundleURL != nil) {
bundle = [NSBundle bundleWithURL:bundleURL];
}
});
return bundle;
}

NSString *__nullable RCTAssetCatalogNameForURL(NSURL *__nullable URL)
{
// The "assets/" prefix the packager uses for all image assets. The CLI strips
// it from the identifiers it names the imagesets with.
const NSUInteger assetsPrefixLength = 7;

NSString *path = RCTBundlePathForURL(URL);
// Packager assets always live under "assets/". Anything else (sub-bundles,
// CodePush/OTA assets outside the main bundle) is not in the catalog.
if (path == nil || ![path hasPrefix:@"assets/"]) {
return nil;
}

// Other packager assets (gif, webp, ...) are copied as plain files and must
// use the regular loader.
if (!RCTIsCatalogImagePath(path)) {
return nil;
}

// File system paths come back decomposed (NFD); restore the precomposed form
// the packager saw on disk so non-ASCII characters filter out the same way
// they do in the CLI's identifier.
path = path.precomposedStringWithCanonicalMapping;

const NSUInteger length = path.length;
unichar stackBuffer[256];
unichar *chars = length <= 256 ? stackBuffer : (unichar *)malloc(length * sizeof(unichar));
[path getCharacters:chars range:NSMakeRange(0, length)];

// Strip the file extension (guaranteed present by RCTIsImageAssetsPath) and
// an optional "@<scale>x" suffix (integer or fractional, e.g. "@2x",
// "@1.5x"). The catalog stores a single imageset per image and resolves the
// scale by name at runtime, see
// https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs
NSUInteger end = length - 1;
while (end > assetsPrefixLength && chars[end] != '.') {
end--;
}
if (end > assetsPrefixLength && chars[end - 1] == 'x') {
// Walk back over "@<digits>(.<digits>)?" ending at the "x".
NSUInteger cursor = end - 1;
while (cursor > assetsPrefixLength && chars[cursor - 1] >= '0' && chars[cursor - 1] <= '9') {
cursor--;
}
if (cursor < end - 1) {
if (chars[cursor - 1] == '@') {
end = cursor - 1;
} else if (chars[cursor - 1] == '.') {
NSUInteger integerPart = cursor - 1;
while (integerPart > assetsPrefixLength && chars[integerPart - 1] >= '0' && chars[integerPart - 1] <= '9') {
integerPart--;
}
if (integerPart < cursor - 1 && chars[integerPart - 1] == '@') {
end = integerPart - 1;
}
}
}
}

// Build the identifier in place in a single pass: skip the "assets/" prefix,
// lowercase, encode the folder structure with "_" and drop anything that is
// not a valid identifier character, producing the same identifier as
// getResourceIdentifier in the CLI, which names the imagesets.
NSUInteger resultLength = 0;
for (NSUInteger i = assetsPrefixLength; i < end; i++) {
unichar c = chars[i];
if (c == '/') {
chars[resultLength++] = '_';
} else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') {
chars[resultLength++] = c;
} else if (c >= 'A' && c <= 'Z') {
chars[resultLength++] = c + ('a' - 'A');
}
}

NSString *name = [NSString stringWithCharacters:chars length:resultLength];
if (chars != stackBuffer) {
free(chars);
}
return name;
}

UIImage *__nullable RCTImageFromLocalBundleAssetURL(NSURL *imageURL)
{
if (![imageURL.scheme isEqualToString:@"file"]) {
Expand All @@ -955,6 +1079,37 @@ BOOL RCTIsLocalAssetURL(NSURL *__nullable imageURL)

UIImage *__nullable RCTImageFromLocalAssetURL(NSURL *imageURL)
{
if (RCTUseAssetCatalog()) {
NSString *catalogName = RCTAssetCatalogNameForURL(imageURL);
if (catalogName != nil) {
// The app opted into the asset catalog and this is a packager asset, so it
// was compiled into RNAssets.bundle at build time. Trust the catalog and
// return directly, keeping the common path a single lookup with no
// filesystem fallback. Non-catalog assets (nil name) fall through below.
NSBundle *assetCatalogBundle = RCTAssetCatalogBundle();
if (assetCatalogBundle == nil) {
// Passing a nil bundle to imageNamed:inBundle: would silently search the
// main bundle instead, potentially resolving an unrelated app image.
RCTLogError(
@"RCTUseAssetCatalog is enabled but RNAssets.bundle was not found in the app. Image assets must be "
"bundled by react-native-xcode.sh, which compiles them into the app at build time. (loading %@)",
imageURL);
return nil;
}
UIImage *image = [UIImage imageNamed:catalogName
inBundle:assetCatalogBundle
compatibleWithTraitCollection:nil];
if (image == nil) {
RCTLogError(
@"Image \"%@\" (%@) was not found in the asset catalog. RCTUseAssetCatalog is enabled, "
"so image assets must be compiled into the app's RNAssets.bundle at build time.",
catalogName,
imageURL);
}
return image;
}
}

NSString *imageName = RCTBundlePathForURL(imageURL);

NSBundle *bundle = nil;
Expand Down
10 changes: 10 additions & 0 deletions packages/react-native/scripts/react-native-xcode.sh
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,14 @@ else
EXTRA_ARGS+=("--config-cmd" "'$NODE_BINARY' $NODE_ARGS '$REACT_NATIVE_DIR/cli.js' config")
fi

# iOS asset catalog: when the app opts in with the RCTUseAssetCatalog key in its
# Info.plist, emit image assets into an asset catalog compiled into
# RNAssets.bundle inside the app (see scripts/xcode/asset-catalog.sh). This also
# covers Mac Catalyst (BUNDLE_PLATFORM is forced to "ios" above).
# shellcheck source=/dev/null
source "$REACT_NATIVE_DIR/scripts/xcode/asset-catalog.sh"
asset_catalog_prepare

# shellcheck disable=SC2086
"$NODE_BINARY" $NODE_ARGS "$CLI_PATH" $BUNDLE_COMMAND \
$CONFIG_ARG \
Expand All @@ -163,6 +171,8 @@ fi
"${EXTRA_ARGS[@]}" \
$EXTRA_PACKAGER_ARGS

asset_catalog_compile

if [[ $USE_HERMES == false ]]; then
cp "$BUNDLE_FILE" "$DEST/"
BUNDLE_FILE="$DEST/$BUNDLE_NAME.jsbundle"
Expand Down
98 changes: 98 additions & 0 deletions packages/react-native/scripts/xcode/asset-catalog.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/bin/bash
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.

# Bundles packager image assets into a compiled asset catalog (RNAssets.bundle)
# inside the app, where the native image loader resolves them by name. Sourced
# by react-native-xcode.sh.
#
# The feature is opt-in via the RCTUseAssetCatalog key in the app's Info.plist,
# which is the same key the native image loader reads, so bundling and runtime
# cannot disagree on where image assets live. The catalog is fully owned by
# these functions (staged in derived files, compiled with actool into the app's
# resources next to the js bundle), so the app's Xcode project needs no
# changes. Apps that have not migrated are unaffected.
#
# After changing the RCTUseAssetCatalog key, do a clean build: incremental
# builds do not remove image assets a previous build placed in the app with the
# other setting (they are unused but add dead weight).

# Decides whether the app opted in and stages an empty catalog for the bundle
# command to emit imagesets into. Sets USE_ASSET_CATALOG and
# ASSET_CATALOG_STAGING_DIR, and appends --asset-catalog-dest to EXTRA_ARGS.
asset_catalog_prepare() {
USE_ASSET_CATALOG=""
if [[ "$BUNDLE_PLATFORM" == "ios" && -n "$PRODUCT_SETTINGS_PATH" ]]; then
USE_ASSET_CATALOG="$(/usr/libexec/PlistBuddy -c 'Print :RCTUseAssetCatalog' "$PRODUCT_SETTINGS_PATH" 2>/dev/null || true)"
# Accept the value forms NSBundle's boolValue treats as true, so this check
# cannot disagree with the native runtime check.
case "$(echo "$USE_ASSET_CATALOG" | tr '[:upper:]' '[:lower:]')" in
true | yes | 1) USE_ASSET_CATALOG="true" ;;
*) USE_ASSET_CATALOG="" ;;
esac
if [[ "$USE_ASSET_CATALOG" == "true" ]]; then
ASSET_CATALOG_STAGING_DIR="${DERIVED_FILE_DIR:-$(mktemp -d)}/rn-assets"
rm -rf "$ASSET_CATALOG_STAGING_DIR"
mkdir -p "$ASSET_CATALOG_STAGING_DIR/RNAssets.xcassets"
EXTRA_ARGS+=("--asset-catalog-dest" "$ASSET_CATALOG_STAGING_DIR")
fi
fi
}

# Compiles the staged catalog into RNAssets.bundle inside the app. The compiled
# Assets.car resolves the right scale by name at runtime, see
# https://developer.apple.com/documentation/xcode/managing-assets-with-asset-catalogs
asset_catalog_compile() {
local rn_assets_bundle="$DEST/RNAssets.bundle"
# Always remove first so a stale bundle from a previous build does not ship
# when the app opts out or has no image assets.
rm -rf "$rn_assets_bundle"
if [[ "$USE_ASSET_CATALOG" != "true" ]]; then
return
fi
if [[ -z "$(find "$ASSET_CATALOG_STAGING_DIR/RNAssets.xcassets" -maxdepth 1 -name '*.imageset' -print -quit)" ]]; then
return
fi

mkdir -p "$rn_assets_bundle"
local actool_args=("--platform" "${PLATFORM_NAME:-iphoneos}")
actool_args+=("--minimum-deployment-target" "${IPHONEOS_DEPLOYMENT_TARGET:-${MACOSX_DEPLOYMENT_TARGET:-15.1}}")
case "${TARGETED_DEVICE_FAMILY:-1}" in *1*) actool_args+=("--target-device" "iphone") ;; esac
case "${TARGETED_DEVICE_FAMILY:-1}" in *2*) actool_args+=("--target-device" "ipad") ;; esac
if [[ "${IS_MACCATALYST:-NO}" == "YES" ]]; then
actool_args+=("--ui-framework-family" "uikit")
fi

# Surface actool diagnostics in the build log: without these flags actool
# suppresses them entirely, and it exits 0 even when it drops an imageset.
local actool_output
actool_output="$(xcrun actool "$ASSET_CATALOG_STAGING_DIR/RNAssets.xcassets" \
--compile "$rn_assets_bundle" \
--output-format human-readable-text \
--errors --warnings --notices \
"${actool_args[@]}" 2>&1)" || true
echo "$actool_output"
if [[ ! -f "$rn_assets_bundle/Assets.car" ]]; then
echo "error: failed to compile image assets into RNAssets.bundle. See actool output above." >&2
exit 2
fi

cat > "$rn_assets_bundle/Info.plist" <<'RN_ASSETS_PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>org.reactjs.RNAssets</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>RNAssets</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
</dict>
</plist>
RN_ASSETS_PLIST
}
2 changes: 2 additions & 0 deletions packages/rn-tester/RNTester/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
<string>You need to add NSPhotoLibraryUsageDescription key in Info.plist to enable photo library usage, otherwise it is going to *fail silently*!</string>
<key>RCTNewArchEnabled</key>
<true/>
<key>RCTUseAssetCatalog</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
Expand Down
Loading
Loading