From 46622fcf4d174ef3830ff9cf5e60672e9e0a99f0 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Wed, 9 Sep 2026 15:06:20 +0100 Subject: [PATCH] Use indexed watch folder paths for external assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Assets outside `projectRoot` currently derive their `httpServerLocation` from a path relative to `projectRoot`. This can produce `..` paths which escape `/assets`, and does not identify which configured watch folder owns the asset. This uses Metro's existing `[metro-watchFolders]/N/` namespace for external asset URLs - the same namespace `_sourceRequestRoutingMap` already serves source requests from, and which `_resolveWatchFolderPrefix` already decodes: - asset transforms and `getAssets` encode the path relative to the containing watch folder - the encoded path is in the transform cache key, since it reaches the transformer and is baked into the module output - the `/assets` endpoint resolves the watch folder prefix before selecting platform and density variants - assets within `projectRoot` retain their existing URLs Keeping these requests under `/assets` preserves Metro's platform-specific and density-aware asset resolution. The URL path reaches the default transformer as a separate `TransformExtras` argument rather than as a field on `JsTransformOptions`, because those options are spread wholesale into `BabelTransformerArgs` and this isn't something custom Babel transformers should be given. It's a named object rather than a bare positional argument so that the next Metro-computed input doesn't need a seventh parameter. Note: The new input is added to the transform cache key, so the first build after upgrading is a cold transform cache for every module, not just assets. Fixes: https://github.com/react/metro/issues/19 Fixes: https://github.com/react/metro/issues/290 Fixes: https://github.com/react/metro/issues/1614 Fixes: https://github.com/react/metro/issues/1615 Changelog: ``` - **[Fix]**: Use indexed watch folder paths for assets outside `projectRoot` ``` ### Expo This doesn't change anything for Expo, but it should let them delete a workaround. Expo already avoids the `..` problem, by never letting the path into the URL path in the first place: `@expo/cli` sets `transformer.publicPath` to `/assets/?unstable_path=.` in development, so an external asset is addressed as `/assets/?unstable_path=./../shared/media` and the relative segment survives as an opaque, URL-encoded query parameter. For exports they use `?export_path=` plus the same `../` to `_` rewrite React Native applies. Both are handled in their own forks of `asset-transformer` and `getAssets`, which is also why this PR won't reach Expo projects on a Metro bump alone - their transformer calls `metro-transform-worker`'s `transform` with five arguments, so it never receives `TransformExtras`. `[metro-watchFolders]/N/` is the thing `?unstable_path=` is standing in for: an unambiguous name for a file outside `projectRoot` that survives URL normalisation, in the namespace the source-request routing map already uses. If Expo adopts it, the query parameter, the encode/decode step, and the `../` to `_` rewrite in `getAssets` can go, and external assets go back through the ordinary `/assets` path with platform and density selection intact. ## Test plan Created a minimal project which imports an asset from a sibling watch folder: ```text metro-watch-folder-e2e/ ├── project/ │ ├── AssetRegistry.js │ └── index.js └── shared/ └── media/ ├── tone.mp3 ├── tone@2x.mp3 ├── tone@1x.ios.mp3 └── tone@2x.ios.mp3 ``` `index.js` imports the sibling asset: ```js const asset = require('../shared/media/tone.mp3'); ``` Started Metro from this checkout with `shared` configured as a watch folder: ```console $ yarn start serve \ --config /Users/robhogan/cowork/metro-watch-folder-e2e/metro.config.js \ --host 127.0.0.1 \ --port 8099 \ --reset-cache Metro ready ``` Requested an iOS bundle and inspected the generated asset metadata: ```console $ curl --fail --silent --show-error \ --output /private/tmp/metro-watch-folder-bundle.js \ 'http://127.0.0.1:8099/index.bundle?platform=ios&dev=true&minify=false' $ rg -A 5 '"httpServerLocation"' /private/tmp/metro-watch-folder-bundle.js "httpServerLocation": "/assets/[metro-watchFolders]/1/media", "scales": [1, 2], "hash": "6db41d4f75e3e7e47d792c57abc17cb9", "name": "tone", "type": "mp3" }); ``` Metro normalises `projectRoot` as watch folder 0, so the configured sibling directory is watch folder 1. Requested the emitted asset path at 2x density for iOS: ```console $ curl --silent --show-error --globoff --include \ 'http://127.0.0.1:8099/assets/[metro-watchFolders]/1/media/tone@2x.mp3?platform=ios&hash=6db41d4f75e3e7e47d792c57abc17cb9' HTTP/1.1 200 OK Content-Type: audio/mpeg Content-Length: 7 ios-2x ``` The response body matches `tone@2x.ios.mp3`, confirming that the indexed URL resolves through the sibling watch folder while retaining platform and density selection. New unit tests cover the two halves of that URL scheme meeting in the middle: `getAssetUrlPath` composed with `_resolveWatchFolderPrefix` round-trips back to the original absolute path for in-project, nested and watch-folder assets. It fails if the mapping is perturbed - an off-by-one in the watch folder index breaks it. ### Release builds `httpServerLocation` is also read by React Native when it copies assets for a release build, so this changes where an external asset lands on disk and what it is called. Checked with a fixture holding one in-project asset and one in a sibling watch folder, running `Metro.runBuild` with `assets: true` from this checkout and passing the resulting `AssetData` through React Native 0.87.1's own `getAssetDestPathIOS` / `getAssetDestPathAndroid` / `filterPlatformAssetScales` - the three functions `saveAssets` uses to implement `--assets-dest`: ```text before (main) ios /assets/../shared/media -> assets/_shared/media/logo.png ios /assets -> assets/local.png android /assets/../shared/media -> drawable-mdpi/_shared_media_logo.png android /assets -> drawable-mdpi/local.png after ios /assets/[metro-watchFolders]/1/media -> assets/[metro-watchFolders]/1/media/logo.png ios /assets -> assets/local.png android /assets/[metro-watchFolders]/1/media -> drawable-mdpi/metrowatchfolders_1_media_logo.png android /assets -> drawable-mdpi/local.png ``` 1x rows only; the `@2x` and `drawable-xhdpi` rows differ from these by suffix and folder alone. In-project assets are untouched on both platforms. For external assets: - The destination path and the Android resource identifier both change. `AssetSourceResolver` derives the runtime lookup from the same `httpServerLocation` that the CLI wrote the file from, so both halves move together and neither platform needs a change. - The Android resource identifier now encodes the watch folder index, so reordering `watchFolders` renames the generated resource. Previously it encoded the relative path, so moving the directory renamed it instead. It is derived either way, and was never stable across a change in project layout. - `..` no longer reaches either consumer. `scaledAssetURLNearBundle` and `getAssetDestPathIOS` each rewrite `../` to `_` independently to keep external assets inside the assets directory, which collapses distinct directories onto the same name; `assetServerURL` does not rewrite it at all, which is the escaping this PR fixes. Also ran: ```console $ yarn jest packages/metro/src packages/metro-transform-worker/src $ yarn flow check $ yarn typecheck-ts $ yarn verify-api-snapshots $ yarn lint ``` --- packages/metro-transform-worker/API.md | 11 +++- .../src/__tests__/index-test.js | 21 ++++++++ packages/metro-transform-worker/src/index.js | 13 +++++ .../src/utils/assetTransformer.js | 3 +- packages/metro/src/Assets.js | 44 ++++++++++++++++ .../Serializers/__tests__/getAssets-test.js | 23 ++++++++- .../src/DeltaBundler/Serializers/getAssets.js | 9 +++- .../metro/src/DeltaBundler/Transformer.js | 16 +++++- .../metro/src/DeltaBundler/Worker.flow.js | 18 +++++-- .../__tests__/Transformer-test.js | 34 +++++++++++++ packages/metro/src/Server.js | 8 ++- .../metro/src/Server/__tests__/Server-test.js | 50 ++++++++++++++++++- packages/metro/src/__tests__/Assets-test.js | 27 +++++++++- 13 files changed, 262 insertions(+), 15 deletions(-) diff --git a/packages/metro-transform-worker/API.md b/packages/metro-transform-worker/API.md index df45164685..d5e86ab20f 100644 --- a/packages/metro-transform-worker/API.md +++ b/packages/metro-transform-worker/API.md @@ -74,10 +74,19 @@ export type MinifierOptions = { export type MinifierResult = {code: string; map?: BasicSourceMap | undefined}; -export const transform: (config: JsTransformerConfig, projectRoot: string, projectRelativePath: string, data: Buffer, options: JsTransformOptions) => Promise; +export const transform: ( +config: JsTransformerConfig, +projectRoot: string, +projectRelativePath: string, +data: Buffer, +options: JsTransformOptions, +extras?: TransformExtras, +) => Promise; export type transform = typeof transform; +export type TransformExtras = Readonly<{assetUrlPath?: string | undefined}>; + export type Type = 'script' | 'module' | 'asset'; ``` diff --git a/packages/metro-transform-worker/src/__tests__/index-test.js b/packages/metro-transform-worker/src/__tests__/index-test.js index 2387b786ba..d0e75e01e4 100644 --- a/packages/metro-transform-worker/src/__tests__/index-test.js +++ b/packages/metro-transform-worker/src/__tests__/index-test.js @@ -135,6 +135,27 @@ test('transforms a simple module', async () => { expect(result.dependencies).toEqual([]); }); +test('uses the indexed watch folder path for asset URLs', async () => { + fs.mkdirSync('/root/external', {recursive: true}); + fs.writeFileSync('/root/external/test.mp4', 'asset data'); + + const result = await Transformer.transform( + baseConfig, + '/root', + 'external/test.mp4', + Buffer.from('asset data'), + { + ...baseTransformOptions, + type: 'asset', + }, + {assetUrlPath: '[metro-watchFolders]/1/test.mp4'}, + ); + + expect(result.output[0].data.code).toContain( + '"httpServerLocation": "/assets/[metro-watchFolders]/1"', + ); +}); + test('transforms a module with dependencies', async () => { const contents = [ '"use strict";', diff --git a/packages/metro-transform-worker/src/index.js b/packages/metro-transform-worker/src/index.js index fcf9a1a554..cf960049a9 100644 --- a/packages/metro-transform-worker/src/index.js +++ b/packages/metro-transform-worker/src/index.js @@ -133,6 +133,15 @@ export type JsTransformOptions = Readonly<{ unstable_transformProfile: TransformProfile, }>; +/** + * Inputs computed by Metro and passed alongside the public transform options. + * Kept out of JsTransformOptions because those are spread into + * BabelTransformerArgs, and these are for the default transformer only. + */ +export type TransformExtras = Readonly<{ + assetUrlPath?: string, +}>; + opaque type AbsolutePath = string; opaque type ProjectRelativePath = string; @@ -164,6 +173,7 @@ type JSONFile = { type TransformationContext = Readonly<{ config: JsTransformerConfig, + extras: TransformExtras, projectRoot: AbsolutePath, options: JsTransformOptions, }>; @@ -538,6 +548,7 @@ async function transformAsset( getBabelTransformArgs(file, context), assetRegistryPath, assetPlugins, + context.extras.assetUrlPath, ); const jsFile = { @@ -678,8 +689,10 @@ export const transform = async ( projectRelativePath: string, data: Buffer, options: JsTransformOptions, + extras?: TransformExtras = {}, ): Promise => { const context: TransformationContext = { + extras, config, options, projectRoot, diff --git a/packages/metro-transform-worker/src/utils/assetTransformer.js b/packages/metro-transform-worker/src/utils/assetTransformer.js index f3bd13d5e8..e705adaff4 100644 --- a/packages/metro-transform-worker/src/utils/assetTransformer.js +++ b/packages/metro-transform-worker/src/utils/assetTransformer.js @@ -20,6 +20,7 @@ export async function transform( {filename, options, src}: BabelTransformerArgs, assetRegistryPath: string, assetDataPlugins: ReadonlyArray, + assetUrlPath?: string, ): Promise<{ast: File, ...}> { options = options || { platform: '', @@ -32,7 +33,7 @@ export async function transform( const data = await getAssetData( absolutePath, - filename, + assetUrlPath ?? filename, assetDataPlugins, options.platform, options.publicPath, diff --git a/packages/metro/src/Assets.js b/packages/metro/src/Assets.js index 2b5ef3598a..464c2e2527 100644 --- a/packages/metro/src/Assets.js +++ b/packages/metro/src/Assets.js @@ -243,6 +243,50 @@ export async function getAssetData( return await applyAssetDataPlugins(assetDataPlugins, assetData); } +/** + * Returns the path used to identify an asset in its development server URL. + * Assets outside projectRoot use an indexed watch folder prefix so that the + * URL unambiguously identifies their configured root. + * + * Roots are resolved as Server does when it decodes these paths, so that the + * index here and the index it reads back refer to the same directory. + */ +export function getAssetUrlPath( + assetPath: string, + projectRoot: string, + watchFolders: ReadonlyArray, +): string { + const projectRelativePath = path.relative( + path.resolve(projectRoot), + assetPath, + ); + if (isPathInsideRoot(projectRelativePath)) { + return normalizePathSeparatorsToPosix(projectRelativePath); + } + + for (let i = 0; i < watchFolders.length; i++) { + const watchFolderRelativePath = path.relative( + path.resolve(watchFolders[i]), + assetPath, + ); + if (isPathInsideRoot(watchFolderRelativePath)) { + return normalizePathSeparatorsToPosix( + path.join('[metro-watchFolders]', String(i), watchFolderRelativePath), + ); + } + } + + return normalizePathSeparatorsToPosix(projectRelativePath); +} + +function isPathInsideRoot(relativePath: string): boolean { + return ( + relativePath !== '..' && + !relativePath.startsWith('..' + path.sep) && + !path.isAbsolute(relativePath) + ); +} + async function applyAssetDataPlugins( assetDataPlugins: ReadonlyArray, assetData: AssetData, diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js index c13c148b66..08682ce876 100644 --- a/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js @@ -10,7 +10,7 @@ jest.mock('../../../Assets'); -import {getAssetData} from '../../../Assets'; +import {getAssetData, getAssetUrlPath} from '../../../Assets'; import getAssets from '../getAssets'; beforeEach(() => { @@ -18,6 +18,9 @@ beforeEach(() => { path, localPath, })); + getAssetUrlPath.mockImplementation( + jest.requireActual('../../../Assets').getAssetUrlPath, + ); }); test('should return the bundle assets', async () => { @@ -82,16 +85,32 @@ test('should return the bundle assets', async () => { ], }, ], + [ + '/external/6.png', + { + path: '/external/6.png', + output: [ + { + type: 'js/module/asset', + data: {code: '//', lineCount: 1, map: [], functionMap: null}, + }, + ], + }, + ], ]); expect( await getAssets(dependencies, { projectRoot: '/tmp', - watchFolders: ['/tmp'], + watchFolders: ['/tmp', '/external'], processModuleFilter: () => true, }), ).toEqual([ {path: '/tmp/3.png', localPath: '3.png'}, {path: '/tmp/5.mov', localPath: '5.mov'}, + { + path: '/external/6.png', + localPath: '[metro-watchFolders]/1/6.png', + }, ]); }); diff --git a/packages/metro/src/DeltaBundler/Serializers/getAssets.js b/packages/metro/src/DeltaBundler/Serializers/getAssets.js index bc0c5d94d1..8111a1e1c4 100644 --- a/packages/metro/src/DeltaBundler/Serializers/getAssets.js +++ b/packages/metro/src/DeltaBundler/Serializers/getAssets.js @@ -12,7 +12,7 @@ import type {AssetData} from '../../Assets'; import type {Module, ReadOnlyDependencies} from '../types'; -import {getAssetData} from '../../Assets'; +import {getAssetData, getAssetUrlPath} from '../../Assets'; import {getJsOutput, isJsModule} from './helpers/js'; import path from 'node:path'; @@ -22,6 +22,7 @@ type Options = { platform: ?string, projectRoot: string, publicPath: string, + watchFolders: ReadonlyArray, }; export default async function getAssets( @@ -41,7 +42,11 @@ export default async function getAssets( promises.push( getAssetData( module.path, - path.relative(options.projectRoot, module.path), + getAssetUrlPath( + module.path, + options.projectRoot, + options.watchFolders, + ), options.assetPlugins, options.platform, options.publicPath, diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index 4fe9cfccc7..90bf123bdf 100644 --- a/packages/metro/src/DeltaBundler/Transformer.js +++ b/packages/metro/src/DeltaBundler/Transformer.js @@ -13,6 +13,7 @@ import type {TransformResult, TransformResultWithSource} from '../DeltaBundler'; import type {TransformerConfig, TransformOptions} from './Worker'; import type {ConfigT} from 'metro-config'; +import {getAssetUrlPath} from '../Assets'; import {normalizePathSeparatorsToPosix} from '../lib/pathUtils'; import getTransformCacheKey from './getTransformCacheKey'; import WorkerFarm from './WorkerFarm'; @@ -112,6 +113,14 @@ export default class Transformer { this._config.projectRoot, filePath, ); + const assetUrlPath = + type === 'asset' + ? getAssetUrlPath( + filePath, + this._config.projectRoot, + this._config.watchFolders, + ) + : null; const partialKey = stableHash([ // This is the hash related to the global Bundler config. @@ -121,6 +130,9 @@ export default class Transformer { // addition to content hash because transformers receive path as an // input, and may apply e.g. extension-based logic. normalizePathSeparatorsToPosix(projectRelativePath), + assetUrlPath == null + ? null + : normalizePathSeparatorsToPosix(assetUrlPath), customTransformOptions, dev, experimentalImportSupport, @@ -170,7 +182,9 @@ export default class Transformer { ? {result, sha1} : await this._workerFarm.transform( projectRelativePath, - transformerOptions, + assetUrlPath == null + ? transformerOptions + : {...transformerOptions, assetUrlPath}, content, ); diff --git a/packages/metro/src/DeltaBundler/Worker.flow.js b/packages/metro/src/DeltaBundler/Worker.flow.js index c13dcc4e07..632dec33e3 100644 --- a/packages/metro/src/DeltaBundler/Worker.flow.js +++ b/packages/metro/src/DeltaBundler/Worker.flow.js @@ -14,6 +14,7 @@ import type {LogEntry} from 'metro-core/private/Logger'; import type { JsTransformerConfig, JsTransformOptions, + TransformExtras, } from 'metro-transform-worker'; import traverse from '@babel/traverse'; @@ -21,7 +22,13 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -export type {JsTransformOptions as TransformOptions} from 'metro-transform-worker'; +export type TransformOptions = Readonly<{ + ...JsTransformOptions, + // Split out of the options before they reach the transformer, which receives + // them as a separate TransformExtras argument, so that they are not spread + // into BabelTransformerArgs along with the public options. + assetUrlPath?: string, +}>; type TransformerInterface = { transform( @@ -30,6 +37,7 @@ type TransformerInterface = { string, Buffer, JsTransformOptions, + TransformExtras, ): Promise>, }; @@ -68,7 +76,7 @@ function asDeserializedBuffer(value: any): Buffer | null { export const transform = ( filename: string, - transformOptions: JsTransformOptions, + transformOptions: TransformOptions, projectRoot: string, transformerConfig: TransformerConfig, fileBuffer?: Buffer, @@ -97,7 +105,7 @@ export type Worker = { async function transformFile( projectRelativePath: string, data: Buffer, - transformOptions: JsTransformOptions, + transformOptions: TransformOptions, projectRoot: string, transformerConfig: TransformerConfig, ): Promise { @@ -117,12 +125,14 @@ async function transformFile( const sha1 = crypto.createHash('sha1').update(data).digest('hex'); + const {assetUrlPath, ...publicTransformOptions} = transformOptions; const result = await Transformer.transform( transformerConfig.transformerConfig, projectRoot, projectRelativePath, data, - transformOptions, + publicTransformOptions, + {assetUrlPath}, ); // The babel cache caches scopes and pathes for already traversed AST nodes. diff --git a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js index 6d49546a78..72bff7acce 100644 --- a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js @@ -22,6 +22,7 @@ jest const Transformer = require('../Transformer').default; const {getDefaultValues} = require('metro-config').getDefaultConfig; const {mergeConfig} = require('metro-config/private/loadConfig'); +const path = require('node:path'); const fs = jest.requireMock('node:fs'); @@ -215,4 +216,37 @@ describe('Transformer', function () { expect(require('../getTransformCacheKey')).not.toBeCalled(); }); + + test('passes an indexed watch folder URL path to asset transforms', async () => { + const workerTransform = + require('../WorkerFarm').default.prototype.transform; + workerTransform.mockClear(); + workerTransform.mockReturnValue({ + sha1: 'abcdefabcdefabcdefabcdefabcdefabcdefabcd', + result: {}, + }); + fs.mkdirSync('/external', {recursive: true}); + + const transformerInstance = new Transformer( + { + ...commonOptions, + cacheStores: [], + watchFolders: ['/root', '/external'], + }, + {getOrComputeSha1}, + ); + + await transformerInstance.transformFile('/external/imgs/a.png', { + type: 'asset', + }); + + expect(workerTransform).toHaveBeenCalledWith( + path.join('..', 'external', 'imgs', 'a.png'), + { + type: 'asset', + assetUrlPath: '[metro-watchFolders]/1/imgs/a.png', + }, + undefined, + ); + }); }); diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 529315309a..8cb8feeceb 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -454,6 +454,7 @@ export default class Server { // and [metro-project] means projectRoot in _sourceRequestRoutingMap. projectRoot: this._config.projectRoot, publicPath: this._config.transformer.publicPath, + watchFolders: this._config.watchFolders, }); } @@ -574,9 +575,12 @@ export default class Server { try { const depGraph = await this._bundler.getBundler().getDependencyGraph(); + const resolvedAssetPath = this._resolveWatchFolderPrefix( + './' + assetPath, + ); const data = await getAsset( - assetPath, - this._config.projectRoot, + resolvedAssetPath?.filePath ?? assetPath, + resolvedAssetPath?.rootDir ?? this._config.projectRoot, this._config.watchFolders, urlObj.searchParams.get('platform'), this._config.resolver.assetExts, diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 61c8a844fb..580cb0c4b3 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -901,6 +901,24 @@ describe('processRequest', () => { ); }); + test('should resolve an indexed watch folder asset path', async () => { + getAsset.mockResolvedValue(Promise.resolve('i am image')); + + const response = await makeRequest( + '/assets/[metro-watchFolders]/0/imgs/a.png?platform=ios', + ); + expect(response._getString()).toBe('i am image'); + + expect(getAsset).toBeCalledWith( + './imgs/a.png', + '/root', + ['/root'], + 'ios', + expect.any(Array), + expect.any(Function), + ); + }); + test('should serve range request', async () => { const mockData = 'i am image'; getAsset.mockResolvedValue(mockData); @@ -1541,7 +1559,10 @@ describe('processRequest', () => { expect(getAssetsSerializer).toBeCalledWith( expect.anything(), - expect.objectContaining({projectRoot: '/root'}), + expect.objectContaining({ + projectRoot: '/root', + watchFolders: ['/root'], + }), ); }); }); @@ -1627,5 +1648,32 @@ describe('processRequest', () => { '/project/mybundle', ); }); + + test.each([ + '/project/imgs/a.png', + '/project/nested/deep/b.png', + '/external/packages/imgs/c.png', + '/external/packages/d.png', + ])( + 'asset URL path for %s round-trips back to the same file', + absolutePath => { + const {getAssetUrlPath} = require('../../Assets'); + const urlPath = getAssetUrlPath(absolutePath, '/project', [ + '/project', + '/external/packages', + ]); + + // Mirrors how _processSingleAssetRequest resolves an incoming URL. + const resolved = watchFolderServer._resolveWatchFolderPrefix( + './' + urlPath, + ); + expect( + path.resolve( + resolved?.rootDir ?? '/project', + resolved?.filePath ?? urlPath, + ), + ).toBe(absolutePath); + }, + ); }); }); diff --git a/packages/metro/src/__tests__/Assets-test.js b/packages/metro/src/__tests__/Assets-test.js index 4b35afd058..a359fc0f59 100644 --- a/packages/metro/src/__tests__/Assets-test.js +++ b/packages/metro/src/__tests__/Assets-test.js @@ -20,7 +20,12 @@ jest.mock('../lib/imageSize', () => ({ jest.useRealTimers(); -const {getAsset, getAssetData, getAssetSize} = require('../Assets'); +const { + getAsset, + getAssetData, + getAssetSize, + getAssetUrlPath, +} = require('../Assets'); const getImageDimensions = require('../lib/imageSize').getImageDimensions; const crypto = require('node:crypto'); const path = require('node:path'); @@ -30,6 +35,26 @@ const fs = jest.requireMock('node:fs'); const mockImageWidth = 300; const mockImageHeight = 200; +describe('getAssetUrlPath', () => { + test('uses a project-relative path for assets within projectRoot', () => { + expect( + getAssetUrlPath('/root/imgs/a.png', '/root', ['/root', '/external']), + ).toBe('imgs/a.png'); + }); + + test('uses an indexed path for assets within a watch folder', () => { + expect( + getAssetUrlPath('/external/imgs/a.png', '/root', ['/root', '/external']), + ).toBe('[metro-watchFolders]/1/imgs/a.png'); + }); + + test('falls back to a project-relative path outside configured roots', () => { + expect( + getAssetUrlPath('/other/imgs/a.png', '/root', ['/root', '/external']), + ).toBe('../other/imgs/a.png'); + }); +}); + describe('getAssetSize', () => { test('returns null for non-image assets', () => { expect(getAssetSize('mp4', Buffer.from('video'), '/root/video.mp4')).toBe(