From 980ae3d32843591472b244055b421c74148edcc0 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 5 Aug 2026 00:57:56 -0400 Subject: [PATCH] Fix asset catalog scale-to-file pairing and handle assets without standard scales The iOS asset catalog path filtered asset scales through filterPlatformAssetScales but indexed the unfiltered asset.files array, so an asset with scales [1, 1.5, 2, 3] would associate the 2x catalog rendition with the 1.5x file and the 3x rendition with the 2x file. Additionally, assets with no standard scale at all (e.g. only @1.5x) produced an imageset actool silently drops from Assets.car, making the image unloadable when RCTUseAssetCatalog is enabled. These assets now map their closest variant into the nearest valid slot (the same "closest larger" fallback filterPlatformAssetScales applies to loose files) with a build-log warning, so every imageset contains at least one rendition actool will compile. --- .../bundle/__tests__/assetCatalogIOS-test.js | 104 ++++++++++++++++++ .../src/commands/bundle/assetCatalogIOS.js | 51 +++++++-- .../src/commands/bundle/saveAssets.js | 7 +- 3 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 packages/community-cli-plugin/src/commands/bundle/__tests__/assetCatalogIOS-test.js 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 new file mode 100644 index 000000000000..36023209e6bd --- /dev/null +++ b/packages/community-cli-plugin/src/commands/bundle/__tests__/assetCatalogIOS-test.js @@ -0,0 +1,104 @@ +/** + * 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. + * + * @flow strict-local + * @format + */ + +import {getCatalogImages, getImageSet} from '../assetCatalogIOS'; + +const path = require('node:path'); + +jest.dontMock('../assetCatalogIOS'); + +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +function makeAsset(scales: Array) { + return { + __packager_asset: true, + fileSystemLocation: '/project/img', + httpServerLocation: '/assets/img', + width: 100, + height: 100, + scales, + files: scales.map( + scale => `/project/img/logo${scale === 1 ? '' : `@${scale}x`}.png`, + ), + hash: 'hash', + name: 'logo', + type: 'png', + }; +} + +describe('getCatalogImages', () => { + test('pairs each standard scale with its file', () => { + const asset = makeAsset([1, 2, 3]); + expect(getCatalogImages(asset)).toEqual([ + {scale: 1, src: '/project/img/logo.png'}, + {scale: 2, src: '/project/img/logo@2x.png'}, + {scale: 3, src: '/project/img/logo@3x.png'}, + ]); + }); + + test('skips non-standard scales without shifting file pairing', () => { + // Regression test: filtering scales without filtering files used to + // associate the 2x rendition with the 1.5x file. + const asset = makeAsset([1, 1.5, 2, 3]); + expect(getCatalogImages(asset)).toEqual([ + {scale: 1, src: '/project/img/logo.png'}, + {scale: 2, src: '/project/img/logo@2x.png'}, + {scale: 3, src: '/project/img/logo@3x.png'}, + ]); + }); + + test('maps a fractional-only asset into the nearest valid slot', () => { + const asset = makeAsset([1.5]); + expect(getCatalogImages(asset)).toEqual([ + {scale: 2, src: '/project/img/logo@1.5x.png'}, + ]); + }); + + test('clamps scales larger than 3x to the 3x slot', () => { + const asset = makeAsset([4]); + expect(getCatalogImages(asset)).toEqual([ + {scale: 3, src: '/project/img/logo@4x.png'}, + ]); + }); + + test('uses the largest fractional variant when several exist', () => { + const asset = makeAsset([1.5, 2.5]); + expect(getCatalogImages(asset)).toEqual([ + {scale: 3, src: '/project/img/logo@2.5x.png'}, + ]); + }); +}); + +describe('getImageSet', () => { + test('builds imageset path and per-scale file entries', () => { + const asset = makeAsset([1, 2, 3]); + const imageSet = getImageSet('/catalog', asset); + expect(imageSet.basePath).toBe(path.join('/catalog', 'img_logo.imageset')); + expect(imageSet.files).toEqual([ + {name: 'img_logo.png', scale: 1, src: '/project/img/logo.png'}, + {name: 'img_logo@2x.png', scale: 2, src: '/project/img/logo@2x.png'}, + {name: 'img_logo@3x.png', scale: 3, src: '/project/img/logo@3x.png'}, + ]); + }); + + test('names the fallback rendition after its catalog slot', () => { + const asset = makeAsset([1.5]); + const imageSet = getImageSet('/catalog', asset); + expect(imageSet.files).toEqual([ + {name: 'img_logo@2x.png', scale: 2, src: '/project/img/logo@1.5x.png'}, + ]); + }); +}); diff --git a/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js b/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js index 7ecf219a5372..657110f5b8fa 100644 --- a/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js +++ b/packages/community-cli-plugin/src/commands/bundle/assetCatalogIOS.js @@ -28,20 +28,57 @@ type ImageSet = { files: {name: string, src: string, scale: number}[], }; -export function getImageSet( - catalogDir: string, - asset: AssetData, - scales: ReadonlyArray, -): ImageSet { +// Scales an iOS asset catalog imageset can hold. actool silently drops +// renditions at any other scale (e.g. a fractional @1.5x). +const CATALOG_SCALES = [1, 2, 3]; + +type CatalogImage = {scale: number, src: string}; + +/** + * Pairs each catalog-valid scale of the asset with its source file. + * + * If the asset has no valid scale at all (e.g. only a fractional @1.5x + * variant), its closest variant is mapped into the nearest valid slot, + * mirroring the "closest larger" fallback filterPlatformAssetScales applies + * to loose files, so the imageset always contains at least one rendition + * actool will compile. + */ +export function getCatalogImages(asset: AssetData): Array { + const images: Array = []; + asset.scales.forEach((scale, idx) => { + if (CATALOG_SCALES.includes(scale)) { + images.push({scale, src: asset.files[idx]}); + } + }); + if (images.length === 0 && asset.scales.length > 0) { + const maxCatalogScale = CATALOG_SCALES[CATALOG_SCALES.length - 1]; + let idx = asset.scales.findIndex(scale => scale > maxCatalogScale); + if (idx === -1) { + idx = asset.scales.length - 1; + } + const scale = Math.min( + maxCatalogScale, + Math.max(1, Math.ceil(asset.scales[idx])), + ); + console.warn( + `warning: Asset "${asset.name}" has no 1x/2x/3x variant; ` + + `using its @${asset.scales[idx]}x file as the ${scale}x catalog rendition.`, + ); + images.push({scale, src: asset.files[idx]}); + } + return images; +} + +export function getImageSet(catalogDir: string, asset: AssetData): ImageSet { const fileName = getAndroidResourceIdentifier(asset); return { basePath: path.join(catalogDir, `${fileName}.imageset`), - files: scales.map((scale, idx) => { + files: getCatalogImages(asset).map(({scale, src}) => { const suffix = scale === 1 ? '' : `@${scale}x`; return { name: `${fileName + suffix}.${asset.type}`, scale, - src: asset.files[idx], + src, }; }), }; diff --git a/packages/community-cli-plugin/src/commands/bundle/saveAssets.js b/packages/community-cli-plugin/src/commands/bundle/saveAssets.js index 666b7671e974..c8487aef66c8 100644 --- a/packages/community-cli-plugin/src/commands/bundle/saveAssets.js +++ b/packages/community-cli-plugin/src/commands/bundle/saveAssets.js @@ -74,12 +74,7 @@ async function saveAssets( cleanAssetCatalog(catalogDir); for (const asset of assets) { if (isCatalogAsset(asset)) { - const imageSet = getImageSet( - catalogDir, - asset, - filterPlatformAssetScales(platform, asset.scales), - ); - writeImageSet(imageSet); + writeImageSet(getImageSet(catalogDir, asset)); } else { addAssetToCopy(asset); }