Conversation
robhogan
added this pull request to stack #1918
September 9, 2026 16:11
## 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 ```
robhogan
commented
Sep 10, 2026
Comment on lines
+1375
to
+1378
| return await this._getAssetsFromDependencies( | ||
| dependencies, | ||
| transformOptions.platform, | ||
| ); |
Collaborator
Author
There was a problem hiding this comment.
This change is behaviour preserving - this _processAssetsRequest path (which serves *.assets requests - whole-bundle asset manifests - this is not the /assets server) already passed projectRoot: this._config.projectRoot and so was already correct.
The change here just reuses _getAssetsFromDependencies now that that has been changed to match.
unstable_serverRoot
robhogan
marked this pull request as ready for review
September 10, 2026 14:30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
When Metro sees
require('./imgs/logo.png')in userland source it synthesises a JS module representing./imgs/logo.pngthat encodes includes everything needed to find the asset, roughly:In a dev server build, the client rebuilds a URL from this information and asks Metro for the file:
In a "release" build (more correctly - any CLI-driven build not served from the dev server, but this is typically used for (and only for) release builds), typically, frameworks use Metro's
saveAssets, which copies each file to a path derived fromhttpServerLocation(even though no http server is used) at build time:and at runtime
AssetSourceResolverderives the lookup path fromhttpServerLocation, by the same rules. Both halves have to agree.Summary
httpServerLocationis computed twice, from two different roots. The transformer computes it per module relative toprojectRootand bakes it into the bundle - that's what the client uses to find an asset at runtime, in both dev and prod. ThegetAssetsserialiser (for prod) computes it again over the whole graph relative tounstable_serverRoot, and that's whatsaveAssetsuses to place files for--assets-dest.So if
unstable_serverRootis set,Server.getAssets()andServer.build(<...>, {withAssets: true})result in asset paths that do not match the JS bundle. Eg, forunstable_serverRootset to a workspace root, andprojectRootset to the subdirapp, the release build writes (conceptually)assets/app/assets/logo.pngbut the runtime looks forassets/assets/logo.png.Dev is unaffected, because there both halves of the lookup come from the transformer's copy.
This dates to the original
unstable_serverRootchange, which applied the server root broadly acrossServerand took this call site with it while both readers stayed onprojectRoot. It was obviously never really tested -unstable_serverRootis/was used at Meta but the release assets pipeline via Metro-Buck is completely different.The main motivation for this now is not to make it easier to use
unstable_serverRoot, which probably should not exist, but to make asset path calculation consistent, allowing us to build on that to fix assets outsideprojectRoot- that's the follow up #189 .Changelog:
Expo
No immediate impact. Expo is the main setter of
unstable_serverRoot-@expo/metro-configpoints it at the workspace root. Expo already applies exactly this fix in our fork of the assets path - expo/expo#25312, since 2023.So this fix in core removes one of the reasons Expo's fork has to exist.
Community CLI
No impact -
unstable_serverRoot(thankfully) remains completely unmentioned in RN or RNC/CLI.Test plan
Unit
New test pins
_getAssetsFromDependenciestoprojectRootwithunstable_serverRootset to/. Restoring_getServerRootDir()fails it:End to end
Built a React Native 0.87.1 app in a Yarn workspace, with an asset directory peer to the app root and
server.unstable_serverRootpointed at the workspace root:react-native bundle --platform ios --dev false --entry-file app/index.js --assets-dest …onmainproduces the four-way mismatch shown under "The problem" above: the JS bundle carries/assets/assetsand/assets/../mediafrom the transformer, while the copied files land underassets/app/assets/andassets/media/fromgetAssets.Running
Metro.runBuildwithassets: trueagainst the same config, at each revision, and passing the resultingAssetDatathrough React Native 0.87.1's owngetAssetDestPathIOS/getAssetDestPathAndroid/filterPlatformAssetScales- the three functionssaveAssetsuses to implement--assets-dest:Comparing each against what the runtime derives from the bundle's copy:
The peer asset matches without the
[metro-watchFolders]change stacked on top, becausegetAssetDestPathIOSandscaledAssetURLNearBundleapply the same../to_rewrite independently. An ugly filename, but a working image.The "this PR" destinations are computed from the verified
getAssetsoutput fed through React Native's real helpers, rather than from areact-native bundlerun against a patched Metro.Against a running dev server, the URLs
mainproduces do not resolve at the endpoint meant to serve them, and this PR's do:Checks
Run on this commit alone, not on the stack: