From 55f0bda0677b82ac06de91c4aed0292e29394d95 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Wed, 9 Sep 2026 15:03:38 +0100 Subject: [PATCH 1/2] Anchor asset URLs on projectRoot, not the server root ## Summary `_getAssetsFromDependencies` built asset URLs relative to `unstable_serverRoot`, while both readers of those URLs use `projectRoot`: the `/assets` endpoint resolves a bare relative path against `this._config.projectRoot`, and `[metro-project]` maps to `projectRoot` in `_sourceRequestRoutingMap`. With `unstable_serverRoot` set to a monorepo root, an in-project asset was served at a URL the endpoint then failed to resolve. **It sometimes worked anyway, and that was bad**. If `unstable_serverRoot` was the `projectRoot`'s parent (a common workspaces case), the URL of an asset outside `projectRoot` would be something like `http://localhost:8081/assets/../foo.jpg`. The *client* would often normalise this to `http://localhost:8081/foo.jpg` when requesting it, and Metro would serve that as a file from the deprecated static file server out of the project root, completely bypassing the `/assets` endpoint and all of the logic around it (including asset resolution, scale variants, etc). This dates to the original `unstable_serverRoot` change, which applied the server root broadly across `Server` and left the other asset call site on `projectRoot`. This was obviously never really tested, and never properly worked except by accident of the case above. Anchor asset URLs on `projectRoot`, and route the `/assets` manifest endpoint through `_getAssetsFromDependencies` so all three callers share one options object and the root cannot drift again. Changelog: ``` - **[Fix]**: Asset URLs are relative to `projectRoot` rather than `server.unstable_serverRoot`, so they resolve at the `/assets` endpoint ``` ## Expo No impact. Expo is the main setter of `unstable_serverRoot` - `@expo/metro-config` points it at the workspace root - so Expo projects are exactly the population this bug describes. They avoid it because they don't call this code path: their custom serializer calls their own fork of the `getAssets` serializer, and that call site already passes `projectRoot`, carrying the comment `// this._getServerRootDir()` to mark the deviation from Metro. It dates to expo/expo#25312, in November 2023. So this upstreams a fix Expo reached independently two years ago, and removes one of the reasons their fork has to exist. ## Test plan New unit test pins `_getAssetsFromDependencies` to `projectRoot` with `unstable_serverRoot` set to `/`. It fails if `_getServerRootDir()` is restored. ```console $ yarn jest packages/metro/src $ yarn flow check $ yarn typecheck-ts $ yarn lint ``` --- packages/metro/src/Server.js | 16 ++++++------ .../metro/src/Server/__tests__/Server-test.js | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 37d457628a..529315309a 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -449,7 +449,10 @@ export default class Server { processModuleFilter: this._config.serializer.processModuleFilter, assetPlugins: this._config.transformer.assetPlugins, platform, - projectRoot: this._getServerRootDir(), + // Asset URLs are anchored on projectRoot, not the server root: the + // /assets endpoint resolves a bare relative path against projectRoot, + // and [metro-project] means projectRoot in _sourceRequestRoutingMap. + projectRoot: this._config.projectRoot, publicPath: this._config.transformer.publicPath, }); } @@ -1369,13 +1372,10 @@ export default class Server { {onProgress, shallow: false, lazy: false}, ); - return await getAssets(dependencies, { - processModuleFilter: this._config.serializer.processModuleFilter, - assetPlugins: this._config.transformer.assetPlugins, - platform: transformOptions.platform, - publicPath: this._config.transformer.publicPath, - projectRoot: this._config.projectRoot, - }); + return await this._getAssetsFromDependencies( + dependencies, + transformOptions.platform, + ); }, finish({mres, result}) { mres.setHeader('Content-Type', 'application/json'); diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 8d81454fa1..61c8a844fb 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -63,6 +63,7 @@ describe('processRequest', () => { let getTransformFn; let getResolveDependencyFn; let getAsset; + let getAssetsSerializer; beforeEach(() => { jest.resetModules(); @@ -76,6 +77,7 @@ describe('processRequest', () => { getTransformFn = jest.fn(); getResolveDependencyFn = jest.fn(); getAsset = jest.fn(); + getAssetsSerializer = jest.fn().mockResolvedValue([]); let i = 0; jest.doMock('node:crypto', () => ({ @@ -114,6 +116,11 @@ describe('processRequest', () => { .mockImplementation(buildGraph); jest.spyOn(DeltaBundler.prototype, 'getDelta').mockImplementation(getDelta); + jest.doMock('../../DeltaBundler/Serializers/getAssets', () => ({ + __esModule: true, + default: getAssetsSerializer, + })); + Server = require('../../Server').default; }); @@ -1521,6 +1528,24 @@ describe('processRequest', () => { ); }); + describe('asset URL roots', () => { + test('anchors asset URLs on projectRoot, not unstable_serverRoot', async () => { + // $FlowFixMe[unclear-type] - reaching for a private method under test. + const serverRootServer: any = new Server( + mergeConfig(config, { + server: {unstable_serverRoot: '/'}, + } as InputConfigT), + ); + + await serverRootServer._getAssetsFromDependencies(new Map(), 'ios'); + + expect(getAssetsSerializer).toBeCalledWith( + expect.anything(), + expect.objectContaining({projectRoot: '/root'}), + ); + }); + }); + describe('watchFolder prefix resolution', () => { let watchFolderServer: $FlowFixMe; From c065b61aa2dc3a42eab2944772084cf51126b7ce Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:56:16 +0100 Subject: [PATCH 2/2] Remove verbose comment --- packages/metro/src/Server.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 529315309a..43ed021536 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -449,9 +449,6 @@ export default class Server { processModuleFilter: this._config.serializer.processModuleFilter, assetPlugins: this._config.transformer.assetPlugins, platform, - // Asset URLs are anchored on projectRoot, not the server root: the - // /assets endpoint resolves a bare relative path against projectRoot, - // and [metro-project] means projectRoot in _sourceRequestRoutingMap. projectRoot: this._config.projectRoot, publicPath: this._config.transformer.publicPath, });