From aae6dcc618e12d96b40f9459b84e3e55a6b4b77b Mon Sep 17 00:00:00 2001 From: ahmdshrif Date: Sun, 30 Aug 2026 09:16:37 +0300 Subject: [PATCH] fix(link-assets): match asset extensions case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `link-assets` dispatches every asset to a per-extension bucket by comparing `path.extname()` against the lower-case type lists in `fileTypes.ts`. The comparison is case-sensitive, so an asset whose extension is cased differently on disk — `Lato-Regular.TTF`, `Photo.PNG` — never matches its bucket and falls through to the "custom" one instead. The result is a silent mislink: - on Android the font is copied to `app/src/main/assets/custom/` rather than `app/src/main/res/font/`, no `res/font/.xml` is generated and no `ReactFontManager.addCustomFont()` call is added to `MainApplication`, and an image lands there instead of `res/drawable/`; - on iOS the file is added to the Xcode `Resources` group but `isFontAsset` is false, so it is never appended to `UIAppFonts` and the font ships inside the bundle without being loadable at runtime. Normalize the extension once, in `getAssetExtension()`, and use it both where `linkPlatform` builds its filters and in `migration2`, which flags previously linked fonts for relinking with the same comparison. Extensions that were already lower-case are unaffected: all 32 existing snapshots pass unchanged. --- .../src/__tests__/linkAssets.test.ts | 55 +++++++++++++++++++ packages/cli-link-assets/src/fileTypes.ts | 15 ++++- .../src/tools/linkPlatform/index.ts | 5 +- .../tools/manifest/migrations/migration2.ts | 5 +- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/packages/cli-link-assets/src/__tests__/linkAssets.test.ts b/packages/cli-link-assets/src/__tests__/linkAssets.test.ts index a6b6642fa..c1bbcf9d8 100644 --- a/packages/cli-link-assets/src/__tests__/linkAssets.test.ts +++ b/packages/cli-link-assets/src/__tests__/linkAssets.test.ts @@ -585,4 +585,59 @@ describe('linkAssets', () => { ), ).toMatchSnapshot(); }); + it('should link a font asset whose extension is upper-cased', async () => { + writeFiles(DIR, { + ...baseProjectKotlin, + 'assets/shared/fonts/Montserrat-Regular.TTF': + fixtureFiles.montserratRegularFont, + }); + + await linkAssets([], configMock as CLIConfig); + + // Android: it is linked as a font resource, not as a custom asset. + expect(readMontserratXMLFontFile()).toContain('@font/montserrat_regular'); + expect( + fs.existsSync( + path.resolve( + DIR, + 'android/app/src/main/res/font/montserrat_regular.ttf', + ), + ), + ).toBe(true); + expect( + fs.existsSync( + path.resolve( + DIR, + 'android/app/src/main/assets/custom/Montserrat-Regular.TTF', + ), + ), + ).toBe(false); + expect(readMainApplicationKotlinFile()).toContain( + 'addCustomFont(this, "Montserrat", R.font.montserrat)', + ); + + // iOS: it is registered in `UIAppFonts`, otherwise the font ships in the + // bundle but cannot be used at runtime. + expect(readInfoPlistFile()).toContain('Montserrat-Regular.TTF'); + }); + + it('should link an image asset whose extension is upper-cased', async () => { + writeFiles(DIR, { + ...baseProjectKotlin, + 'assets/shared/Upper Image.PNG': fixtureFiles.imagePng, + }); + + await linkAssets([], configMock as CLIConfig); + + expect( + fs.existsSync( + path.resolve(DIR, 'android/app/src/main/res/drawable/upper_image.png'), + ), + ).toBe(true); + expect( + fs.existsSync( + path.resolve(DIR, 'android/app/src/main/assets/custom/Upper Image.PNG'), + ), + ).toBe(false); + }); }); diff --git a/packages/cli-link-assets/src/fileTypes.ts b/packages/cli-link-assets/src/fileTypes.ts index ce2ccee8c..c27e0257d 100644 --- a/packages/cli-link-assets/src/fileTypes.ts +++ b/packages/cli-link-assets/src/fileTypes.ts @@ -1,7 +1,20 @@ +import path from 'path'; + const fontTypes = ['otf', 'ttf'] as const; const imageTypes = ['png', 'jpg', 'gif'] as const; const audioTypes = ['mp3'] as const; -export {fontTypes, imageTypes, audioTypes}; +/** + * Returns the extension of an asset, without the leading dot and lower-cased. + * + * The known asset types above are all spelled in lower case, while the + * extension on disk can be cased in any way (`Lato-Regular.TTF`), so it has to + * be normalized before it is matched against them. + */ +function getAssetExtension(filePath: string) { + return path.extname(filePath).substring(1).toLowerCase(); +} + +export {fontTypes, imageTypes, audioTypes, getAssetExtension}; diff --git a/packages/cli-link-assets/src/tools/linkPlatform/index.ts b/packages/cli-link-assets/src/tools/linkPlatform/index.ts index 3479bb06b..f30b8bb30 100644 --- a/packages/cli-link-assets/src/tools/linkPlatform/index.ts +++ b/packages/cli-link-assets/src/tools/linkPlatform/index.ts @@ -1,6 +1,7 @@ import {CLIError, logger} from '@react-native-community/cli-tools'; import fs from 'fs'; import path from 'path'; +import {getAssetExtension} from '../../fileTypes'; import sha1File from '../../sha1File'; import {CleanAssets} from '../cleanAssets/types'; import {CopyAssets} from '../copyAssets/types'; @@ -173,7 +174,7 @@ function linkPlatform({ .map((fileExt): FileFilter => { return { name: fileExt, - filter: (asset) => path.extname(asset.path) === `.${fileExt}`, + filter: (asset) => getAssetExtension(asset.path) === fileExt, options: linkOptionsPerExt[fileExt as Extension], }; }) @@ -181,7 +182,7 @@ function linkPlatform({ name: 'custom', filter: (asset) => Object.keys(linkOptionsPerExt).indexOf( - path.extname(asset.path).substring(1), + getAssetExtension(asset.path), ) === -1, options: otherLinkOptions, }); diff --git a/packages/cli-link-assets/src/tools/manifest/migrations/migration2.ts b/packages/cli-link-assets/src/tools/manifest/migrations/migration2.ts index 8a7f65f3f..de8a4350d 100644 --- a/packages/cli-link-assets/src/tools/manifest/migrations/migration2.ts +++ b/packages/cli-link-assets/src/tools/manifest/migrations/migration2.ts @@ -1,6 +1,5 @@ -import path from 'path'; import {AssetPathAndSHA1} from '..'; -import {fontTypes} from '../../../fileTypes'; +import {fontTypes, getAssetExtension} from '../../../fileTypes'; import {Platform} from '../../linkPlatform'; function migration2( @@ -12,7 +11,7 @@ function migration2( shouldRelinkAndroidFonts: platform === 'android' && fontTypes.includes( - path.extname(asset.path).substring(1) as (typeof fontTypes)[number], + getAssetExtension(asset.path) as (typeof fontTypes)[number], ), })); }