Skip to content

Fix mismatched asset paths in builds when using unstable_serverRoot - #1917

Open
robhogan wants to merge 2 commits into
mainfrom
pr1917
Open

Fix mismatched asset paths in builds when using unstable_serverRoot#1917
robhogan wants to merge 2 commits into
mainfrom
pr1917

Conversation

@robhogan

@robhogan robhogan commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Context

When Metro sees require('./imgs/logo.png') in userland source it synthesises a JS module representing ./imgs/logo.png that encodes includes everything needed to find the asset, roughly:

{name: "logo", ext: "png", scales: [1, 2], hash: "abc123", httpServerLocation: "/assets/imgs"}

In a dev server build, the client rebuilds a URL from this information and asks Metro for the file:

http://localhost:8081/assets/imgs/logo@2x.png?platform=ios&hash=abc123

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 from httpServerLocation (even though no http server is used) at build time:

iOS:      /assets/imgs  ->  assets/imgs/logo.png, assets/imgs/logo@2x.png
Android:  /assets/imgs  ->  drawable-mdpi/imgs_logo.png, drawable-xhdpi/imgs_logo.png

and at runtime AssetSourceResolver derives the lookup path from httpServerLocation, by the same rules. Both halves have to agree.

Summary

httpServerLocation is computed twice, from two different roots. The transformer computes it per module relative to projectRoot and bakes it into the bundle - that's what the client uses to find an asset at runtime, in both dev and prod. The getAssets serialiser (for prod) computes it again over the whole graph relative to unstable_serverRoot, and that's what saveAssets uses to place files for --assets-dest.

So if unstable_serverRoot is set, Server.getAssets() and Server.build(<...>, {withAssets: true}) result in asset paths that do not match the JS bundle. Eg, for unstable_serverRoot set to a workspace root, and projectRoot set to the subdir app, the release build writes (conceptually) assets/app/assets/logo.png but the runtime looks for assets/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_serverRoot change, which applied the server root broadly across Server and took this call site with it while both readers stayed on projectRoot. It was obviously never really tested - unstable_serverRoot is/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 outside projectRoot - that's the follow up #189 .

Changelog:

 - **[Experimental]**: Fix mismatched asset paths in (release) builds under `unstable_serverRoot`, make them always relative to `projectRoot`.

Expo

No immediate impact. Expo is the main setter of unstable_serverRoot - @expo/metro-config points 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 _getAssetsFromDependencies to projectRoot with unstable_serverRoot set to /. Restoring _getServerRootDir() fails it:

$ yarn jest packages/metro/src/Server
  ✕ anchors asset URLs on projectRoot, not unstable_serverRoot
Tests: 1 failed, 83 passed, 84 total

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_serverRoot pointed at the workspace root:

workspace/
├── media/          peer-logo.png, peer-logo@2x.png, peer-logo.ios.png, peer-logo@2x.ios.png
└── app/            projectRoot; watchFolders: [workspaceRoot]
    └── assets/     in-project.png, in-project@2x.png, in-project.ios.png, in-project@2x.ios.png

react-native bundle --platform ios --dev false --entry-file app/index.js --assets-dest … on main produces the four-way mismatch shown under "The problem" above: the JS bundle carries /assets/assets and /assets/../media from the transformer, while the copied files land under assets/app/assets/ and assets/media/ from getAssets.

Running Metro.runBuild with assets: true against the same config, at each revision, 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:

                       getAssets says        destination
main                   /assets/app/assets    assets/app/assets/in-project.png
                       /assets/media         assets/media/peer-logo.png
this PR                /assets/assets        assets/assets/in-project.png
                       /assets/../media      assets/_media/peer-logo.png

Comparing each against what the runtime derives from the bundle's copy:

in-project   main      iOS MISMATCH   Android MISMATCH
             this PR   iOS MATCH      Android MATCH
peer-logo    main      iOS MISMATCH   Android MISMATCH
             this PR   iOS MATCH      Android MATCH

The peer asset matches without the [metro-watchFolders] change stacked on top, because getAssetDestPathIOS and scaledAssetURLNearBundle apply the same ../ to _ rewrite independently. An ugly filename, but a working image.

The "this PR" destinations are computed from the verified getAssets output fed through React Native's real helpers, rather than from a react-native bundle run against a patched Metro.

Against a running dev server, the URLs main produces do not resolve at the endpoint meant to serve them, and this PR's do:

$ curl -o /dev/null -w '%{http_code}' '/assets/app/assets/in-project.png?platform=ios'   # main
404
$ curl -o /dev/null -w '%{http_code}' '/assets/media/peer-logo.png?platform=ios'         # main
404
$ curl -o /dev/null -w '%{http_code}' '/assets/assets/in-project.png?platform=ios'       # this PR
200
$ curl -o /dev/null -w '%{http_code}' '/assets/../media/peer-logo.png?platform=ios'      # this PR
200

Checks

Run on this commit alone, not on the stack:

$ yarn jest packages/metro/src
Test Suites: 61 passed, 61 total
Tests:       1 skipped, 869 passed, 870 total

$ yarn flow check
$ yarn typecheck-ts
$ yarn verify-api-snapshots
$ yarn lint

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 9, 2026
@robhogan
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
```
Comment on lines +1375 to +1378
return await this._getAssetsFromDependencies(
dependencies,
transformOptions.platform,
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@robhogan robhogan changed the title Anchor asset URLs on projectRoot, not the server root Fix mismatched asset paths in builds when using unstable_serverRoot Sep 10, 2026
@robhogan
robhogan marked this pull request as ready for review September 10, 2026 14:30
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant