Create @react-native/asset-utils package (#57368)

Summary:
Pull Request resolved: https://github.com/react/react-native/pull/57368

**Context**

This stack intends to relocate the `AssetRegistry` API from `react-native/assets-registry` into `react-native` — addressing runtime safety and public deep imports issues (1.0 and JS Stable API blockers).

**This diff**

Create a new `react-native/asset-utils` package, intended for internal/framework use (read: **will not be an end user concern**). This re-houses `react-native/assets-registry/path-support.js`.

Stacked with the next two diffs, this contributes to the 0.87 objective to delete `react-native/assets-registry` towards install-layout-safe behaviour and JS deep import removal.

- (Temporarily, this leaves two copies of `path-support.js` until I enact the package removal down the stack.)

This change also enables us to de-duplicate `assetPathUtils.js` inside `community-cli-plugin` and use one source of truth.

- Additionally, we have fbsource consumers of this util that cannot have a circular Buck dep on `react-native`, so the main package was not a suitable relocation point (secondarily to not adding an awkward new public API here).

**Changes**
- Add `react-native/asset-utils` (`packages/asset-utils/`): `src/AssetPathUtils.js` containing Android path helpers and tests.
- De-duplicate usages in `community-cli-plugin`: delete `src/commands/bundle/assetPathUtils.js`.

Changelog:
[General][Added] - Introduce `react-native/asset-utils` package (relocates Android path utils for libraries/frameworks)

Reviewed By: robhogan

Differential Revision: D110045272

fbshipit-source-id: 781e75c58ae9ce7f0fa860e66beca3b9990d26ea
This commit is contained in:
Alex Hunt
2026-07-06 10:32:14 -07:00
committed by meta-codesync[bot]
parent a2cd37f828
commit 41d52189e2
20 changed files with 300 additions and 116 deletions
+23
View File
@@ -0,0 +1,23 @@
# @react-native/asset-utils
[![npm]](https://www.npmjs.com/package/@react-native/asset-utils) [![npm downloads]](https://www.npmjs.com/package/@react-native/asset-utils)
[npm]: https://img.shields.io/npm/v/@react-native/asset-utils.svg?color=blue
[npm downloads]: https://img.shields.io/npm/dm/@react-native/asset-utils.svg
Android resource-path helpers used when copying React Native assets into `drawable-*` / `raw` folders. Consumed by bundling and build tooling; most apps never import this directly.
## API
```js
import {
getAndroidResourceFolderName,
getAndroidResourceIdentifier,
} from '@react-native/asset-utils';
```
| Export | Signature | Notes |
|---|---|---|
| `getAndroidResourceFolderName` | `(asset: PackagerAsset, scale: number) => string` | e.g. `drawable-xhdpi`; non-drawable types resolve to `raw` |
| `getAndroidResourceIdentifier` | `(asset: PackagerAsset) => string` | Sanitised resource name |
| `drawableFileTypes` | `Set<string>` | Asset types that map to a `drawable-*` folder |
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@react-native/asset-utils",
"version": "0.87.0-main",
"description": "Asset path utilities for React Native.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/react/react-native.git",
"directory": "packages/asset-utils"
},
"homepage": "https://github.com/react/react-native/tree/HEAD/packages/asset-utils#readme",
"keywords": [
"react-native"
],
"bugs": "https://github.com/react/react-native/issues",
"engines": {
"node": "^22.13.0 || ^24.3.0 || >= 26.0.0"
},
"exports": {
".": "./src/index.js",
"./package.json": "./package.json"
},
"files": [
"src",
"README.md",
"!**/__docs__/**",
"!**/__fixtures__/**",
"!**/__mocks__/**",
"!**/__tests__/**"
]
}
+23
View File
@@ -0,0 +1,23 @@
/**
* 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.
*
* @format
*/
export type PackagerAsset = Readonly<{
httpServerLocation: string;
name: string;
type: string;
}>;
export function getAndroidResourceFolderName(
asset: PackagerAsset,
scale: number,
): string;
export function getAndroidResourceIdentifier(asset: PackagerAsset): string;
export const drawableFileTypes: Set<string>;
@@ -0,0 +1,93 @@
/**
* 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
* @format
*/
'use strict';
/*::
// Conforms to the `PackagerAsset` type from `react-native`.
export type PackagerAsset = Readonly<{
httpServerLocation: string,
name: string,
type: string,
...
}>;
*/
const androidScaleSuffix /*: {[string]: string} */ = {
'0.75': 'ldpi',
'1': 'mdpi',
'1.5': 'hdpi',
'2': 'xhdpi',
'3': 'xxhdpi',
'4': 'xxxhdpi',
};
const ANDROID_BASE_DENSITY = 160;
// FIXME: Using number to represent discrete scale numbers is fragile in
// essence because of floating point number imprecision.
function getAndroidAssetSuffix(scale /*: number */) /*: string */ {
if (scale.toString() in androidScaleSuffix) {
return androidScaleSuffix[scale.toString()];
}
// NOTE: Android Gradle Plugin does not fully support the nnndpi format.
// See https://issuetracker.google.com/issues/72884435
if (Number.isFinite(scale) && scale > 0) {
return Math.round(scale * ANDROID_BASE_DENSITY) + 'dpi';
}
throw new Error('no such scale ' + scale.toString());
}
// See https://developer.android.com/guide/topics/resources/drawable-resource.html
const drawableFileTypes /*: Set<string> */ = new Set([
'gif',
'heic',
'heif',
'jpeg',
'jpg',
'ktx',
'png',
'webp',
'xml',
]);
function getAndroidResourceFolderName(
asset /*: PackagerAsset */,
scale /*: number */,
) /*: string */ {
if (!drawableFileTypes.has(asset.type)) {
return 'raw';
}
return 'drawable-' + getAndroidAssetSuffix(scale);
}
function getAndroidResourceIdentifier(
asset /*: PackagerAsset */,
) /*: string */ {
return (getBasePath(asset) + '/' + asset.name)
.toLowerCase()
.replace(/\//g, '_') // Encode folder structure in file name
.replace(/([^a-z0-9_])/g, '') // Remove illegal chars
.replace(/^(?:assets|assetsunstable_path)_/, ''); // Remove "assets_" or "assetsunstable_path_" prefix
}
function getBasePath(asset /*: PackagerAsset */) /*: string */ {
const basePath = asset.httpServerLocation;
return basePath.startsWith('/') ? basePath.slice(1) : basePath;
}
module.exports = {
drawableFileTypes,
getAndroidResourceFolderName,
getAndroidResourceIdentifier,
};
@@ -0,0 +1,72 @@
/**
* 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 {getAndroidResourceFolderName} from '../AndroidPathUtils';
const DRAWABLE_ASSET = {
httpServerLocation: '/assets/',
name: 'foo',
type: 'png',
};
const NON_DRAWABLE_ASSET = {
httpServerLocation: '/assets/',
name: 'foo',
type: 'txt',
};
describe('getAndroidResourceFolderName', () => {
test('supports the six primary density buckets', () => {
expect(getAndroidResourceFolderName(DRAWABLE_ASSET, 0.75)).toBe(
'drawable-ldpi',
);
expect(getAndroidResourceFolderName(DRAWABLE_ASSET, 1)).toBe(
'drawable-mdpi',
);
expect(getAndroidResourceFolderName(DRAWABLE_ASSET, 1.5)).toBe(
'drawable-hdpi',
);
expect(getAndroidResourceFolderName(DRAWABLE_ASSET, 2)).toBe(
'drawable-xhdpi',
);
expect(getAndroidResourceFolderName(DRAWABLE_ASSET, 3)).toBe(
'drawable-xxhdpi',
);
expect(getAndroidResourceFolderName(DRAWABLE_ASSET, 4)).toBe(
'drawable-xxxhdpi',
);
});
test('supports nonstandard densities', () => {
expect(getAndroidResourceFolderName(DRAWABLE_ASSET, 1.25)).toBe(
'drawable-200dpi',
);
expect(getAndroidResourceFolderName(DRAWABLE_ASSET, 1.66)).toBe(
'drawable-266dpi',
);
expect(getAndroidResourceFolderName(DRAWABLE_ASSET, 1.33)).toBe(
'drawable-213dpi',
); // ~tvdpi
});
test('throws if the density cannot be processed', () => {
expect(() => getAndroidResourceFolderName(DRAWABLE_ASSET, -1)).toThrow();
expect(() => getAndroidResourceFolderName(DRAWABLE_ASSET, 0)).toThrow();
expect(() =>
getAndroidResourceFolderName(DRAWABLE_ASSET, Infinity),
).toThrow();
});
test('returns "raw" for non-drawables', () => {
expect(getAndroidResourceFolderName(NON_DRAWABLE_ASSET, 0.75)).toBe('raw');
expect(getAndroidResourceFolderName(NON_DRAWABLE_ASSET, 1)).toBe('raw');
expect(getAndroidResourceFolderName(NON_DRAWABLE_ASSET, 1.25)).toBe('raw');
});
});
+10
View File
@@ -0,0 +1,10 @@
/**
* 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.
*
* @format
*/
export * from './AndroidPathUtils';
+17
View File
@@ -0,0 +1,17 @@
/**
* 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
* @format
*/
'use strict';
/*::
export type {PackagerAsset} from './AndroidPathUtils';
*/
module.exports = require('./AndroidPathUtils');
@@ -31,6 +31,7 @@
"prepack": "node ../../scripts/build/prepack.js"
},
"dependencies": {
"@react-native/asset-utils": "0.87.0-main",
"@react-native/dev-middleware": "0.87.0-main",
"debug": "^4.4.0",
"invariant": "^2.2.4",
@@ -10,7 +10,7 @@
import filterPlatformAssetScales from '../filterPlatformAssetScales';
jest.dontMock('../filterPlatformAssetScales').dontMock('../assetPathUtils');
jest.dontMock('../filterPlatformAssetScales');
describe('filterPlatformAssetScales', () => {
test('removes everything but 2x and 3x for iOS', () => {
@@ -12,7 +12,9 @@ import getAssetDestPathAndroid from '../getAssetDestPathAndroid';
const path = require('path');
jest.dontMock('../getAssetDestPathAndroid').dontMock('../assetPathUtils');
jest
.dontMock('../getAssetDestPathAndroid')
.dontMock('@react-native/asset-utils');
describe('getAssetDestPathAndroid', () => {
test('should use the right destination folder', () => {
@@ -10,7 +10,7 @@
import type {AssetData} from 'metro';
import assetPathUtils from './assetPathUtils';
import {getAndroidResourceIdentifier} from '@react-native/asset-utils';
import fs from 'fs';
import path from 'path';
@@ -33,7 +33,7 @@ export function getImageSet(
asset: AssetData,
scales: ReadonlyArray<number>,
): ImageSet {
const fileName = assetPathUtils.getResourceIdentifier(asset);
const fileName = getAndroidResourceIdentifier(asset);
return {
basePath: path.join(catalogDir, `${fileName}.imageset`),
files: scales.map((scale, idx) => {
@@ -1,95 +0,0 @@
/**
* 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
*/
export type PackagerAsset = Readonly<{
httpServerLocation: string,
name: string,
type: string,
...
}>;
/**
* FIXME: using number to represent discrete scale numbers is fragile in essence because of
* floating point numbers imprecision.
*/
function getAndroidAssetSuffix(scale: number): string {
switch (scale) {
case 0.75:
return 'ldpi';
case 1:
return 'mdpi';
case 1.5:
return 'hdpi';
case 2:
return 'xhdpi';
case 3:
return 'xxhdpi';
case 4:
return 'xxxhdpi';
default:
return '';
}
}
// See https://developer.android.com/guide/topics/resources/drawable-resource.html
const drawableFileTypes: Set<string> = new Set<string>([
'gif',
'heic',
'heif',
'jpeg',
'jpg',
'png',
'webp',
'xml',
]);
function getAndroidResourceFolderName(
asset: PackagerAsset,
scale: number,
): string {
if (!drawableFileTypes.has(asset.type)) {
return 'raw';
}
const suffix = getAndroidAssetSuffix(scale);
if (!suffix) {
throw new Error(
`Don't know which android drawable suffix to use for asset: ${JSON.stringify(
asset,
)}`,
);
}
const androidFolder = `drawable-${suffix}`;
return androidFolder;
}
function getResourceIdentifier(asset: PackagerAsset): string {
const folderPath = getBasePath(asset);
return `${folderPath}/${asset.name}`
.toLowerCase()
.replace(/\//g, '_') // Encode folder structure in file name
.replace(/([^a-z0-9_])/g, '') // Remove illegal chars
.replace(/^(?:assets|assetsunstable_path)_/, ''); // Remove "assets_" or "assetsunstable_path_" prefix
}
function getBasePath(asset: PackagerAsset): string {
let basePath = asset.httpServerLocation;
if (basePath[0] === '/') {
basePath = basePath.substr(1);
}
return basePath;
}
export default {
drawableFileTypes,
getAndroidAssetSuffix,
getAndroidResourceFolderName,
getResourceIdentifier,
getBasePath,
};
@@ -10,7 +10,10 @@
import type {AssetData} from 'metro';
import assetPathUtils from './assetPathUtils';
import {
drawableFileTypes,
getAndroidResourceIdentifier,
} from '@react-native/asset-utils';
import fs from 'fs';
import path from 'path';
@@ -23,12 +26,8 @@ async function createKeepFileAsync(
}
const assetsList = [];
for (const asset of assets) {
const prefix = assetPathUtils.drawableFileTypes.has(asset.type)
? 'drawable'
: 'raw';
assetsList.push(
`@${prefix}/${assetPathUtils.getResourceIdentifier(asset)}`,
);
const prefix = drawableFileTypes.has(asset.type) ? 'drawable' : 'raw';
assetsList.push(`@${prefix}/${getAndroidResourceIdentifier(asset)}`);
}
const keepPath = path.join(outputDirectory, 'raw/keep.xml');
const content = `<resources xmlns:tools="http://schemas.android.com/tools" tools:keep="${assetsList.join(',')}" />\n`;
@@ -8,17 +8,17 @@
* @format
*/
import type {PackagerAsset} from './assetPathUtils';
import type {PackagerAsset} from '@react-native/asset-utils';
import assetPathUtils from './assetPathUtils';
import {
getAndroidResourceFolderName,
getAndroidResourceIdentifier,
} from '@react-native/asset-utils';
import path from 'path';
function getAssetDestPathAndroid(asset: PackagerAsset, scale: number): string {
const androidFolder = assetPathUtils.getAndroidResourceFolderName(
asset,
scale,
);
const fileName = assetPathUtils.getResourceIdentifier(asset);
const androidFolder = getAndroidResourceFolderName(asset, scale);
const fileName = getAndroidResourceIdentifier(asset);
return path.join(androidFolder, `${fileName}.${asset.type}`);
}
@@ -8,7 +8,7 @@
* @format
*/
import type {PackagerAsset} from './assetPathUtils';
import type {PackagerAsset} from '@react-native/asset-utils';
import path from 'path';
@@ -42,8 +42,7 @@ const {pickScale} = require('./AssetUtils');
const {
getAndroidResourceFolderName,
getAndroidResourceIdentifier,
getBasePath,
} = require('@react-native/assets-registry/path-support');
} = require('@react-native/asset-utils');
const invariant = require('invariant');
/**
@@ -66,6 +65,11 @@ function getAssetPathInDrawableFolder(asset: PackagerAsset): string {
return drawableFolder + '/' + fileName + '.' + asset.type;
}
function getBasePath(asset: PackagerAsset): string {
const basePath = asset.httpServerLocation;
return basePath.startsWith('/') ? basePath.slice(1) : basePath;
}
/**
* Returns true if the asset can be loaded over the network.
*
+1
View File
@@ -26,6 +26,7 @@ const config = {
// Make Metro able to resolve required packages that might be imported from /packages/react-native
watchFolders: [
path.resolve(__dirname, '../../node_modules'),
path.resolve(__dirname, '../asset-utils'),
path.resolve(__dirname, '../assets-registry'),
path.resolve(__dirname, '../normalize-color'),
path.resolve(__dirname, '../polyfills'),
+1
View File
@@ -145,6 +145,7 @@
}
},
"dependencies": {
"@react-native/asset-utils": "0.87.0-main",
"@react-native/assets-registry": "0.87.0-main",
"@react-native/codegen": "0.87.0-main",
"@react-native/community-cli-plugin": "0.87.0-main",
+1
View File
@@ -24,6 +24,7 @@ const config = {
// Make Metro able to resolve required external dependencies
watchFolders: [
path.resolve(__dirname, '../../node_modules'),
path.resolve(__dirname, '../asset-utils'),
path.resolve(__dirname, '../assets-registry'),
path.resolve(__dirname, '../community-cli-plugin'),
path.resolve(__dirname, '../dev-middleware'),
+1
View File
@@ -29,6 +29,7 @@ const config = {
// Make Metro able to resolve required external dependencies
watchFolders: [
repositoryPath('node_modules'),
repositoryPath('packages/asset-utils'),
repositoryPath('packages/assets-registry'),
repositoryPath('packages/normalize-color'),
repositoryPath('packages/polyfills'),