Use indexed watch folder paths for external assets - #1892
Draft
robhogan wants to merge 1 commit into
Draft
Conversation
robhogan
force-pushed
the
robhogan/watch-folder-asset-urls
branch
4 times, most recently
from
September 9, 2026 16:10
ec1b3fc to
fc45f87
Compare
robhogan
added this pull request to stack #1918
September 9, 2026 16:11
## 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: #19 Fixes: #290 Fixes: #1614 Fixes: #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 ```
robhogan
force-pushed
the
robhogan/watch-folder-asset-urls
branch
from
September 9, 2026 16:37
fc45f87 to
a9bf7e1
Compare
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.
Summary
Assets outside
projectRootcurrently derive theirhttpServerLocationfrom a path relative toprojectRoot. 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_sourceRequestRoutingMapalready serves source requests from, and which_resolveWatchFolderPrefixalready decodes:getAssetsencode the path relative to the containing watch folder/assetsendpoint resolves the watch folder prefix before selecting platform and density variantsprojectRootretain their existing URLsKeeping these requests under
/assetspreserves Metro's platform-specific and density-aware asset resolution.The URL path reaches the default transformer as a separate
TransformExtrasargument rather than as a field onJsTransformOptions, because those options are spread wholesale intoBabelTransformerArgsand 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: #19
Fixes: #290
Fixes: #1614
Fixes: #1615
Changelog:
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/clisetstransformer.publicPathto/assets/?unstable_path=.in development, so an external asset is addressed as/assets/?unstable_path=./../shared/mediaand 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 ofasset-transformerandgetAssets, which is also why this PR won't reach Expo projects on a Metro bump alone - their transformer callsmetro-transform-worker'stransformwith five arguments, so it never receivesTransformExtras.[metro-watchFolders]/N/is the thing?unstable_path=is standing in for: an unambiguous name for a file outsideprojectRootthat 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 ingetAssetscan go, and external assets go back through the ordinary/assetspath with platform and density selection intact.Test plan
Created a minimal project which imports an asset from a sibling watch folder:
index.jsimports the sibling asset:Started Metro from this checkout with
sharedconfigured as a watch folder:Requested an iOS bundle and inspected the generated asset metadata:
Metro normalises
projectRootas watch folder 0, so the configured sibling directory is watch folder 1.Requested the emitted asset path at 2x density for iOS:
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:
getAssetUrlPathcomposed with_resolveWatchFolderPrefixround-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
httpServerLocationis 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, runningMetro.runBuildwithassets: truefrom this checkout and passing the resultingAssetDatathrough React Native 0.87.1's owngetAssetDestPathIOS/getAssetDestPathAndroid/filterPlatformAssetScales- the three functionssaveAssetsuses to implement--assets-dest:1x rows only; the
@2xanddrawable-xhdpirows differ from these by suffix and folder alone.In-project assets are untouched on both platforms. For external assets:
AssetSourceResolverderives the runtime lookup from the samehttpServerLocationthat the CLI wrote the file from, so both halves move together and neither platform needs a change.watchFoldersrenames 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.scaledAssetURLNearBundleandgetAssetDestPathIOSeach rewrite../to_independently to keep external assets inside the assets directory, which collapses distinct directories onto the same name;assetServerURLdoes not rewrite it at all, which is the escaping this PR fixes.Also ran: