Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/metro-transform-worker/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ 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<TransformResponse>;
export const transform: (config: JsTransformerConfig, projectRoot: string, projectRelativePath: string, data: Buffer, options: JsTransformOptions, assetUrlPath?: string) => Promise<TransformResponse>;

export type transform = typeof transform;

Expand Down
3 changes: 2 additions & 1 deletion packages/metro-transform-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
},
"devDependencies": {
"@react-native/metro-babel-transformer": "0.78.0",
"metro-memory-fs": "*"
"metro-memory-fs": "*",
"metro-resolver": "*"
},
"engines": {
"node": "^22.13.0 || ^24.3.0 || >= 26.0.0"
Expand Down
34 changes: 32 additions & 2 deletions packages/metro-transform-worker/src/__tests__/index-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ import type {JsTransformerConfig, JsTransformOptions} from '../index';
import typeof * as TransformerType from '../index';
import typeof FSType from 'node:fs';

const {
posixToSystemPath: p,
} = require('metro-resolver/private/__tests__/utils');
const {Buffer} = require('node:buffer');

const babelTransformerPath =
Expand Down Expand Up @@ -79,14 +82,20 @@ const baseTransformOptions: JsTransformOptions = {
beforeEach(() => {
jest.resetModules();

jest.mock('node:fs', () => new (require('metro-memory-fs'))());
jest.mock(
'node:fs',
() =>
new (require('metro-memory-fs'))({
platform: process.platform === 'win32' ? 'win32' : 'posix',
}),
);

fs = jest.requireMock('node:fs');
Transformer = require('../');
// $FlowFixMe[prop-missing] Cannot call `fs.reset` because property `reset` is missing in module `fs`
fs.reset();

fs.mkdirSync('/root/local', {recursive: true});
fs.mkdirSync(p('/root/local'), {recursive: true});
});

test('transforms a simple script', async () => {
Expand Down Expand Up @@ -129,6 +138,27 @@ test('transforms a simple module', async () => {
expect(result.dependencies).toEqual([]);
});

test('uses the indexed watch folder path for asset URLs', async () => {
fs.mkdirSync(p('/root/external'), {recursive: true});
fs.writeFileSync(p('/root/external/test.mp4'), 'asset data');

const result = await Transformer.transform(
baseConfig,
p('/root'),
'external/test.mp4',
Buffer.from('asset data'),
{
...baseTransformOptions,
type: 'asset',
},
'[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";',
Expand Down
8 changes: 8 additions & 0 deletions packages/metro-transform-worker/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ type JSONFile = {
};

type TransformationContext = Readonly<{
assetUrlPath: ?string,
config: JsTransformerConfig,
projectRoot: AbsolutePath,
options: JsTransformOptions,
Expand Down Expand Up @@ -538,6 +539,7 @@ async function transformAsset(
getBabelTransformArgs(file, context),
assetRegistryPath,
assetPlugins,
context.assetUrlPath ?? undefined,
);

const jsFile = {
Expand Down Expand Up @@ -678,8 +680,14 @@ export const transform = async (
projectRelativePath: string,
data: Buffer,
options: JsTransformOptions,
// The asset's path relative to the asset server root (`publicPath`), as
// clients will request it. Computed by Metro for assets only, and passed
// separately from the options so that it is not spread into
// BabelTransformerArgs.
assetUrlPath?: string,
): Promise<TransformResponse> => {
const context: TransformationContext = {
assetUrlPath,
config,
options,
projectRoot,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export async function transform(
{filename, options, src}: BabelTransformerArgs,
assetRegistryPath: string,
assetDataPlugins: ReadonlyArray<string>,
urlPath?: string,
): Promise<{ast: File, ...}> {
options = options || {
platform: '',
Expand All @@ -32,7 +33,7 @@ export async function transform(

const data = await getAssetData(
absolutePath,
filename,
urlPath ?? filename,
assetDataPlugins,
options.platform,
options.publicPath,
Expand Down
74 changes: 65 additions & 9 deletions packages/metro/src/Assets.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,20 +204,26 @@ async function getAbsoluteAssetInfo(

export async function getAssetData(
assetPath: string,
localPath: string,
urlPath: string,
assetDataPlugins: ReadonlyArray<string>,
platform: ?string,
publicPath: string,
): Promise<AssetData> {
// If the path of the asset is outside of the projectRoot, we don't want to
// use `path.join` since this will generate an incorrect URL path. In that
// case we just concatenate the publicPath with the relative path.
let assetUrlPath = localPath.startsWith('..')
? publicPath.replace(/\/$/, '') + '/' + path.dirname(localPath)
: path.join(publicPath, path.dirname(localPath));
// urlPath is the asset's path relative to publicPath. Only its directory
// reaches the client, which composes the file name from the scale and
// platform it needs.
//
// Metro itself never passes a path starting with `..` here, since assets
// outside projectRoot are addressed by watch folder index. Custom
// transformers that call this with a project-relative path still can, and
// `path.join` would collapse the `..` against publicPath, so concatenate
// instead to preserve it.
let httpServerLocation = urlPath.startsWith('..')
? publicPath.replace(/\/$/, '') + '/' + path.dirname(urlPath)
: path.join(publicPath, path.dirname(urlPath));

// On Windows, change backslashes to slashes to get proper URL path from file path.
assetUrlPath = normalizePathSeparatorsToPosix(assetUrlPath);
httpServerLocation = normalizePathSeparatorsToPosix(httpServerLocation);

const {assetInfo, firstFileContent} = await getAbsoluteAssetInfo(
assetPath,
Expand All @@ -231,7 +237,7 @@ export async function getAssetData(
const assetData = {
__packager_asset: true,
fileSystemLocation: path.dirname(assetPath),
httpServerLocation: assetUrlPath,
httpServerLocation,
width: dimensions ? dimensions.width / scale : undefined,
height: dimensions ? dimensions.height / scale : undefined,
scales: assetInfo.scales,
Expand All @@ -243,6 +249,56 @@ export async function getAssetData(
return await applyAssetDataPlugins(assetDataPlugins, assetData);
}

/**
* Returns the path used to identify an asset in its development server URL,
* relative to publicPath. 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. The
* first root to contain the asset wins, in index order, so that appending a
* watch folder never changes the URL of an asset under an existing one.
*/
export function getAssetUrlPath(
assetPath: string,
projectRoot: string,
watchFolders: ReadonlyArray<string>,
): 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),
);
}
}

throw new Error(
`Asset '${assetPath}' is not within projectRoot '${projectRoot}' or any ` +
`watch folder (${watchFolders.join(', ')})`,
);
}

function isPathInsideRoot(relativePath: string): boolean {
return (
relativePath !== '..' &&
!relativePath.startsWith('..' + path.sep) &&
!path.isAbsolute(relativePath)
);
}

async function applyAssetDataPlugins(
assetDataPlugins: ReadonlyArray<string>,
assetData: AssetData,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@

jest.mock('../../../Assets');

import {getAssetData} from '../../../Assets';
import {getAssetData, getAssetUrlPath} from '../../../Assets';
import getAssets from '../getAssets';

beforeEach(() => {
getAssetData.mockImplementation(async (path, localPath) => ({
path,
localPath,
}));
getAssetUrlPath.mockImplementation(
jest.requireActual('../../../Assets').getAssetUrlPath,
);
});

test('should return the bundle assets', async () => {
Expand Down Expand Up @@ -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',
},
]);
});
9 changes: 7 additions & 2 deletions packages/metro/src/DeltaBundler/Serializers/getAssets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -22,6 +22,7 @@ type Options = {
platform: ?string,
projectRoot: string,
publicPath: string,
watchFolders: ReadonlyArray<string>,
};

export default async function getAssets(
Expand All @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions packages/metro/src/DeltaBundler/Transformer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -113,6 +114,17 @@ export default class Transformer {
this._config.projectRoot,
filePath,
);
// Assets are the only modules whose output depends on watchFolders, via
// the URL path baked into them, so that dependency enters the cache key
// per asset here and not in the base hash.
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.
Expand All @@ -122,6 +134,7 @@ 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,
customTransformOptions,
dev,
experimentalImportSupport,
Expand Down Expand Up @@ -173,6 +186,7 @@ export default class Transformer {
projectRelativePath,
transformerOptions,
content,
assetUrlPath ?? undefined,
);

// Only re-compute the full key if the SHA-1 changed. This is because
Expand Down
5 changes: 5 additions & 0 deletions packages/metro/src/DeltaBundler/Worker.flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type TransformerInterface = {
string,
Buffer,
JsTransformOptions,
?string,
): Promise<TransformResult<>>,
};

Expand Down Expand Up @@ -72,6 +73,7 @@ export const transform = (
projectRoot: string,
transformerConfig: TransformerConfig,
fileBuffer?: Buffer,
assetUrlPath?: string,
): Promise<Data> => {
let data;

Expand All @@ -87,6 +89,7 @@ export const transform = (
transformOptions,
projectRoot,
transformerConfig,
assetUrlPath,
);
};

Expand All @@ -100,6 +103,7 @@ async function transformFile(
transformOptions: JsTransformOptions,
projectRoot: string,
transformerConfig: TransformerConfig,
assetUrlPath?: string,
): Promise<Data> {
// eslint-disable-next-line no-useless-call
const Transformer: TransformerInterface = require.call(
Expand All @@ -123,6 +127,7 @@ async function transformFile(
projectRelativePath,
data,
transformOptions,
assetUrlPath,
);

// The babel cache caches scopes and paths for already traversed AST nodes.
Expand Down
Loading
Loading