Skip to content

Use indexed watch folder paths for external assets - #1892

Draft
robhogan wants to merge 1 commit into
pr1917from
robhogan/watch-folder-asset-urls
Draft

Use indexed watch folder paths for external assets#1892
robhogan wants to merge 1 commit into
pr1917from
robhogan/watch-folder-asset-urls

Conversation

@robhogan

@robhogan robhogan commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

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:

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:

const asset = require('../shared/media/tone.mp3');

Started Metro from this checkout with shared configured as a watch folder:

$ 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:

$ 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:

$ 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:

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:

$ yarn jest packages/metro/src packages/metro-transform-worker/src
$ 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 Aug 31, 2026
@robhogan
robhogan force-pushed the robhogan/watch-folder-asset-urls branch 4 times, most recently from ec1b3fc to fc45f87 Compare September 9, 2026 16:10
@robhogan
robhogan changed the base branch from main to pr1917 September 9, 2026 16:11
@robhogan
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
robhogan force-pushed the robhogan/watch-folder-asset-urls branch from fc45f87 to a9bf7e1 Compare September 9, 2026 16:37
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.

Projects

None yet

1 participant