Move AssetRegistry implementation into main package, expose as public API (#57369)

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

**Problem**

The separate `react-native/assets-registry` package includes a longstanding ecosystem footgun.

`registry.js` holds asset state in a module-scoped variable, which makes the package a stateful singleton: exactly one instance must exist per JS runtime, or registration and lookup diverge.

We provide no guarantee that this singleton requirement holds:

- The install layout — how the package manager dedupes packages in `node_modules` — decides how many copies exist, and `react-native`'s exact-version pin means third-party ranges never dedupe against it.

Effects:

- **Consumers silently break**: `expo-asset` and `expo-image` can land on a second copy: assets register in one, resolve as `undefined` from the other. Expo neutralizes this with a shim in `expo/cli` that redirects every registry import to a single virtual module — bare React Native + Metro has no such protection.
- **This blocks 1.0**: The ecosystem can't move from exact-version lockstep to semver ranges until stateful packages like the asset registry are safe to duplicate. Today, relaxing the pin would turn a latent footgun into a common one.

**To solve this**, move towards (but not quite yet) deleting `react-native/assets-registry`, in favour of a replacement `AssetRegistry` API offered directly by `react-native`.

**Key changes**

NOTE: **Reviewer note**: Browsing file changes on GitHub may be more focused — https://github.com/react/react-native/pull/57369/changes

NOTE: Squash of https://github.com/react/react-native/pull/57233 (D108750302) and https://github.com/react/react-native/pull/57232 (D108750303)

`'react-native'`:
- Add new `AssetRegistry` API, along with the `PackagerAsset` and `AssetDestPathResolver` root type exports in `react-native`.
- Add a new `'react-native/asset-registry'` secondary entry point — intended for Metro's `transformer.assetRegistryPath` config contract.

`react-native/assets-registry`:
- Update to source from this relocated implementation — fixing the duplicate install layout bug (where apps/frameworks enforce a single copy of `react-native`).

**Impact**

- **✅ Fixed**: Imports from either `react-native` or `react-native/assets-registry` in RN 0.87+ will be durable to duplicate package installs — Expo can remove their virtual module shim.
- **✅ Fixed**: Deep import `'react-native/Libraries/Image/AssetRegistry'` dependency removed (migrated in `react-native/metro-config`).

Changelog:
- [General][Fixed] - **assets-registry**: `react-native/assets-registry` now shares state across duplicate installs, sourcing from a relocated implementation in the `react-native` package
- [General][Added] - Add `AssetRegistry` API (replaces `react-native/assets-registry/registry`)
- [General][Breaking] - `react-native/Libraries/Image/AssetRegistry` is removed. Please use the `AssetRegistry` API (apps/library code) and/or the `react-native/asset-registry` entrypoint (Metro/build configs).

Reviewed By: robhogan

Differential Revision: D109019622

fbshipit-source-id: 75599a94a9aba084a1266f2128c448379d1596cd
This commit is contained in:
Alex Hunt
2026-07-06 10:32:14 -07:00
committed by meta-codesync[bot]
parent 41d52189e2
commit 6cfde8f296
22 changed files with 165 additions and 86 deletions
-1
View File
@@ -50,7 +50,6 @@ module.exports = {
files: [
'./packages/react-native/Libraries/**/*.{js,flow}',
'./packages/react-native/src/**/*.{js,flow}',
'./packages/assets-registry/registry.js',
],
parser: 'hermes-eslint',
rules: {
+4 -4
View File
@@ -1,9 +1,6 @@
# @react-native/assets-registry
[![npm]](https://www.npmjs.com/package/@react-native/assets-registry) [![npm downloads]](https://www.npmjs.com/package/@react-native/assets-registry)
[npm]: https://img.shields.io/npm/v/@react-native/assets-registry.svg?color=blue
[npm downloads]: https://img.shields.io/npm/dm/@react-native/assets-registry.svg
![npm package](https://img.shields.io/npm/v/@react-native/assets-registry?color=brightgreen&label=npm%20package)
Runtime registry that maps asset IDs generated in a Metro bundle to asset metadata. It backs `<Image>`, `Image.resolveAssetSource()`, and any code that resolves `require('./img.png')` on native.
@@ -13,6 +10,9 @@ Most apps never import this directly — assets are handled through `<Image>`.
### `@react-native/assets-registry/registry`
> [!Note]
> Aliases to [`AssetRegistry`](https://reactnative.dev/docs/assetregistry) (since 0.87). Prefer importing directly from the `'react-native'` package in libraries.
| Export | Signature | Notes |
|---|---|---|
| `registerAsset` | `(asset: PackagerAsset) => number` | Stores the asset; returns a numeric ID |
+4 -1
View File
@@ -26,5 +26,8 @@
"!**/__fixtures__/**",
"!**/__mocks__/**",
"!**/__tests__/**"
]
],
"peerDependencies": {
"react-native": "*"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
* @flow strict-local
* @format
*/
+8 -29
View File
@@ -4,41 +4,20 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict
* @flow strict-local
* @format
*/
'use strict';
/*::
export type AssetDestPathResolver = 'android' | 'generic';
import {AssetRegistry} from 'react-native';
export type PackagerAsset = {
readonly __packager_asset: boolean,
readonly fileSystemLocation: string,
readonly httpServerLocation: string,
readonly width: ?number,
readonly height: ?number,
readonly scales: Array<number>,
readonly hash: string,
readonly name: string,
readonly type: string,
readonly resolver?: AssetDestPathResolver,
...
};
/*::
export type {AssetDestPathResolver, PackagerAsset} from 'react-native';
*/
const assets /*: Array<PackagerAsset> */ = [];
function registerAsset(asset /*: PackagerAsset */) /*: number */ {
// `push` returns new array length, so the first asset will
// get id 1 (not 0) to make the value truthy
return assets.push(asset);
}
function getAssetByID(assetId /*: number */) /*: PackagerAsset */ {
return assets[assetId - 1];
}
// eslint-disable-next-line @react-native/monorepo/no-commonjs-exports
module.exports = {registerAsset, getAssetByID};
module.exports = {
registerAsset: AssetRegistry.registerAsset,
getAssetByID: AssetRegistry.getAssetByID,
};
@@ -32,6 +32,7 @@ module.exports = {
if (
!isDeepReactNativeImport(node.source) ||
isInitializeCoreImport(node.source) ||
isSecondaryEntryPoint(node.source) ||
isFbInternalImport(node.source)
) {
return;
@@ -88,6 +89,7 @@ module.exports = {
if (
!isDeepRequire(node) ||
isInitializeCoreImport(node.arguments[0]) ||
isSecondaryEntryPoint(node.arguments[0]) ||
isFbInternalImport(node.arguments[0])
) {
return;
@@ -173,6 +175,14 @@ module.exports = {
return source.value === 'react-native/Libraries/Core/InitializeCore';
}
function isSecondaryEntryPoint(source) {
if (source.type !== 'Literal' || typeof source.value !== 'string') {
return false;
}
return source.value === 'react-native/asset-registry';
}
function isFbInternalImport(source) {
if (source.type !== 'Literal' || typeof source.value !== 'string') {
return false;
+1 -1
View File
@@ -84,7 +84,7 @@ export function getDefaultConfig(projectRoot: string): ConfigT {
},
transformer: {
allowOptionalDependencies: true,
assetRegistryPath: 'react-native/Libraries/Image/AssetRegistry',
assetRegistryPath: 'react-native/asset-registry',
asyncRequireModulePath: require.resolve(
'metro-runtime/src/modules/asyncRequire',
),
-14
View File
@@ -1,14 +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
* @format
*/
export {
registerAsset,
getAssetByID,
} from '@react-native/assets-registry/registry';
+12 -25
View File
@@ -10,31 +10,10 @@
'use strict';
export type ResolvedAssetSource = {
readonly __packager_asset: boolean,
readonly width: ?number,
readonly height: ?number,
readonly uri: string,
readonly scale: number,
};
// From @react-native/assets-registry
type AssetDestPathResolver = 'android' | 'generic';
// From @react-native/assets-registry
type PackagerAsset = Readonly<{
__packager_asset: boolean,
fileSystemLocation: string,
httpServerLocation: string,
width: ?number,
height: ?number,
scales: Array<number>,
hash: string,
name: string,
type: string,
resolver?: AssetDestPathResolver,
...
}>;
import type {
AssetDestPathResolver,
PackagerAsset,
} from '../../src/private/assets/AssetRegistry';
const PixelRatio = require('../Utilities/PixelRatio').default;
const Platform = require('../Utilities/Platform').default;
@@ -45,6 +24,14 @@ const {
} = require('@react-native/asset-utils');
const invariant = require('invariant');
export type ResolvedAssetSource = {
readonly __packager_asset: boolean,
readonly width: ?number,
readonly height: ?number,
readonly uri: string,
readonly scale: number,
};
/**
* Returns a path like 'assets/AwesomeModule/icon@2x.png'
*/
+1 -1
View File
@@ -13,7 +13,7 @@
// This is a stub for flow to make it understand require('./icon.png')
// See metro/src/Bundler/index.js
const AssetRegistry = require('@react-native/assets-registry/registry');
const {AssetRegistry} = require('../../src/private/assets/AssetRegistry');
const RelativeImageStub = AssetRegistry.registerAsset({
__packager_asset: true,
@@ -8,7 +8,7 @@
* @format
*/
import type {PackagerAsset} from '../../../../assets-registry/registry';
import type {PackagerAsset} from '../../../src/private/assets/AssetRegistry';
import type {ResolvedAssetSource} from '../AssetSourceResolver';
describe('resolveAssetSource', () => {
@@ -20,7 +20,8 @@ describe('resolveAssetSource', () => {
beforeEach(() => {
jest.resetModules();
AssetRegistry = require('@react-native/assets-registry/registry');
AssetRegistry =
require('../../../src/private/assets/AssetRegistry').AssetRegistry;
resolveAssetSource = require('../resolveAssetSource').default;
NativeSourceCode =
require('../../NativeModules/specs/NativeSourceCode').default;
@@ -16,10 +16,10 @@ import type {ImageSource} from './ImageSource';
import SourceCode from '../NativeModules/specs/NativeSourceCode';
const {AssetRegistry} = require('../../src/private/assets/AssetRegistry');
const AssetSourceResolver: AssetSourceResolverT =
require('./AssetSourceResolver').default;
const {pickScale} = require('./AssetUtils');
const AssetRegistry = require('@react-native/assets-registry/registry');
type CustomSourceTransformer = (
resolver: AssetSourceResolver,
+21 -1
View File
@@ -4,7 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @generated SignedSource<<82e308c1a4f2f713285c7586ff3eb618>>
* @generated SignedSource<<637f1d0b012ebca68c9e020a52eb2803>>
*
* This file was generated by scripts/js-api/build-types/index.js.
*/
@@ -151,6 +151,10 @@ declare const AnimatedScrollView_default: AnimatedComponentType<
>
declare const AppState: typeof AppState_default
declare const AppState_default: AppStateImpl
declare const AssetRegistry: {
getAssetByID(assetId: number): PackagerAsset
registerAsset(asset: PackagerAsset): number
}
declare const attachNativeEvent: typeof $$AnimatedImplementation.attachNativeEvent
declare const BackHandler: typeof BackHandler_default
declare const BackHandler_default: TBackHandler
@@ -1645,6 +1649,8 @@ declare interface ArrayLike_2<T> extends Iterable<T> {
[indexer: number]: T
readonly length: number
}
declare type AssetDestPathResolver = "android" | "generic"
declare type AssetRegistry = typeof AssetRegistry
declare type attachNativeEvent = typeof attachNativeEvent
declare function attachNativeEventImpl(
viewRef: any,
@@ -3485,6 +3491,17 @@ declare type OptionalVirtualizedSectionListProps<
declare type OrientationChangeEvent = {
readonly orientation: "landscape" | "portrait"
}
declare type PackagerAsset = {
readonly fileSystemLocation: string
readonly hash: string
readonly height: number | undefined
readonly httpServerLocation: string
readonly name: string
readonly resolver?: AssetDestPathResolver
readonly scales: Array<number>
readonly type: string
readonly width: number | undefined
}
declare type PanResponder = typeof PanResponder
declare type PanResponderCallbacks = {
readonly onMoveShouldSetPanResponder?: ActiveCallback
@@ -5911,6 +5928,8 @@ export {
AppStateEvent, // 80f034c3
AppStateStatus, // 447e5ef2
Appearance, // 83e9641a
AssetDestPathResolver, // 59047424
AssetRegistry, // 6070bb45
AutoCapitalize, // c0e857a0
BackHandler, // f139fc69
BackPressEventName, // 4620fb76
@@ -6044,6 +6063,7 @@ export {
NativeUIEvent, // 44ac26ac
Networking, // bbc5be42
OpaqueColorValue, // 25f3fa5b
PackagerAsset, // d1c88cf4
PanResponder, // f8f71cac
PanResponderCallbacks, // 6d63e7be
PanResponderGestureState, // 54baf558
+3
View File
@@ -233,6 +233,9 @@ module.exports = {
get AppState() {
return require('./Libraries/AppState/AppState').default;
},
get AssetRegistry() {
return require('./src/private/assets/AssetRegistry').AssetRegistry;
},
get BackHandler() {
return require('./Libraries/Utilities/BackHandler').default;
},
+6
View File
@@ -279,6 +279,12 @@ export type {
} from './Libraries/AppState/AppState';
export {default as AppState} from './Libraries/AppState/AppState';
export {AssetRegistry} from './src/private/assets/AssetRegistry';
export type {
AssetDestPathResolver,
PackagerAsset,
} from './src/private/assets/AssetRegistry';
export type {BackPressEventName} from './Libraries/Utilities/BackHandler';
export {default as BackHandler} from './Libraries/Utilities/BackHandler';
-1
View File
@@ -27,7 +27,6 @@ const config = {
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'),
path.resolve(__dirname, '../virtualized-lists'),
+4 -1
View File
@@ -48,6 +48,10 @@
"types": null,
"default": "./src/*.js"
},
"./asset-registry": {
"types": null,
"default": "./src/asset-registry.js"
},
"./jest-preset": "./jest-preset.js",
"./rn-get-polyfills": "./rn-get-polyfills.js",
"./src/fb_internal/*": "./src/fb_internal/*",
@@ -146,7 +150,6 @@
},
"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",
"@react-native/gradle-plugin": "0.87.0-main",
+29
View File
@@ -0,0 +1,29 @@
/**
* 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
*/
'use strict';
// ----------------------------------------------------------------------------
// Secondary react-native/asset-registry entry point.
//
// This is an untyped secondary entry point intended to be referenced from
// Metro's `transformer.assetRegistryPath` config option. This entry point may
// also be preferred in server-side code.
//
// Apps/libraries should use `import {AssetRegistry} from 'react-native'`.
// ----------------------------------------------------------------------------
const {AssetRegistry} = require('./private/assets/AssetRegistry');
/* eslint-disable @react-native/monorepo/no-commonjs-exports */
module.exports = {
registerAsset: AssetRegistry.registerAsset,
getAssetByID: AssetRegistry.getAssetByID,
};
@@ -0,0 +1,52 @@
/**
* 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
*/
export type AssetDestPathResolver = 'android' | 'generic';
export type PackagerAsset = Readonly<{
__packager_asset: boolean,
fileSystemLocation: string,
httpServerLocation: string,
width: ?number,
height: ?number,
scales: Array<number>,
hash: string,
name: string,
type: string,
resolver?: AssetDestPathResolver,
...
}>;
const assets: Array<PackagerAsset> = [];
/**
* Runtime registry that maps asset IDs generated in a Metro bundle to asset
* metadata. It backs `<Image>`, `Image.resolveAssetSource()`, and any code
* that resolves `require('./img.png')` on native.
*
* Most apps do not use this directly — assets are handled through `<Image>`.
*/
export const AssetRegistry = {
/**
* Register an asset. Returns the asset ID.
*/
registerAsset(asset: PackagerAsset): number {
// `push` returns the new length, so the first asset gets id 1 (not 0),
// keeping ids truthy.
return assets.push(asset);
},
/**
* Retrieve a registered asset by ID.
*/
getAssetByID(assetId: number): PackagerAsset {
return assets[assetId - 1];
},
};
-1
View File
@@ -25,7 +25,6 @@ const config = {
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'),
path.resolve(__dirname, '../new-app-screen'),
-1
View File
@@ -30,7 +30,6 @@ const config = {
watchFolders: [
repositoryPath('node_modules'),
repositoryPath('packages/asset-utils'),
repositoryPath('packages/assets-registry'),
repositoryPath('packages/normalize-color'),
repositoryPath('packages/polyfills'),
repositoryPath('packages/react-native'),
+5 -1
View File
@@ -155,7 +155,11 @@ async function updatePackageJson(
}
for (const dependency in newPackageVersions) {
if (dependency in deps) {
if (
dependency in deps &&
// Preserve wildcard specifiers
deps[dependency] !== '*'
) {
deps[dependency] = newPackageVersions[dependency];
}
}