From 57ebb109c180201e3e9d077ce4130fe04f75180c Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 21 Oct 2026 21:17:35 -0400 Subject: [PATCH 1/3] Keep animated PNGs out of the iOS asset catalog actool compiles an animated PNG into a single still frame, so the CLI now copies any png asset with an acTL chunk as a loose file instead of adding it to RNAssets.xcassets. The runtime falls through to the loose-file lookup on a catalog miss and only reports an error when that fails too. --- .../bundle/__tests__/assetCatalogIOS-test.js | 144 +++++++++++++++++- .../src/commands/bundle/assetCatalogIOS.js | 54 ++++++- packages/react-native/React/Base/RCTUtils.mm | 28 ++-- packages/rn-tester/js/assets/animated.png | Bin 0 -> 1442 bytes .../js/examples/Image/ImageExample.js | 20 +++ 5 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 packages/rn-tester/js/assets/animated.png diff --git a/packages/community-cli-plugin/src/commands/bundle/__tests__/assetCatalogIOS-test.js b/packages/community-cli-plugin/src/commands/bundle/__tests__/assetCatalogIOS-test.js index 36023209e6bd..1f7260a7b91f 100644 --- a/packages/community-cli-plugin/src/commands/bundle/__tests__/assetCatalogIOS-test.js +++ b/packages/community-cli-plugin/src/commands/bundle/__tests__/assetCatalogIOS-test.js @@ -8,8 +8,15 @@ * @format */ -import {getCatalogImages, getImageSet} from '../assetCatalogIOS'; +import { + getCatalogImages, + getImageSet, + isAnimatedPNG, + isCatalogAsset, +} from '../assetCatalogIOS'; +const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); jest.dontMock('../assetCatalogIOS'); @@ -102,3 +109,138 @@ describe('getImageSet', () => { ]); }); }); + +describe('isAnimatedPNG', () => { + const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + + // CRCs are not validated by the detector, so chunks carry a zero CRC. + function chunk(type: string, data: Buffer = Buffer.alloc(0)): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + return Buffer.concat([ + length, + Buffer.from(type, 'latin1'), + data, + Buffer.alloc(4), + ]); + } + + const IHDR = chunk('IHDR', Buffer.alloc(13)); + const IDAT = chunk('IDAT', Buffer.from([0x78, 0x9c, 0x03, 0x00])); + const IEND = chunk('IEND'); + const acTL = chunk('acTL', Buffer.alloc(8)); + + let dir: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'assetCatalogIOS-')); + }); + + afterAll(() => { + fs.rmSync(dir, {recursive: true, force: true}); + }); + + function writeFile(name: string, ...parts: Array): string { + const file = path.join(dir, name); + fs.writeFileSync(file, Buffer.concat(parts)); + return file; + } + + test('detects an acTL chunk before the first IDAT', () => { + const file = writeFile( + 'animated.png', + PNG_SIGNATURE, + IHDR, + acTL, + IDAT, + IEND, + ); + expect(isAnimatedPNG(file)).toBe(true); + }); + + test('skips over chunk data when walking the headers', () => { + const large = chunk('iCCP', Buffer.alloc(70000)); + const file = writeFile( + 'icc.png', + PNG_SIGNATURE, + IHDR, + large, + acTL, + IDAT, + IEND, + ); + expect(isAnimatedPNG(file)).toBe(true); + }); + + test('is false for a still PNG', () => { + const file = writeFile('still.png', PNG_SIGNATURE, IHDR, IDAT, IEND); + expect(isAnimatedPNG(file)).toBe(false); + }); + + test('is false for a file without the PNG signature', () => { + const file = writeFile('not.png', Buffer.from('not a png')); + expect(isAnimatedPNG(file)).toBe(false); + }); + + test('is false for a file truncated inside the chunk headers', () => { + const file = writeFile('truncated.png', PNG_SIGNATURE, IHDR.subarray(0, 6)); + expect(isAnimatedPNG(file)).toBe(false); + }); + + test('is false for a missing file', () => { + expect(isAnimatedPNG(path.join(dir, 'missing.png'))).toBe(false); + }); + + describe('isCatalogAsset', () => { + function makeFileAsset(type: string, files: Array) { + return { + ...makeAsset(files.map((_, idx) => idx + 1)), + files, + type, + }; + } + + test('keeps a still PNG in the catalog', () => { + const file = writeFile('logo.png', PNG_SIGNATURE, IHDR, IDAT, IEND); + expect(isCatalogAsset(makeFileAsset('png', [file]))).toBe(true); + }); + + test('keeps an animated PNG out of the catalog', () => { + const file = writeFile( + 'spinner.png', + PNG_SIGNATURE, + IHDR, + acTL, + IDAT, + IEND, + ); + expect(isCatalogAsset(makeFileAsset('png', [file]))).toBe(false); + }); + + test('keeps the whole asset out when only one scale is animated', () => { + const still = writeFile('mixed.png', PNG_SIGNATURE, IHDR, IDAT, IEND); + const animated = writeFile( + 'mixed@2x.png', + PNG_SIGNATURE, + IHDR, + acTL, + IDAT, + IEND, + ); + expect(isCatalogAsset(makeFileAsset('png', [still, animated]))).toBe( + false, + ); + }); + + test('does not read non-PNG assets', () => { + expect( + isCatalogAsset(makeFileAsset('jpg', [path.join(dir, 'missing.jpg')])), + ).toBe(true); + expect( + isCatalogAsset(makeFileAsset('gif', [path.join(dir, 'missing.gif')])), + ).toBe(false); + }); + }); +}); diff --git a/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js b/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js index ff03a6ef130e..98c57f64847b 100644 --- a/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js +++ b/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js @@ -86,7 +86,59 @@ export function getImageSet(catalogDir: string, asset: AssetData): ImageSet { } export function isCatalogAsset(asset: AssetData): boolean { - return asset.type === 'png' || asset.type === 'jpg' || asset.type === 'jpeg'; + if (asset.type === 'png') { + // The runtime resolves an asset by name, so all of its scales must be + // either in the catalog or loose. + return !asset.files.some(isAnimatedPNG); + } + return asset.type === 'jpg' || asset.type === 'jpeg'; +} + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +/** + * Whether the file is an animated PNG: a PNG with an `acTL` chunk before its + * first `IDAT` (https://www.w3.org/TR/png-3/#acTL-chunk). actool compiles + * such a file into a single still frame, so it must stay a loose file. + * + * Only the chunk headers are read. An unreadable or malformed file is reported + * as not animated so that it stays on the catalog path, where actool reports it. + */ +export function isAnimatedPNG(filePath: string): boolean { + let fd = null; + try { + fd = fs.openSync(filePath, 'r'); + const signature = Buffer.alloc(PNG_SIGNATURE.length); + if ( + fs.readSync(fd, signature, 0, signature.length, 0) !== signature.length || + !signature.equals(PNG_SIGNATURE) + ) { + return false; + } + const chunkHeader = Buffer.alloc(8); + let position = signature.length; + while (fs.readSync(fd, chunkHeader, 0, 8, position) === 8) { + const dataLength = chunkHeader.readUInt32BE(0); + const type = chunkHeader.toString('latin1', 4, 8); + if (type === 'acTL') { + return true; + } + if (type === 'IDAT' || type === 'IEND') { + return false; + } + // Skip the chunk data and its 4-byte CRC. + position += 8 + dataLength + 4; + } + return false; + } catch { + return false; + } finally { + if (fd != null) { + fs.closeSync(fd); + } + } } export function writeImageSet(imageSet: ImageSet): void { diff --git a/packages/react-native/React/Base/RCTUtils.mm b/packages/react-native/React/Base/RCTUtils.mm index 21ddfb6ff1f2..7a766876f55b 100644 --- a/packages/react-native/React/Base/RCTUtils.mm +++ b/packages/react-native/React/Base/RCTUtils.mm @@ -1117,13 +1117,16 @@ static BOOL RCTUseAssetCatalog(void) UIImage *__nullable RCTImageFromLocalAssetURL(NSURL *imageURL) { + NSString *catalogName = nil; if (RCTUseAssetCatalog()) { - NSString *catalogName = RCTAssetCatalogNameForURL(imageURL); + 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. + // was compiled into RNAssets.bundle at build time. Return directly on a + // hit, keeping the common path a single lookup. Non-catalog assets (nil + // name) and catalog misses fall through to the loose-file lookup below: + // the CLI copies animated PNGs as loose files, since actool would compile + // them into a single still frame. NSBundle *assetCatalogBundle = RCTAssetCatalogBundle(); if (assetCatalogBundle == nil) { // Passing a nil bundle to imageNamed:inBundle: would silently search the @@ -1135,14 +1138,9 @@ static BOOL RCTUseAssetCatalog(void) 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); + if (image != nil) { + return image; } - return image; } } @@ -1194,6 +1192,14 @@ static BOOL RCTUseAssetCatalog(void) } } } + + if (image == nil && catalogName != nil) { + RCTLogError( + @"Image \"%@\" (%@) was not found in the asset catalog or as a file. RCTUseAssetCatalog is enabled, " + "so image assets must be compiled into the app's RNAssets.bundle at build time.", + catalogName, + imageURL); + } return image; } diff --git a/packages/rn-tester/js/assets/animated.png b/packages/rn-tester/js/assets/animated.png new file mode 100644 index 0000000000000000000000000000000000000000..e51b9fc03c79ebfb12ee5590a46db38761380baa GIT binary patch literal 1442 zcmXw(c{J2(9L9gMBnBZG z38j!djdf6lnX+c0jkwL_O73qC&Uv5rdEW2){`Gmzd9NOJw3S6|MF9XHYi|cqAt#6> zDFN+|X<@^VN&5P`kReAx0xoo&;zG9d)apz;BCJWA0RRRdVZa^vQ92Y|?LjM-=-c1( zJ5-yu$t2QFPphcb`$y!6K1omdw#ob6T~V21=qMRt^%RIdIw#s&@ohifzg~*1$Mu?> zS4bUGuYl(oP1{Xa-b+;3Gem|wLB)u}d=p-4wXY6-Lugwzg91X1%}!ak7P+S7Jf&|I zN|XiTF*SH+pfq3EV9&=ZJ`wL=;Lt^@Qui$cllcUjDD(J-!sJ4FA&k{HRfDU%@Dx_X z@QytGaM2gxAQvULSo|qfmLN@N2Ie-_&{v2*(pOJhICmFN37E(LSU?s5J(yQ+a9!yn zWGwk3#CP$1&E7qXOq|&rv!|ZYf%L)nfl)(jz@iT#76*n}QR*MVoDOoQ+%Ca;iQF%f zsy;E64$=mDx@0}}BTMl)xlFpv-kRNPV;`njbVRkBRfOd5uNeOmEjpGj>Ws6)1)lz< zZnfriZ@U&@e@2o5MB7^vA=v9Rz@TipCUJ8Tt2H#|)m;N|;GJZ)u)YCmJ;8#Vwza%TBpJg^B*E zxgVr{PzZh%cE2+hZHcQJvo9X_1yU)}RcudJ)=hF+UYLQIX_mZ`cVXGPU?N%1of_z- zshdNoT{^ODr)eCCyz{el`!#vRj;|zTkI~y|O9jvPn-zs^XHZg<6PNx`5jBLMWUm;- zYTh`6)zddvUp@|hFW~QU%0o9*=zRyYXF-3wQ(%L7^`YXQInVG3zVvSoQocPu-+htR zp66i@8u7a01;>AhV=kKUIn#I;1UM@rj z9SJS?w*wY#WSlHgu?cJ-n}MC$R$_dQmAeu2(Ho^T%;IoqZlr- z@q7u={RZ5dK{|V!Lo$|;NZQ57YI+?Ok&igAFPfLjTl{l2h`l_!|1|zjc^>yKN=cr^ z4+Z=KSDW8lTvMCfNi)v40vsFuIL$Vh;?$24w2&G6MeD0WaAc0QK0}L;J*R`=B2&B! zdj(R=d}N?BxO3zUmY~y33))MgF(l{tG@_?8a`Jh9QY6_fn)O+;1ym%#lQE2@OhV&j za3;Ox-b+!lAiys&UVSiw8*8}ZMS=o6zd9Xsg#LMNt_V5XB8^6eS>vxxHJOw@F&Rmol>X{C2cJ+dm>vl&YSe zKy*n`oOxoyo{>Lba@n!AFg+@y`?*HBE$e}`#a$(=n*60fEeFkygiNx!=7p2q1Dk9) zerKsqbXj_xEcfgIv~U1Za#||<%e}seWLWzIg@0{V{9?-Wefq!cJV~k+ahSk{tuKpK zM6*OxBRvQhoi?Y1f;d*jgwZsH)tWirXCE}?{EFwwL<{I(k$n%knusbM2@wV~^oRBh zFF1IHW_-XoA#Pg4EuN8UILf~zmshzZtUWO}eQ3Pozt+M+%&}6>WreC%%U>W-`-z&) nSjzQUr$;1SU0W(PStwZ8*fdY0JBrxe5FWtZ+7Vi$uQ*m@ literal 0 HcmV?d00001 diff --git a/packages/rn-tester/js/examples/Image/ImageExample.js b/packages/rn-tester/js/examples/Image/ImageExample.js index eb0b2d3875b4..90ef9263846f 100644 --- a/packages/rn-tester/js/examples/Image/ImageExample.js +++ b/packages/rn-tester/js/examples/Image/ImageExample.js @@ -1115,6 +1115,10 @@ const styles = StyleSheet.create({ flex: 1, height: 200, }, + apng: { + width: 64, + height: 64, + }, base64: { flex: 1, height: 50, @@ -1965,6 +1969,22 @@ exports.examples = [ }, platform: 'ios', }, + { + title: 'Animated PNG', + name: 'animated-png', + description: + 'A bundled APNG. With RCTUseAssetCatalog on, it ships as a loose file instead of being compiled into the asset catalog, which would keep only its first frame.', + render: function (): React.Node { + return ( + + ); + }, + platform: 'ios', + }, { title: 'Base64 image', name: 'base64-image', From 1d9e44156b76be92aae892e168c3dae721152b88 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 21 Oct 2026 21:31:06 -0400 Subject: [PATCH 2/3] Treat a missing RNAssets.bundle as a catalog miss --- packages/react-native/React/Base/RCTUtils.mm | 39 +++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/react-native/React/Base/RCTUtils.mm b/packages/react-native/React/Base/RCTUtils.mm index 7a766876f55b..67d2beb71ad6 100644 --- a/packages/react-native/React/Base/RCTUtils.mm +++ b/packages/react-native/React/Base/RCTUtils.mm @@ -1126,20 +1126,16 @@ static BOOL RCTUseAssetCatalog(void) // hit, keeping the common path a single lookup. Non-catalog assets (nil // name) and catalog misses fall through to the loose-file lookup below: // the CLI copies animated PNGs as loose files, since actool would compile - // them into a single still frame. + // them into a single still frame. RNAssets.bundle itself is absent when + // every image asset was copied loose, so a missing bundle is a miss too. 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) { - return image; + // Passing a nil bundle to imageNamed:inBundle: would silently search the + // main bundle instead, potentially resolving an unrelated app image. + if (assetCatalogBundle != nil) { + UIImage *image = [UIImage imageNamed:catalogName inBundle:assetCatalogBundle compatibleWithTraitCollection:nil]; + if (image != nil) { + return image; + } } } } @@ -1194,11 +1190,18 @@ static BOOL RCTUseAssetCatalog(void) } if (image == nil && catalogName != nil) { - RCTLogError( - @"Image \"%@\" (%@) was not found in the asset catalog or as a file. RCTUseAssetCatalog is enabled, " - "so image assets must be compiled into the app's RNAssets.bundle at build time.", - catalogName, - imageURL); + if (RCTAssetCatalogBundle() == nil) { + 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); + } else { + RCTLogError( + @"Image \"%@\" (%@) was not found in the asset catalog or as a file. RCTUseAssetCatalog is enabled, " + "so image assets must be compiled into the app's RNAssets.bundle at build time.", + catalogName, + imageURL); + } } return image; } From 065d670ee6c5d49e230904e19ba819a368ca8bae Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 21 Oct 2026 21:31:51 -0400 Subject: [PATCH 3/3] Say that Image shows the APNG's first frame in the RNTester example --- packages/rn-tester/js/examples/Image/ImageExample.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rn-tester/js/examples/Image/ImageExample.js b/packages/rn-tester/js/examples/Image/ImageExample.js index 90ef9263846f..f94fb99050b8 100644 --- a/packages/rn-tester/js/examples/Image/ImageExample.js +++ b/packages/rn-tester/js/examples/Image/ImageExample.js @@ -1973,7 +1973,7 @@ exports.examples = [ title: 'Animated PNG', name: 'animated-png', description: - 'A bundled APNG. With RCTUseAssetCatalog on, it ships as a loose file instead of being compiled into the asset catalog, which would keep only its first frame.', + 'A bundled APNG. Image shows its first frame, but with RCTUseAssetCatalog on the file ships loose, with all its frames, instead of being flattened into the asset catalog.', render: function (): React.Node { return (