From 569ec8bf324b7758687de11eeec71c8ee192ff34 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Fri, 8 May 2026 08:18:36 -0700 Subject: [PATCH 01/45] fix: Add ESM unwrapping to all CommonJS require calls (#1675) Summary: This was added to `loadConfig.js` but is missing in other places. While it's arguably not useful in many cases in a "standard" Metro setup with Metro's own packages, it'd be nice to have this behave consistently even with transpiled ESM output (or with require-ESM in Node). Specifically this affects: - metro-file-map plugin workers that are loaded with `require` - metro-file-map's main worker loading - metro's asset plugins - metro-transform-worker's `getMinifier` implementation - metro-transform-worker's `babelTransformerPath` loading This is probably specifically interesting for `babelTransformerPath` which is often overridden by third-party setups. Changelog: [Fix] Unwrap ES modules when requiring metro-file-map workers, asset plugins, the minifier, or the babel transformer Pull Request resolved: https://github.com/facebook/metro/pull/1675 Test Plan: - n/a: This will likely be best left untested, but is a consistent implementation that matches ESM->CJS semantics, also used in `loadConfig.js` Reviewed By: javache Differential Revision: D102510513 Pulled By: robhogan fbshipit-source-id: 863b53901d90e06853f96fa04b2b129c85b316e8 (cherry picked from commit 0e9d7aae402e72d2065474e5788381bd4ad6226d) --- .../metro-file-map/src/plugins/DependencyPlugin.js | 9 +++++++-- .../metro-file-map/src/plugins/dependencies/worker.js | 11 ++++++++--- packages/metro-file-map/src/plugins/haste/worker.js | 4 +++- packages/metro-file-map/src/worker.js | 4 +++- packages/metro-transform-worker/src/index.js | 4 +++- .../metro-transform-worker/src/utils/getMinifier.js | 3 ++- packages/metro/src/Assets.js | 5 ++++- 7 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/metro-file-map/src/plugins/DependencyPlugin.js b/packages/metro-file-map/src/plugins/DependencyPlugin.js index a9f33241cb..50f5551ef3 100644 --- a/packages/metro-file-map/src/plugins/DependencyPlugin.js +++ b/packages/metro-file-map/src/plugins/DependencyPlugin.js @@ -28,8 +28,13 @@ export default class DependencyPlugin extends FileDataPlugin */, ) { if (dependencyExtractor != null) { // $FlowFixMe[unsupported-syntax] - dynamic require - this.#dependencyExtractor = require(dependencyExtractor); + const mod = require(dependencyExtractor); + let extract = mod?.extract; // CJS export or ESM named export + if (extract == null && mod?.__esModule === true) { + extract = mod.default?.extract; + } + this.#dependencyExtractor = extract; } } @@ -40,7 +45,7 @@ module.exports = class DependencyExtractorWorker /*:: implements MetadataWorker const dependencies = this.#dependencyExtractor != null - ? this.#dependencyExtractor.extract( + ? this.#dependencyExtractor( content, filePath, defaultDependencyExtractor.extract, diff --git a/packages/metro-file-map/src/plugins/haste/worker.js b/packages/metro-file-map/src/plugins/haste/worker.js index c6c14806d4..80e01886f9 100644 --- a/packages/metro-file-map/src/plugins/haste/worker.js +++ b/packages/metro-file-map/src/plugins/haste/worker.js @@ -30,7 +30,9 @@ module.exports = class Worker /*:: implements MetadataWorker */ { ) { if (hasteImplModulePath != null) { // $FlowFixMe[unsupported-syntax] - dynamic require - this.#hasteImpl = require(hasteImplModulePath); + const mod = require(hasteImplModulePath); + this.#hasteImpl = + mod.__esModule === true && 'default' in mod ? mod.default : mod; } } diff --git a/packages/metro-file-map/src/worker.js b/packages/metro-file-map/src/worker.js index 92a20908e7..a76b00a8f6 100644 --- a/packages/metro-file-map/src/worker.js +++ b/packages/metro-file-map/src/worker.js @@ -36,7 +36,9 @@ class Worker { constructor({plugins = []} /*: WorkerSetupArgs */) { this.#plugins = plugins.map(({modulePath, setupArgs}) => { // $FlowFixMe[unsupported-syntax] - dynamic require - const PluginWorker = require(modulePath); + const mod = require(modulePath); + const PluginWorker = + mod.__esModule === true && 'default' in mod ? mod.default : mod; return new PluginWorker(setupArgs); }); } diff --git a/packages/metro-transform-worker/src/index.js b/packages/metro-transform-worker/src/index.js index 84a0b4b143..8f7cb85d01 100644 --- a/packages/metro-transform-worker/src/index.js +++ b/packages/metro-transform-worker/src/index.js @@ -542,7 +542,9 @@ async function transformJSWithBabel( ): Promise { const {babelTransformerPath} = context.config; // $FlowFixMe[unsupported-syntax] dynamic require - const transformer: BabelTransformer = require(babelTransformerPath); + const mod = require(babelTransformerPath); + const transformer: BabelTransformer = + mod.__esModule === true && 'default' in mod ? mod.default : mod; const transformResult = await transformer.transform( getBabelTransformArgs(file, context, [ diff --git a/packages/metro-transform-worker/src/utils/getMinifier.js b/packages/metro-transform-worker/src/utils/getMinifier.js index be0cdf44c3..1d2b2de6f9 100644 --- a/packages/metro-transform-worker/src/utils/getMinifier.js +++ b/packages/metro-transform-worker/src/utils/getMinifier.js @@ -17,7 +17,8 @@ export default function getMinifier(minifierPath: string): Minifier { // any entry point that accepts them... try { // $FlowFixMe[unsupported-syntax] TODO t0 cannot do require with literal - return require(minifierPath); + const mod = require(minifierPath); + return mod.__esModule === true && 'default' in mod ? mod.default : mod; } catch (e) { throw new Error( 'A problem occurred while trying to fetch the minifier. Path: "' + diff --git a/packages/metro/src/Assets.js b/packages/metro/src/Assets.js index 83c2460535..11427e9c39 100644 --- a/packages/metro/src/Assets.js +++ b/packages/metro/src/Assets.js @@ -250,7 +250,10 @@ async function applyAssetDataPlugins( const [currentAssetPlugin, ...remainingAssetPlugins] = assetDataPlugins; // $FlowFixMe[unsupported-syntax]: impossible to type a dynamic require. - const assetPluginFunction: AssetDataPlugin = require(currentAssetPlugin); + const mod = require(currentAssetPlugin); + const assetPluginFunction: AssetDataPlugin = + mod.__esModule === true && 'default' in mod ? mod.default : mod; + const resultAssetData = await assetPluginFunction(assetData); return await applyAssetDataPlugins(remainingAssetPlugins, resultAssetData); } From 551e87fc57d80ed146dbc652f44dc3945c0b86d7 Mon Sep 17 00:00:00 2001 From: Calix Tang Date: Mon, 11 May 2026 13:13:41 -0700 Subject: [PATCH 02/45] Bump @babel/plugin-transform-modules-systemjs to fix CVE-2026-44728 (#1712) Summary: Pull Request resolved: https://github.com/facebook/metro/pull/1712 babel/plugin-transform-modules-systemjs versions >= 7.12.0 and <= 7.29.3 are affected by CVE-2026-44728 (GHSA-fv7c-fp4j-7gwp), a HIGH severity vulnerability. The metro website resolves this package at 7.25.9 via babel/preset-env from Docusaurus. This bumps the package to ^7.29.4, the first patched version. Reviewed By: robhogan Differential Revision: D104687111 fbshipit-source-id: 19ec44324488a79fb078c2939f8c05573c0c8695 (cherry picked from commit 25bad997bb346e4fd9364f89558738b8588b5e10) --- website/yarn.lock | 129 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 122 insertions(+), 7 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index b81cd63697..c381d424d6 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -167,6 +167,15 @@ js-tokens "^4.0.0" picocolors "^1.0.0" +"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": + version "7.29.0" + resolved "https://registry.facebook.net/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.26.5", "@babel/compat-data@^7.26.8": version "7.26.8" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.8.tgz#821c1d35641c355284d4a870b8a4a7b0c141e367" @@ -204,6 +213,17 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^3.0.2" +"@babel/generator@^7.29.0": + version "7.29.1" + resolved "https://registry.facebook.net/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50" + integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== + dependencies: + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz#d8eac4d2dc0d7b6e11fa6e535332e0d3184f06b4" @@ -255,6 +275,11 @@ lodash.debounce "^4.0.8" resolve "^1.14.2" +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.facebook.net/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + "@babel/helper-member-expression-to-functions@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz#9dfffe46f727005a5ea29051ac835fb735e4c1a3" @@ -271,6 +296,14 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" +"@babel/helper-module-imports@^7.28.6": + version "7.28.6" + resolved "https://registry.facebook.net/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c" + integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== + dependencies: + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" + "@babel/helper-module-transforms@^7.25.9", "@babel/helper-module-transforms@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae" @@ -280,6 +313,15 @@ "@babel/helper-validator-identifier" "^7.25.9" "@babel/traverse" "^7.25.9" +"@babel/helper-module-transforms@^7.28.6": + version "7.28.6" + resolved "https://registry.facebook.net/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" + integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== + dependencies: + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.6" + "@babel/helper-optimise-call-expression@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz#3324ae50bae7e2ab3c33f60c9a877b6a0146b54e" @@ -292,6 +334,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz#18580d00c9934117ad719392c4f6585c9333cc35" integrity sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg== +"@babel/helper-plugin-utils@^7.28.6": + version "7.28.6" + resolved "https://registry.facebook.net/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== + "@babel/helper-remap-async-to-generator@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz#e53956ab3d5b9fb88be04b3e2f31b523afd34b92" @@ -323,11 +370,21 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.facebook.net/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + "@babel/helper-validator-identifier@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.facebook.net/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + "@babel/helper-validator-option@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72" @@ -357,6 +414,13 @@ dependencies: "@babel/types" "^7.26.9" +"@babel/parser@^7.28.6", "@babel/parser@^7.29.0": + version "7.29.3" + resolved "https://registry.facebook.net/@babel/parser/-/parser-7.29.3.tgz#116f70a77958307fceac27747573032f8a62f88e" + integrity sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA== + dependencies: + "@babel/types" "^7.29.0" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz#cc2e53ebf0a0340777fff5ed521943e253b4d8fe" @@ -632,14 +696,14 @@ "@babel/helper-plugin-utils" "^7.25.9" "@babel/plugin-transform-modules-systemjs@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz#8bd1b43836269e3d33307151a114bcf3ba6793f8" - integrity sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA== + version "7.29.4" + resolved "https://registry.facebook.net/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz#f621105da99919c15cf4bde6fcc7346ef95e7b20" + integrity sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w== dependencies: - "@babel/helper-module-transforms" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/helper-validator-identifier" "^7.25.9" - "@babel/traverse" "^7.25.9" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.29.0" "@babel/plugin-transform-modules-umd@^7.25.9": version "7.25.9" @@ -1025,6 +1089,15 @@ "@babel/parser" "^7.26.9" "@babel/types" "^7.26.9" +"@babel/template@^7.28.6": + version "7.28.6" + resolved "https://registry.facebook.net/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + "@babel/traverse@^7.25.9", "@babel/traverse@^7.26.5", "@babel/traverse@^7.26.8", "@babel/traverse@^7.26.9": version "7.26.9" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.9.tgz#4398f2394ba66d05d988b2ad13c219a2c857461a" @@ -1038,6 +1111,19 @@ debug "^4.3.1" globals "^11.1.0" +"@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": + version "7.29.0" + resolved "https://registry.facebook.net/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" + integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/types" "^7.29.0" + debug "^4.3.1" + "@babel/types@^7.21.3", "@babel/types@^7.25.9", "@babel/types@^7.26.9", "@babel/types@^7.4.4": version "7.26.9" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.9.tgz#08b43dec79ee8e682c2ac631c010bdcac54a21ce" @@ -1046,6 +1132,14 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" +"@babel/types@^7.28.6", "@babel/types@^7.29.0": + version "7.29.0" + resolved "https://registry.facebook.net/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" + integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + "@braintree/sanitize-url@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783" @@ -1914,6 +2008,14 @@ "@types/yargs" "^17.0.8" chalk "^4.0.0" +"@jridgewell/gen-mapping@^0.3.12": + version "0.3.13" + resolved "https://registry.facebook.net/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + "@jridgewell/gen-mapping@^0.3.5": version "0.3.8" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz#4f0e06362e01362f823d348f1872b08f666d8142" @@ -1946,6 +2048,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== +"@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.facebook.net/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" @@ -1954,6 +2061,14 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.facebook.net/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.5" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" From 51893435ea9fdea990c3c5c277d55a817a6dcd96 Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Tue, 12 May 2026 12:38:21 -0700 Subject: [PATCH 03/45] fix(metro-file-map): Fix Windows cross-drive path handling (#1711) Summary: Related PR for `expo/expo` fork: https://github.com/expo/expo/pull/45648 Supersedes https://github.com/facebook/metro/issues/1696 Cross-device path normals are intended to be relative paths from the rootDir. If the rootDir is at `C:\project` then a cross-device normal path at `D:\temp` is represented as `..\..\D:\temp`. This assumption is broken in several places, like `normalToAbsolute` printing, leading to `C:\D:\` paths that are invalid. Changelog: [Fix] Fix Windows path handling for cross-drive paths Pull Request resolved: https://github.com/facebook/metro/pull/1711 Test Plan: - Unit tests were adjusted to demonstrate all failing cases - **Note:** Invalid unit tests have been removed for paths that rise above the filesystem root (they're assumed to be impossible, and their output doesn't have to be deterministic/correct) Reviewed By: robhogan Differential Revision: D104700077 Pulled By: CalixTang fbshipit-source-id: e77baa4e66495b50ef41b260cec60bcc832d4a6a (cherry picked from commit 6e6b52c4bd31370c0f05b0d4b2e887c350f3831d) --- .../metro-file-map/src/lib/RootPathUtils.js | 20 ++- packages/metro-file-map/src/lib/TreeFS.js | 23 ++- .../src/lib/__tests__/RootPathUtils-test.js | 153 ++++++++++++++---- .../src/lib/__tests__/TreeFS-test.js | 81 ++++++++++ 4 files changed, 238 insertions(+), 39 deletions(-) diff --git a/packages/metro-file-map/src/lib/RootPathUtils.js b/packages/metro-file-map/src/lib/RootPathUtils.js index 5720d60ee9..bb3312108e 100644 --- a/packages/metro-file-map/src/lib/RootPathUtils.js +++ b/packages/metro-file-map/src/lib/RootPathUtils.js @@ -46,6 +46,9 @@ const SEP_UP_FRAGMENT = path.sep + '..'; const UP_FRAGMENT_SEP_LENGTH = UP_FRAGMENT_SEP.length; const CURRENT_FRAGMENT = '.' + path.sep; +const IS_WIN32 = path.sep === '\\'; +const ROOT_BASE_IDX = IS_WIN32 ? 0 : 1; + export class RootPathUtils { #rootDir: string; #rootDirnames: ReadonlyArray; @@ -149,6 +152,12 @@ export class RootPathUtils { const right = pos === 0 ? normalPath : normalPath.slice(pos); if (right.length === 0) { return left; + } else if (IS_WIN32 && pos > this.#rootDepth * UP_FRAGMENT_SEP_LENGTH) { + // On a real file system, navigating to `..` at the top level (posix `/` + // or Windows drive) is a no-op, but we can't respect that on Windows + // because Metro uses e.g. `..\..\D:\foo` to represent cross-drive + // relative paths. + return right; } // left may already end in a path separator only if it is a filesystem root, // '/' or 'X:\'. @@ -198,7 +207,9 @@ export class RootPathUtils { if (relativePath === '') { return {collapsedSegments: 0, normalPath}; } - const left = normalPath + path.sep; + const left = normalPath.endsWith(path.sep) + ? normalPath + : normalPath + path.sep; const rawPath = left + relativePath; if (normalPath === '..' || normalPath.endsWith(SEP_UP_FRAGMENT)) { const collapsed = this.#tryCollapseIndirectionsInSuffix(rawPath, 0, 0); @@ -299,9 +310,10 @@ export class RootPathUtils { }; } - // Cap the number of indirections at the total number of root segments. - // File systems treat '..' at the root as '.'. - if (totalUpIndirections < this.#rootParts.length - 1) { + // Cap the number of indirections at the total number of root parts. + // File systems treat '..' at the root as '.'. For Windows, cross-device + // paths need to survive this. + if (totalUpIndirections < this.#rootParts.length - ROOT_BASE_IDX) { totalUpIndirections++; } diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 7b1691ae2c..2790ce44ae 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -493,6 +493,13 @@ export default class TreeFS implements MutableFileSystem { remove(mixedPath: Path, changeListener?: FileSystemListener): void { const normalPath = this.#normalizePath(mixedPath); + this.#removeNormalPath(normalPath, changeListener); + } + + #removeNormalPath( + normalPath: string, + changeListener?: FileSystemListener, + ): void { const result = this.#lookupByNormalPath(normalPath, {followLeaf: false}); if (!result.exists) { return; @@ -501,7 +508,10 @@ export default class TreeFS implements MutableFileSystem { if (isDirectory(node) && node.size > 0) { for (const basename of node.keys()) { - this.remove(canonicalPath + path.sep + basename, changeListener); + this.#removeNormalPath( + canonicalPath + path.sep + basename, + changeListener, + ); } // Removing the last file will delete this directory return; @@ -521,7 +531,7 @@ export default class TreeFS implements MutableFileSystem { // that's not expected to be a case common enough to justify // implementation complexity, or slowing down more common uses of // _lookupByNormalPath. - this.remove(path.dirname(canonicalPath), changeListener); + this.#removeNormalPath(path.dirname(canonicalPath), changeListener); } } } @@ -1224,16 +1234,19 @@ export default class TreeFS implements MutableFileSystem { typeof literalSymlinkTarget === 'string', 'Expected symlink target to be populated.', ); - const absoluteSymlinkTarget = path.resolve( + let absoluteSymlinkTarget = path.resolve( this.#rootDir, canonicalPathOfSymlink, '..', // Symlink target is relative to its containing directory. literalSymlinkTarget, // May be absolute, in which case the above are ignored ); - const normalSymlinkTarget = path.relative( - this.#rootDir, + if (absoluteSymlinkTarget.endsWith(path.sep)) { + absoluteSymlinkTarget = absoluteSymlinkTarget.slice(0, -1); + } + const normalSymlinkTarget = this.#pathUtils.absoluteToNormal( absoluteSymlinkTarget, ); + const result = { ancestorOfRootIdx: this.#pathUtils.getAncestorOfRootIdx(normalSymlinkTarget), diff --git a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js index 8fb693c498..493f9b3eb4 100644 --- a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js +++ b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js @@ -91,37 +91,59 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => { expect(pathRelative).toHaveBeenCalled(); }); - test.each([ - p('..'), - p('../..'), - p('../../'), - p('normal/path'), - p('normal/path/'), - p('../normal/path'), - p('../normal/path/'), - p('../../normal/path'), - p('../../../normal/path'), - ])(`normalToAbsolute('%s') matches path.resolve`, normalPath => { - let expected = mockPathModule.resolve(rootDir, normalPath); - // Unlike path.resolve, we expect to preserve trailing separators. - if (normalPath.endsWith(sep) && !expected.endsWith(sep)) { - expected += sep; - } - expect(pathUtils.normalToAbsolute(normalPath)).toEqual(expected); - }); + const normalToAbsoluteInputs = + rootDir === p('/project/root') + ? [ + p('..'), + p('../..'), + p('../../'), + p('normal/path'), + p('normal/path/'), + p('../normal/path'), + p('../normal/path/'), + p('../../normal/path'), + // On POSIX, `..` at the root re-enters the root + ...(platform === 'posix' ? [p('../../../normal/path')] : []), + ] + : [ + p('..'), + p('../..'), + p('../../'), + p('normal/path'), + p('normal/path/'), + ]; - test.each([ - p('..'), - p('../root'), - p('../root/path'), - p('../project'), - p('../project/'), - p('../../project/root'), - p('../../project/root/'), - p('../../../normal/path'), - p('../../../normal/path/'), - p('../../..'), - ])( + test.each(normalToAbsoluteInputs)( + `normalToAbsolute('%s') matches path.resolve`, + normalPath => { + let expected = mockPathModule.resolve(rootDir, normalPath); + // Unlike path.resolve, we expect to preserve trailing separators. + if (normalPath.endsWith(sep) && !expected.endsWith(sep)) { + expected += sep; + } + expect(pathUtils.normalToAbsolute(normalPath)).toEqual(expected); + }, + ); + + const relativeToNormalInputs = + rootDir === p('/project/root') + ? [ + p('..'), + p('../root'), + p('../root/path'), + p('../project'), + p('../project/'), + p('../../project/root'), + p('../../project/root/'), + p('../../..'), + // On POSIX, `..` at the root re-enters the root + ...(platform === 'posix' + ? [p('../../../normal/path'), p('../../../normal/path/')] + : []), + ] + : [p('..')]; + + test.each(relativeToNormalInputs)( `relativeToNormal('%s') matches path.resolve + path.relative`, relativePath => { let expected = mockPathModule.relative( @@ -153,4 +175,75 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => { ])('getAncestorOfRootIdx (%s => %s)', (input, expected) => { expect(pathUtils.getAncestorOfRootIdx(input)).toEqual(expected); }); + + if (platform === 'win32') { + describe('cross-drive absolute paths (Windows)', () => { + test.each([['C:\\project\\root'], ['C:\\']])( + 'path.relative returns cross-drive target as-is from rootDir=%s', + rootDir => { + expect(mockPathModule.relative(rootDir, 'D:\\some\\file.js')).toEqual( + 'D:\\some\\file.js', + ); + }, + ); + + test.each([ + [ + 'C:\\project\\root', + 'D:\\some\\file.js', + '..\\..\\..\\D:\\some\\file.js', + ], + ['C:\\project\\root', 'D:\\some\\', '..\\..\\..\\D:\\some\\'], + ['C:\\project\\root', 'D:\\', '..\\..\\..\\D:\\'], + ['C:\\', 'D:\\some\\file.js', '..\\D:\\some\\file.js'], + ['C:\\', 'D:\\', '..\\D:\\'], + ['D:\\project\\root', 'C:\\file.js', '..\\..\\..\\C:\\file.js'], + ])( + 'absoluteToNormal emits a ..-chain (rootDir=%s, X=%s -> %s)', + (rootDir, absolutePath, expectedNormal) => { + pathUtils = new RootPathUtils(rootDir); + expect(pathUtils.absoluteToNormal(absolutePath)).toEqual( + expectedNormal, + ); + }, + ); + + test.each([ + ['C:\\project\\root', 'D:\\some\\file.js'], + ['C:\\project\\root', 'D:\\some\\'], + ['C:\\project\\root', 'D:\\'], + ['C:\\', 'D:\\some\\file.js'], + ['C:\\', 'D:\\some\\'], + ['C:\\', 'D:\\'], + ['D:\\project\\root', 'C:\\file.js'], + ['D:\\project\\root', 'C:\\'], + ])( + 'normalToAbsolute(absoluteToNormal(X)) === X for rootDir=%s, X=%s', + (rootDir, absolutePath) => { + pathUtils = new RootPathUtils(rootDir); + const normal = pathUtils.absoluteToNormal(absolutePath); + expect(pathUtils.normalToAbsolute(normal)).toEqual(absolutePath); + }, + ); + + test.each([ + ['C:\\project\\root', 'D:\\dir\\sub', 'extra\\file.js'], + ['C:\\project\\root', 'D:\\', 'foo.js'], + ['C:\\', 'D:\\dir', 'sub\\file.js'], + ])( + 'joinNormalToRelative round-trips cross-drive (rootDir=%s, base=%s, rel=%s)', + (rootDir, baseAbsolute, relativePath) => { + pathUtils = new RootPathUtils(rootDir); + const baseNormal = pathUtils.absoluteToNormal(baseAbsolute); + const {normalPath} = pathUtils.joinNormalToRelative( + baseNormal, + relativePath, + ); + expect(pathUtils.normalToAbsolute(normalPath)).toEqual( + mockPathModule.join(baseAbsolute, relativePath), + ); + }, + ); + }); + } }); diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index a65eab4293..bc5923cece 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -1235,4 +1235,85 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { }); }); }); + + if (platform === 'win32') { + describe('cross-drive paths (Windows)', () => { + let tfsCD: TreeFSType; + const externalMeta: FileMetadata = [123, 4, 0, null, 0, 'external']; + + beforeEach(() => { + tfsCD = new TreeFS({ + rootDir: 'C:\\project', + files: new Map([ + ['bar.js', [234, 3, 0, null, 0, 'bar']], + ['..\\..\\D:\\external\\file.js', externalMeta], + ]), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + }); + + test('exists() finds a seeded cross-drive file', () => { + expect(tfsCD.exists('D:\\external\\file.js')).toBe(true); + }); + + test('lookup() returns the absolute drive-prefixed path as realPath', () => { + expect(tfsCD.lookup('D:\\external\\file.js')).toMatchObject({ + exists: true, + type: 'f', + realPath: 'D:\\external\\file.js', + }); + }); + + test('getAllFiles() enumerates cross-drive and in-tree files side by side', () => { + expect(tfsCD.getAllFiles().sort()).toEqual([ + 'C:\\project\\bar.js', + 'D:\\external\\file.js', + ]); + }); + + test('addOrModify() accepts a new cross-drive absolute path', () => { + tfsCD.addOrModify('D:\\added\\later.js', [1, 1, 0, null, 0, 'later']); + expect(tfsCD.exists('D:\\added\\later.js')).toBe(true); + expect(tfsCD.lookup('D:\\added\\later.js')).toMatchObject({ + exists: true, + type: 'f', + realPath: 'D:\\added\\later.js', + }); + }); + + test('remove() deletes a cross-drive entry and prunes empty ancestor dirs', () => { + tfsCD.remove('D:\\external\\file.js'); + expect(tfsCD.exists('D:\\external\\file.js')).toBe(false); + expect(tfsCD.lookup('D:\\external').exists).toBe(false); + expect(tfsCD.exists('C:\\project\\bar.js')).toBe(true); + }); + + test('lookup() reports missing for non-existent cross-drive path', () => { + expect(tfsCD.lookup('D:\\external\\missing.js')).toMatchObject({ + exists: false, + }); + expect(tfsCD.exists('E:\\anywhere.js')).toBe(false); + }); + + test('lookup() follows a symlink whose target is a cross-drive path', () => { + const tfsLink = new TreeFS({ + rootDir: 'C:\\project', + files: new Map([ + ['..\\..\\D:\\external\\file.js', externalMeta], + ['link', [0, 0, 0, null, 'D:\\external\\file.js', null]], + ]), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + expect(tfsLink.lookup('C:\\project\\link')).toMatchObject({ + exists: true, + type: 'f', + realPath: 'D:\\external\\file.js', + }); + }); + }); + } }); From cc430fef20087cad575b11300566c43ef4beab31 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Fri, 29 May 2026 08:02:44 -0700 Subject: [PATCH 04/45] Resolver tests: Refactor to test observable "browser"-spec redirection rather than calls to redirectModulePath Summary: Currently, `metro-resolver` tests assert on and mock calls to `redirectModulePath`, which since [5e96d176e3](https://github.com/facebook/metro/commit/5e96d176e3c14f1b56c8a75807b8901aa7eabf7d) (v0.81.0) is a fixed implementation detail, rather than a user-configurable context method. Refactor tests to assert on observable behaviour by creating mock `package.json`s, which makes these tests more robust to upcoming internal optimisations. Changelog: [Internal] ___ Differential Revision: D102514698 fbshipit-source-id: 05c47b37ea07d2d42128b20c4d15148a8f35a634 (cherry picked from commit 922ee8b8d64cb446de91449e94eddfaefa0a44bb) --- .../src/__tests__/index-test.js | 220 +++++++++--------- 1 file changed, 104 insertions(+), 116 deletions(-) diff --git a/packages/metro-resolver/src/__tests__/index-test.js b/packages/metro-resolver/src/__tests__/index-test.js index 24c07a33c7..d80a5248c0 100644 --- a/packages/metro-resolver/src/__tests__/index-test.js +++ b/packages/metro-resolver/src/__tests__/index-test.js @@ -15,7 +15,7 @@ import type {ResolutionContext} from '../index'; import {createResolutionContext} from './utils'; -let Resolver = require('../index'); +const Resolver = require('../index'); const fileMap = { '/root/project/foo.js': '', @@ -396,67 +396,84 @@ test('throws a descriptive error when a file inside a Haste package cannot be re `); }); -describe('redirectModulePath', () => { - const mockRedirectModulePath = jest.fn(); - const context = CONTEXT; - - beforeEach(() => { - mockRedirectModulePath.mockReset(); - mockRedirectModulePath.mockImplementation(filePath => false); - - jest.resetModules(); - jest.mock('../PackageResolve', () => { - return { - ...jest.requireActual('../PackageResolve'), - redirectModulePath: (_ctx, specifier) => - mockRedirectModulePath(specifier), - }; - }); - - Resolver = require('../index'); - }); - - afterEach(() => { - jest.unmock('../PackageResolve'); - jest.resetModules(); - Resolver = require('../index'); - }); - +describe('browser spec redirection', () => { test('is used for relative path requests', () => { + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + name: 'project', + browser: { + './bar': false, + }, + }), + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/foo.js', + }; expect(Resolver.resolve(context, './bar', null)).toMatchInlineSnapshot(` Object { "type": "empty", } `); - expect(mockRedirectModulePath).toBeCalledTimes(1); - expect(mockRedirectModulePath).toBeCalledWith('/root/project/bar'); }); test('is used for absolute path requests', () => { - expect(Resolver.resolve(context, '/bar', null)).toMatchInlineSnapshot(` + // browser field redirects the specifier to false + const testFileMap = { + '/root/bar.js': '', + '/root/package.json': JSON.stringify({ + browser: { + './bar': false, + }, + }), + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/other/project/foo.js', + }; + expect(Resolver.resolve(context, '/root/bar', null)).toMatchInlineSnapshot(` Object { "type": "empty", } `); - expect(mockRedirectModulePath).toBeCalledTimes(1); - expect(mockRedirectModulePath).toBeCalledWith('/bar'); }); test('is used for non-Haste package requests', () => { + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + name: 'project', + browser: { + 'does-not-exist': false, + }, + }), + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/foo.js', + }; expect(Resolver.resolve(context, 'does-not-exist', null)) .toMatchInlineSnapshot(` Object { "type": "empty", } `); - expect(mockRedirectModulePath).toBeCalledTimes(1); - expect(mockRedirectModulePath).toBeCalledWith('does-not-exist'); }); test('can redirect to an arbitrary relative module', () => { - mockRedirectModulePath - .mockImplementationOnce(filePath => '../smth/beep') - .mockImplementation(filePath => filePath); + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + browser: { + 'does-not-exist': '../smth/beep', + }, + }), + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/foo.js', + }; expect(Resolver.resolve(context, 'does-not-exist', null)) .toMatchInlineSnapshot(` Object { @@ -464,97 +481,52 @@ describe('redirectModulePath', () => { "type": "sourceFile", } `); - expect(mockRedirectModulePath.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "does-not-exist", - ], - Array [ - "/root/smth/beep", - ], - Array [ - "/root/smth/beep.js", - ], - ] - `); }); - test("is called for source extension candidates that don't exist on disk", () => { - mockRedirectModulePath.mockImplementation(filePath => - filePath.replace('.another-fake-ext', '.js'), - ); - expect( - Resolver.resolve( - {...context, sourceExts: ['fake-ext', 'another-fake-ext']}, - '../smth/beep', - null, - ), - ).toMatchInlineSnapshot(` + test('resolves source extension candidates to relative paths', () => { + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + name: 'project', + browser: { + './beep.another-fake-ext': '../smth/beep.js', + }, + }), + '/root/project/beep.js': '', + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/foo.js', + sourceExts: ['fake-ext', 'another-fake-ext'], + }; + expect(Resolver.resolve(context, './beep', null)).toMatchInlineSnapshot(` Object { "filePath": "/root/smth/beep.js", "type": "sourceFile", } `); - expect(mockRedirectModulePath.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/root/smth/beep", - ], - Array [ - "/root/smth/beep.fake-ext", - ], - Array [ - "/root/smth/beep.another-fake-ext", - ], - ] - `); }); test('can resolve to empty from a candidate with an added source extension', () => { - mockRedirectModulePath.mockImplementation(filePath => - filePath.endsWith('.fake-ext') ? false : filePath, - ); - expect( - Resolver.resolve( - {...context, sourceExts: ['fake-ext', 'js']}, - '../smth/beep', - null, - ), - ).toMatchInlineSnapshot(` + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + name: 'project', + browser: { + './beep.fake-ext': false, + }, + }), + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/foo.js', + sourceExts: ['fake-ext', 'js'], + }; + expect(Resolver.resolve(context, './beep', null)).toMatchInlineSnapshot(` Object { "type": "empty", } `); - expect(mockRedirectModulePath.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/root/smth/beep", - ], - Array [ - "/root/smth/beep.fake-ext", - ], - ] - `); - }); - - test('is not called redundantly for a candidate that does exist on disk', () => { - mockRedirectModulePath.mockImplementation(filePath => filePath); - expect(Resolver.resolve(context, './bar', null)).toMatchInlineSnapshot(` - Object { - "filePath": "/root/project/bar.js", - "type": "sourceFile", - } - `); - expect(mockRedirectModulePath.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/root/project/bar", - ], - Array [ - "/root/project/bar.js", - ], - ] - `); }); }); @@ -643,9 +615,17 @@ describe('resolveRequest', () => { }); test('is called with the platform and non-redirected module path', () => { + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + browser: { + 'does-not-exist': './redirected', + }, + }), + }; const contextWithRedirect = { ...context, - redirectModulePath: (filePath: string) => filePath + '.redirected', + ...createResolutionContext(testFileMap), }; expect(Resolver.resolve(contextWithRedirect, 'does-not-exist', 'android')) .toMatchInlineSnapshot(` @@ -666,9 +646,17 @@ describe('resolveRequest', () => { type: 'sourceFile', filePath: '/some/fake/path', })); + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + browser: { + 'does-not-exist': false, + }, + }), + }; const contextWithRedirect = { ...context, - redirectModulePath: (filePath: string) => false as const, + ...createResolutionContext(testFileMap), }; expect(Resolver.resolve(contextWithRedirect, 'does-not-exist', 'android')) .toMatchInlineSnapshot(` From b1471287ac50e85e99ad6ce1ffe056b088a9acd6 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 1 Jun 2026 05:45:42 -0700 Subject: [PATCH 05/45] Resolver: Refactor to avoid redundant categorisation of specifier, lift closest package scope to root resolver for reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: On the bare-specifier hot path, replace the top-level `redirectModulePath(context, specifier)` wrapper call with a direct `getPackageForModule(originModulePath)` + `matchSubpathFromMainFields(...)`. Perf: `resolve()` has already excluded relative/absolute specifiers by this point, so the `redirectModulePath` wrapper was re-deriving facts the caller already knew — an extra call frame, a redundant `path.isAbsolute()` and `.startsWith('.')`, and an unconditional `isRelativeImport()/path.isAbsolute()` on the result that now only runs on the rare actual-redirect case. The two genuinely costly operations (the closest-package lookup and the main-fields match) are unchanged, so this is a small saving — ~1.5% resolver-only CPU (see test plan). More importantly, this surfaces the closest `package.json` to the origin (`closestPackageToOrigin`) in the root resolver, where it can be reused by follow-up diffs rather than being re-derived inside `redirectModulePath`. In particular, we'll reuse closest package scope to implement `PACKAGE_SELF_RESOLVE` at negligible cost. Changelog: Internal Reviewed By: GijsWeterings, huntie Differential Revision: D102515190 fbshipit-source-id: acc962617e38e1c56b85196ed71a5b010abe2c17 (cherry picked from commit 752958584abd8b501f9881f55d982f57b58e6d04) --- packages/metro-resolver/src/PackageResolve.js | 2 +- packages/metro-resolver/src/resolve.js | 63 ++++++++++++------- .../metro-resolver/types/PackageResolve.d.ts | 19 +++++- 3 files changed, 60 insertions(+), 24 deletions(-) diff --git a/packages/metro-resolver/src/PackageResolve.js b/packages/metro-resolver/src/PackageResolve.js index f12c17a8da..ff701d9451 100644 --- a/packages/metro-resolver/src/PackageResolve.js +++ b/packages/metro-resolver/src/PackageResolve.js @@ -164,7 +164,7 @@ export function redirectModulePath( * - `false`, indicating the module should be ignored. * - `null` when there is no entry for the subpath. */ -function matchSubpathFromMainFields( +export function matchSubpathFromMainFields( /** * The subpath, or set of subpath variants, to match. Can be either a * package-relative subpath (beginning with '.') or a bare import specifier diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index ebde8adf27..f6a66e54c7 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -26,7 +26,11 @@ import PackageImportNotResolvedError from './errors/PackageImportNotResolvedErro import PackagePathNotExportedError from './errors/PackagePathNotExportedError'; import {resolvePackageTargetFromExports} from './PackageExportsResolve'; import {resolvePackageTargetFromImports} from './PackageImportsResolve'; -import {getPackageEntryPoint, redirectModulePath} from './PackageResolve'; +import { + getPackageEntryPoint, + matchSubpathFromMainFields, + redirectModulePath, +} from './PackageResolve'; import resolveAsset from './resolveAsset'; import isAssetFile from './utils/isAssetFile'; import {posixToSystemPath} from './utils/paths'; @@ -110,28 +114,47 @@ export default function resolve( } } - const redirectedSpecifier = redirectModulePath(context, specifier); + const {originModulePath} = context; + + const closestPackageToOrigin = context.getPackageForModule(originModulePath); + + const maybeRedirectedSpecifier = + closestPackageToOrigin != null + ? matchSubpathFromMainFields( + specifier, + closestPackageToOrigin.packageJson, + context.mainFields, + ) + : null; // exclude - if (redirectedSpecifier === false) { + if (maybeRedirectedSpecifier === false) { return {type: 'empty'}; } - const {originModulePath} = context; - - const isDirectImport = - isRelativeImport(redirectedSpecifier) || - path.isAbsolute(redirectedSpecifier); + // If the specifier was redirected to a relative or absolute path + if ( + maybeRedirectedSpecifier != null && + (isRelativeImport(maybeRedirectedSpecifier) || + path.isAbsolute(maybeRedirectedSpecifier)) + ) { + // TODO: (robhogan) This isn't right - per browser spec: "All paths for + // browser fields are relative to the package.json file location". The + // *closest* package.json is the relevant one for browser spec, regardless + // of the closest node_modules. - if (isDirectImport) { - // derive absolute path /.../node_modules/originModuleDir/redirectedSpecifier + // derive absolute path /.../node_modules/originModuleDir/specifier const fromModuleParentIdx = originModulePath.lastIndexOf('node_modules' + path.sep) + 13; const originModuleDir = originModulePath.slice( 0, originModulePath.indexOf(path.sep, fromModuleParentIdx), ); - const absPath = path.join(originModuleDir, redirectedSpecifier); + + // TODO: (robhogan) If maybeRedirectedSpecifier is absolute, this join is + // wrong. We should disallow attempts to redirect to an absolute path, as + // that's not part of the "browser" field spec anyway, and is broken here. + const absPath = path.join(originModuleDir, maybeRedirectedSpecifier); const result = resolveModulePath(context, absPath, platform); if (result.type === 'failed') { throw new FailedToResolvePathError(result.candidates); @@ -139,13 +162,13 @@ export default function resolve( return result.resolution; } - /** - * At this point, redirectedSpecifier is not a "direct" (absolute or relative) - * import, so it's a bare specifier - for our purposes either Haste name - * or a package specifier. - */ - - const parsedSpecifier = parseBareSpecifier(redirectedSpecifier); + // At this point, maybeRedirectedSpecifier is either null (not redirected), + // and `specifier` is still bare, or `maybeRedirectedSpecifier` is a + // redirected bare specifier. Supported bare specifiers are either Haste + // names, package names, or (rarely) non-package modules under node_modules. + const parsedSpecifier = parseBareSpecifier( + maybeRedirectedSpecifier ?? specifier, + ); if (context.allowHaste) { if (parsedSpecifier.isSinglePart) { @@ -162,9 +185,7 @@ export default function resolve( } } - /** - * redirectedSpecifier is now a package specifier. - */ + // parsedSpecifier is now a non-Haste bare specifier. const {disableHierarchicalLookup} = context; diff --git a/packages/metro-resolver/types/PackageResolve.d.ts b/packages/metro-resolver/types/PackageResolve.d.ts index 00327004b0..54a4a20ca7 100644 --- a/packages/metro-resolver/types/PackageResolve.d.ts +++ b/packages/metro-resolver/types/PackageResolve.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<7e880beb8b73f4a072dfd248ff41d7b0>> + * @generated SignedSource<<20b836efd67d20258d429521857d8eab>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-resolver/src/PackageResolve.js @@ -15,7 +15,7 @@ * yarn run build-ts-defs (OSS) */ -import type {PackageInfo, ResolutionContext} from './types'; +import type {PackageInfo, PackageJson, ResolutionContext} from './types'; /** * Resolve the main entry point subpath for a package. * @@ -45,3 +45,18 @@ export declare function redirectModulePath( }>, modulePath: string, ): string | false; +/** + * Get the mapped replacement for the given subpath defined by matching + * `mainFields` entries in the passed `package.json` + * (https://github.com/defunctzombie/package-browser-field-spec#replace-specific-files---advanced). + * + * Returns either: + * - A `string` with the matched replacement subpath. + * - `false`, indicating the module should be ignored. + * - `null` when there is no entry for the subpath. + */ +export declare function matchSubpathFromMainFields( + subpath: string | ReadonlyArray, + pkg: PackageJson, + mainFields: ReadonlyArray, +): string | false | null; From a14f0cf61cfced62fc9890654dbf5a12ab61e165 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 1 Jun 2026 08:09:42 -0700 Subject: [PATCH 06/45] Resolver: Avoid intermediate allocations in matchSubpathFromMainFields, improve overall resolver perf ~5% Summary: `matchSubpathFromMainFields` is called on every resolution attempt (per source-extension via `redirectModulePath`, and per package entry point), and on every call it allocated a `.map()` array, a `.filter()` array, and an `Object.assign({}, ...spread)` - even in the overwhelmingly common case where the package declares no object-valued main field (no "browser" map) and the function returns `null`. Instead, rebuild the merged replacement map with a single reverse loop over `mainFields` (preserving the prior "earlier mainFields win on key conflict" semantics) such that nothing is allocated (`null` is still returned) unless and until an object-valued field is actually found. The list of subpath variants is likewise only built in that rare matched case. This improves the perf of `matchSubpathFromMainFields` by ~20% and ~5% on resolutions overall, based on Meta's product graph. ``` - **[Performance]**: Refactor to reduce allocs for a ~5% faster resolver Reviewed By: huntie Differential Revision: D107024445 fbshipit-source-id: cec2261dc588590eb12bb413a7bbb6fc6869401c (cherry picked from commit 9341463424ef7753d2d17ef0d6943f3610a4e23a) --- packages/metro-resolver/src/PackageResolve.js | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/metro-resolver/src/PackageResolve.js b/packages/metro-resolver/src/PackageResolve.js index ff701d9451..fb85bffaed 100644 --- a/packages/metro-resolver/src/PackageResolve.js +++ b/packages/metro-resolver/src/PackageResolve.js @@ -174,24 +174,36 @@ export function matchSubpathFromMainFields( pkg: PackageJson, mainFields: ReadonlyArray, ): string | false | null { - const fieldValues = mainFields + // Merge object-valued main fields ("browser"-style maps) into a single + // replacement map. We iterate `mainFields` in reverse so that, on a key + // conflict, earlier `mainFields` win, equivalent to + // `Object.assign({}, ...fieldValues.reverse())`, but avoiding any allocation + // in the the most common case (no object-valued field, e.g. only a string + // "main"/"browser"). + let replacements: {[string]: string | false} | null = null; + for (let i = mainFields.length - 1; i >= 0; i--) { // $FlowFixMe[invalid-computed-prop] - .map(name => pkg[name]) - .filter(value => value != null && typeof value !== 'string'); + const value = pkg[mainFields[i]]; + if (value != null && typeof value !== 'string') { + if (replacements == null) { + replacements = {}; + } + replacements = {...replacements, ...value}; + } + } - if (!fieldValues.length) { + if (replacements == null) { return null; } - // $FlowFixMe[unsafe-object-assign] - const replacements = Object.assign({}, ...fieldValues.reverse()); + // The list of subpath variants is only built in this rare matched case (a + // single subpath is expanded to its "browser"-spec variants; a pre-expanded + // array is matched as-is). const variants = Array.isArray(subpath) ? subpath : expandSubpathVariants(subpath); - for (const variant of variants) { const replacement = replacements[variant]; - if (replacement != null) { return replacement; } From e1d2de4cae6a41ed974da9717d26f3593f23aba1 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 1 Jun 2026 09:46:34 -0700 Subject: [PATCH 07/45] Resolver: Gracefully reject illegal browser-spec redirects of bare specifier -> absolute path Summary: While doing some refactoring here I noticed some nonsensical handling of the case where a browser-spec `package.json` tries to redirect a bare specifier to an absolute path, eg: ``` // /project/package.json { "browser": { "foo-pkg": "/var/foo" } } ``` This currently causes the resolver to redirect to `/project/var/foo` - *joining* the absolute path onto the end of the origin-relative path. This is surely never the desired behaviour, and is outside the [`browser` field spec](https://github.com/defunctzombie/package-browser-field-spec#replace-specific-files---advanced). Rather than "fix" this and support something new in an increasingly outdated spec (`browser` redirects have been effectively replaced by `exports`/`imports` maps), here we just replace what was broken with a graceful, actionable rejection. Although this changes observable resolver behaviour, I think it's safe to treat this as non-breaking because the previous behaviour was so unexpected. ``` - **[Fix]**: Resolver: Gracefully reject bare->absolute redirects rather than failing on a spurious path ``` Reviewed By: huntie Differential Revision: D102568988 fbshipit-source-id: 5bf8d6c3f5a26e0b3ea2fd3f49a3dd9fa4113663 (cherry picked from commit e1f4d61842a99cd3f8cde0e8cfd135210d6fec3d) --- .../src/__tests__/index-test.js | 25 +++++++++++++++++++ packages/metro-resolver/src/resolve.js | 24 +++++++++++++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/metro-resolver/src/__tests__/index-test.js b/packages/metro-resolver/src/__tests__/index-test.js index d80a5248c0..bfe6f8876c 100644 --- a/packages/metro-resolver/src/__tests__/index-test.js +++ b/packages/metro-resolver/src/__tests__/index-test.js @@ -483,6 +483,31 @@ describe('browser spec redirection', () => { `); }); + test('rejects redirection of a bare specifier to an absolute path', () => { + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + name: 'project', + browser: { + 'foo-pkg': '/otherroot/foo', + }, + }), + '/otherroot/foo.js': '', + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/bar.js', + }; + expect(() => Resolver.resolve(context, 'foo-pkg', null)) + .toThrowErrorMatchingInlineSnapshot(` +"The package /root/project contains an invalid package.json configuration. Consider raising this issue with the package maintainer(s). +Reason: Attempted to redirect import to an absolute path. This is not allowed by the \\"browser\\" spec. + From: /root/project/bar.js + Import: foo-pkg + Attempted redirect: /otherroot/foo" +`); + }); + test('resolves source extension candidates to relative paths', () => { const testFileMap = { ...fileMap, diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index f6a66e54c7..55186ce650 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -132,11 +132,10 @@ export default function resolve( return {type: 'empty'}; } - // If the specifier was redirected to a relative or absolute path + // If the specifier was redirected to a relative path if ( maybeRedirectedSpecifier != null && - (isRelativeImport(maybeRedirectedSpecifier) || - path.isAbsolute(maybeRedirectedSpecifier)) + isRelativeImport(maybeRedirectedSpecifier) ) { // TODO: (robhogan) This isn't right - per browser spec: "All paths for // browser fields are relative to the package.json file location". The @@ -151,15 +150,28 @@ export default function resolve( originModulePath.indexOf(path.sep, fromModuleParentIdx), ); - // TODO: (robhogan) If maybeRedirectedSpecifier is absolute, this join is - // wrong. We should disallow attempts to redirect to an absolute path, as - // that's not part of the "browser" field spec anyway, and is broken here. const absPath = path.join(originModuleDir, maybeRedirectedSpecifier); const result = resolveModulePath(context, absPath, platform); if (result.type === 'failed') { throw new FailedToResolvePathError(result.candidates); } return result.resolution; + } else if ( + maybeRedirectedSpecifier != null && + closestPackageToOrigin != null && // Implied by maybeRedirectedSpecifier != null + // An absolute path inside the file may not match the system's path separator + (path.posix.isAbsolute(maybeRedirectedSpecifier) || + path.win32.isAbsolute(maybeRedirectedSpecifier)) + ) { + throw new InvalidPackageConfigurationError({ + packagePath: closestPackageToOrigin.rootPath, + reason: + 'Attempted to redirect import to an absolute path. ' + + 'This is not allowed by the "browser" spec.' + + `\n From: ${originModulePath}` + + `\n Import: ${specifier}` + + `\n Attempted redirect: ${maybeRedirectedSpecifier}`, + }); } // At this point, maybeRedirectedSpecifier is either null (not redirected), From 5eefeed042d547ae3b408e5fc840835566ce490a Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Tue, 2 Jun 2026 07:23:55 -0700 Subject: [PATCH 08/45] Resolver: Fix browser-spec bare-specifier->relative path redirect for nested/first-party package.jsons (#1720) Summary: Pull Request resolved: https://github.com/facebook/metro/pull/1720 The [browser spec](https://github.com/defunctzombie/package-browser-field-spec) redirect for bare specifiers (`"browser": {"foo-pkg": "./shims/foo.js"}`) was previously rebased against a path derived by slicing `originModulePath` after the last `node_modules/` segment. That happens to be correct only in the (common) case that the closest `package.json` is the one at the root of a containing `node_modules` package. However: - It's legal for an NPM package to contain nested `package.json` files and those may contain redirects - in which case their targets are relative to themselves, not to the package root `package.json`. - For origin modules that aren't located inside a `node_modules` tree (e.g. project-local source), `lastIndexOf('node_modules/')` returns `-1`, producing a malformed root path and either failed resolutions or surprising successes. This diff replaces that derivation with `closestPackageToOrigin.rootPath`, which we already look up at the top of `resolve`. Behaviour is identical in the already-working cases, and corrected otherwise. Matches webpack's `AliasFieldPlugin`, which rebases against `request.descriptionFileRoot`. Changelog: ``` - **[Fix]**: Fix browser-spec redirects of bare specifier to relative path, where `package.json` is not directly under `node_modules//` ```` Reviewed By: huntie Differential Revision: D105485945 fbshipit-source-id: 0b0b0b985294024d64305a15720ecc9007658d16 (cherry picked from commit 385a94667b184430bd9a743c4cef953a3ebae3ca) --- .../src/__tests__/browser-spec-test.js | 57 +++++++++++++++++++ packages/metro-resolver/src/resolve.js | 20 +++---- 2 files changed, 64 insertions(+), 13 deletions(-) diff --git a/packages/metro-resolver/src/__tests__/browser-spec-test.js b/packages/metro-resolver/src/__tests__/browser-spec-test.js index c3122002ee..9b4f326008 100644 --- a/packages/metro-resolver/src/__tests__/browser-spec-test.js +++ b/packages/metro-resolver/src/__tests__/browser-spec-test.js @@ -99,4 +99,61 @@ describe('browser field spec', () => { }); }); }); + + describe('replace specific files', () => { + test('should resolve a bare-specifier redirect relative to the origin package root, not its containing directory', () => { + // Per the browser spec, paths in the `browser` map are relative to the + // package.json file location. When the origin module lives in a + // subdirectory of its package (here `lib/nested/`), the redirect target + // must still resolve against the package root. + const packageJson = { + name: 'origin-pkg', + main: 'lib/nested/index.js', + browser: { + 'foo-pkg': './shims/foo.js', + }, + }; + const context = { + ...createResolutionContext({ + '/root/node_modules/origin-pkg/package.json': + JSON.stringify(packageJson), + '/root/node_modules/origin-pkg/lib/nested/index.js': '', + '/root/node_modules/origin-pkg/shims/foo.js': '', + }), + originModulePath: '/root/node_modules/origin-pkg/lib/nested/index.js', + mainFields: ['browser', 'main'], + }; + + expect(Resolver.resolve(context, 'foo-pkg', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/origin-pkg/shims/foo.js', + }); + }); + + test('should resolve a bare-specifier redirect for an origin outside of `node_modules`', () => { + // Project-level package.json — there is no enclosing `node_modules` + // segment, so the old heuristic of slicing after `node_modules/` would + // misbehave. The redirect must resolve against the package root. + const context = { + ...createResolutionContext({ + '/root/project/package.json': JSON.stringify({ + name: 'project', + main: 'src/index.js', + browser: { + 'foo-pkg': './shims/foo.js', + }, + }), + '/root/project/src/index.js': '', + '/root/project/shims/foo.js': '', + }), + originModulePath: '/root/project/src/index.js', + mainFields: ['browser', 'main'], + }; + + expect(Resolver.resolve(context, 'foo-pkg', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/project/shims/foo.js', + }); + }); + }); }); diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 55186ce650..51b9543f44 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -135,22 +135,16 @@ export default function resolve( // If the specifier was redirected to a relative path if ( maybeRedirectedSpecifier != null && + closestPackageToOrigin != null && // Implied by maybeRedirectedSpecifier != null isRelativeImport(maybeRedirectedSpecifier) ) { - // TODO: (robhogan) This isn't right - per browser spec: "All paths for - // browser fields are relative to the package.json file location". The - // *closest* package.json is the relevant one for browser spec, regardless - // of the closest node_modules. - - // derive absolute path /.../node_modules/originModuleDir/specifier - const fromModuleParentIdx = - originModulePath.lastIndexOf('node_modules' + path.sep) + 13; - const originModuleDir = originModulePath.slice( - 0, - originModulePath.indexOf(path.sep, fromModuleParentIdx), + // Per the "browser" spec: "All paths for browser fields are relative to + // the package.json file location". `closestPackageToOrigin` is the package + // that provided the redirect, so join relative paths to its `rootPath`. + const absPath = path.resolve( + closestPackageToOrigin.rootPath, + maybeRedirectedSpecifier, ); - - const absPath = path.join(originModuleDir, maybeRedirectedSpecifier); const result = resolveModulePath(context, absPath, platform); if (result.type === 'failed') { throw new FailedToResolvePathError(result.candidates); From 7c64a9bfc1d5bd0b311b345ab17627ad9c3a461c Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Tue, 2 Jun 2026 12:11:33 -0700 Subject: [PATCH 09/45] Resolver: Implement PACKAGE_SELF_RESOLVE for self-referencing imports (#1721) Summary: Pull Request resolved: https://github.com/facebook/metro/pull/1721 Add support to Metro's resolver for [self-referencing](https://nodejs.org/api/packages.html#self-referencing-a-package-using-its-name). A package can import itself by name, resolving via its own `exports` field. Per the [Node ESM resolution algorithm](https://nodejs.org/api/esm.html#resolution-algorithm-specification) (6.5 `PACKAGE_SELF_RESOLVE`), if the origin module lives in a package whose `name` matches the bare specifier and which declares `exports`, the request is routed through `PACKAGE_EXPORTS_RESOLVE` against that package, in preference to hierarchical `node_modules` lookup. (In particular, this allows `react-native`'s own source to `import {Platform} from 'react-native'`, which *may* be the approach we take to fix broken platform inlining) Gated behind the existing `unstable_enablePackageExports` flag (enabled by default). When on, the resolver attempts self-resolve before hierarchical lookup; on `PackagePathNotExportedError` it follows Metro's existing nonstrict policy of warning and falling through to legacy resolution, consistent with how non-self bare specifiers behave today. This nonstrict fallback will be removed in future. Matches webpack's `SelfReferencePlugin` and Node's own behaviour. ``` - **[Feature]**: Resolver: support self-referencing packages ([`PACKAGE_SELF_RESOLVE`](https://nodejs.org/api/esm.html#resolution-algorithm-specification)) ``` Reviewed By: huntie, motiz88 Differential Revision: D105485943 fbshipit-source-id: 257090425208ef418d9b8c9cc3968d9d1df839cf (cherry picked from commit 06f1de1fc6fe9d5cd8de7383571e0df305f32785) --- docs/Resolution.md | 23 ++++- .../src/__tests__/package-exports-test.js | 93 +++++++++++++++++++ packages/metro-resolver/src/resolve.js | 50 ++++++++++ 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/docs/Resolution.md b/docs/Resolution.md index 9572af498c..a64ef796f5 100644 --- a/docs/Resolution.md +++ b/docs/Resolution.md @@ -75,18 +75,21 @@ Parameters: (*context*, *moduleName*, *platform*) 2. If resolved as a Haste package path, then 1. Perform the algorithm for resolving a path (step 2 above). Throw an error if this resolution fails. For example, if the Haste package path for `'a/b'` is `foo/package.json`, perform step 2 as if _moduleName_ was `foo/c`. -6. If [`context.disableHierarchicalLookup`](#disableHierarchicalLookup-boolean) is not `true`, then +6. If [`context.enablePackageExports`](#enablepackageexports-boolean) is enabled, then + 1. Get the result of [**PACKAGE_SELF_RESOLVE**](#package_self_resolve)(*context*, *moduleName*, *platform*). + 2. If resolved, return result. +7. If [`context.disableHierarchicalLookup`](#disableHierarchicalLookup-boolean) is not `true`, then 1. Try resolving _moduleName_ under `node_modules` from the current directory (i.e. parent of [`context.originModulePath`](#originmodulepath-string)) up to the root directory. 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*) for each candidate path. -7. For each element _nodeModulesPath_ of [`context.nodeModulesPaths`](#nodemodulespaths-readonlyarraystring): +8. For each element _nodeModulesPath_ of [`context.nodeModulesPaths`](#nodemodulespaths-readonlyarraystring): 1. Try resolving _moduleName_ under _nodeModulesPath_ as if the latter was another `node_modules` directory (similar to step 5 above). 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*) for each candidate path. -8. If [`context.extraNodeModules`](#extranodemodules-string-string) is set: +9. If [`context.extraNodeModules`](#extranodemodules-string-string) is set: 1. Split _moduleName_ into a package name (including an optional [scope](https://docs.npmjs.com/cli/v8/using-npm/scope)) and relative path. 2. Look up the package name in [`context.extraNodeModules`](#extranodemodules-string-string). If found, then 1. Construct a path _modulePath_ by replacing the package name part of _moduleName_ with the value found in [`context.extraNodeModules`](#extranodemodules-string-string) 2. Return the result of [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*). -9. If no valid resolution has been found, throw a resolution failure error. +10. If no valid resolution has been found, throw a resolution failure error. #### RESOLVE_MODULE @@ -110,6 +113,18 @@ Parameters: (*context*, *moduleName*, *platform*) 2. Else, log either a package configuration or package encapsulation warning. 2. Return the result of [**RESOLVE_MODULE**](#resolve_module)(*context*, *filePath*, *platform*). +#### PACKAGE_SELF_RESOLVE + +Parameters: (*context*, *moduleName*, *platform*) + +1. Find the closest package to [`context.originModulePath`](#originmodulepath-string). If no package is found, return no resolution. +2. If the package does not declare both a `"name"` field and an `"exports"` field, return no resolution. +3. Split _moduleName_ into a package name (including an optional [scope](https://docs.npmjs.com/cli/v8/using-npm/scope)) and relative path. +4. If the package name does not match the current package's `"name"` field, return no resolution. +5. Get the result of [**RESOLVE_PACKAGE_EXPORTS**](#resolve_package-exports)(*context*, *packagePath*, *filePath*, *exportsField*, *platform*) against the current package's `"exports"` field. + 1. If resolved path exists, return result. + 2. Else, log either a package configuration or package encapsulation warning, then continue with normal resolution. + #### RESOLVE_PACKAGE_EXPORTS Parameters: (*context*, *packagePath*, *filePath*, *exportsField*, *platform*) diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index a3df8b864f..3d63e835c7 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -1210,4 +1210,97 @@ describe('with package exports resolution enabled', () => { }); }); }); + + describe('self-referencing imports (PACKAGE_SELF_RESOLVE)', () => { + const packageJson = { + name: 'self-pkg', + main: 'index-main.js', + exports: { + '.': './index.js', + './sub': './lib/sub.js', + }, + }; + const baseContext = { + ...createResolutionContext({ + '/root/node_modules/self-pkg/package.json': JSON.stringify(packageJson), + '/root/node_modules/self-pkg/index.js': '', + '/root/node_modules/self-pkg/index-main.js': '', + '/root/node_modules/self-pkg/lib/sub.js': '', + '/root/node_modules/self-pkg/lib/internal.js': '', + }), + originModulePath: '/root/node_modules/self-pkg/index.js', + unstable_enablePackageExports: true, + }; + + test('should resolve own package name via its own "exports" field', () => { + expect(Resolver.resolve(baseContext, 'self-pkg', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/self-pkg/index.js', + }); + }); + + test('should resolve a subpath of the own package via "exports"', () => { + expect(Resolver.resolve(baseContext, 'self-pkg/sub', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/self-pkg/lib/sub.js', + }); + }); + + test('[nonstrict] should warn and fall back when self subpath is not in "exports"', () => { + const logWarning = jest.fn(); + // `./lib/internal.js` is not exported, but exists on disk. We log the + // out-of-exports access and fall through to the regular hierarchical + // lookup, which finds the file inside `node_modules/self-pkg/`. + expect( + Resolver.resolve( + {...baseContext, unstable_logWarning: logWarning}, + 'self-pkg/lib/internal', + null, + ), + ).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/self-pkg/lib/internal.js', + }); + expect(logWarning).toHaveBeenCalled(); + expect(logWarning.mock.calls[0][0]).toMatch( + /which is not listed in the "exports"/, + ); + }); + + test('should not self-resolve when origin package has no name', () => { + const noNameContext = { + ...baseContext, + ...createPackageAccessors({ + '/root/node_modules/self-pkg/package.json': { + main: 'index-main.js', + exports: {'.': './index.js'}, + }, + }), + }; + // With no `name`, falls through to hierarchical lookup (and finds the + // package in node_modules, going through "exports" as a normal import). + expect(Resolver.resolve(noNameContext, 'self-pkg', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/self-pkg/index.js', + }); + }); + + test('should not self-resolve when the package has no "exports" field', () => { + const noExportsContext = { + ...baseContext, + ...createPackageAccessors({ + '/root/node_modules/self-pkg/package.json': { + name: 'self-pkg', + main: 'index-main.js', + }, + }), + }; + // Without `exports`, self-resolve does not apply. Falls back to + // node_modules lookup, which resolves via "main". + expect(Resolver.resolve(noExportsContext, 'self-pkg', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/self-pkg/index-main.js', + }); + }); + }); }); diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index 51b9543f44..7ace085ae4 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -193,6 +193,56 @@ export default function resolve( // parsedSpecifier is now a non-Haste bare specifier. + // PACKAGE_SELF_RESOLVE: if the origin lives inside a package whose `name` + // matches the bare specifier and which declares an `exports` field, resolve + // the request via that package's `exports`. Per Node.js ESM spec section 6.5 + // (https://nodejs.org/api/esm.html#resolution-algorithm-specification). + if ( + context.unstable_enablePackageExports && + closestPackageToOrigin != null && + closestPackageToOrigin.packageJson.exports != null && + closestPackageToOrigin.packageJson.name === parsedSpecifier.packageName + ) { + try { + const exportsField = closestPackageToOrigin.packageJson.exports; + const packageExportsResult = resolvePackageTargetFromExports( + context, + closestPackageToOrigin.rootPath, + path.join( + closestPackageToOrigin.rootPath, + parsedSpecifier.posixSubpath, + ), + // packageRelativePath for the requested subpath, stripping the leading + // './' so the empty string represents the package root (matching how + // `getPackageForModule` reports `packageRelativePath`). + parsedSpecifier.posixSubpath === '.' + ? '' + : parsedSpecifier.posixSubpath.slice(2), + exportsField, + platform, + ); + if (packageExportsResult != null) { + return packageExportsResult; + } + } catch (e) { + // NB: Falling back is a departure from the spec, but retained for + // backwards compatibility. Remove this in a breaking change. + if (e instanceof PackagePathNotExportedError) { + context.unstable_logWarning( + e.message + + ' Falling back to hierarchical resolution for backwards compatibility.', + ); + } else if (e instanceof InvalidPackageConfigurationError) { + context.unstable_logWarning( + e.message + + ' Falling back to hierarchical resolution for backwards compatibility.', + ); + } else { + throw e; + } + } + } + const {disableHierarchicalLookup} = context; if (!disableHierarchicalLookup) { From 037eb13147e1c4ea0c62ac60fa512f00a9728994 Mon Sep 17 00:00:00 2001 From: Durvesh Pilankar Date: Sat, 27 Jun 2026 16:35:20 -0700 Subject: [PATCH 10/45] Fix HttpStore write retries using the read endpoint's config (#1751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: `HttpStore.#withRetries(fn, endpoint)` is called for both endpoints — `get()` passes `#getEndpoint`, `set()` passes `#setEndpoint` — and it correctly consults the passed `endpoint` for the `maxAttempts === 1` short-circuit. However, the `backOff` options hardcoded `this.#getEndpoint` for `numOfAttempts`, `retryStatuses`, and `retryNetworkErrors`. As a result, when an `HttpStore` is constructed with the documented split `{ getOptions, setOptions }` form, all `set()`/PUT retries silently use the **read** endpoint's retry configuration — `setOptions.maxAttempts`, `setOptions.retryStatuses`, and `setOptions.retryNetworkErrors` are ignored. There's also an internal inconsistency: the `maxAttempts === 1` early-return reads the (set) endpoint, but `numOfAttempts` then read `#getEndpoint.maxAttempts`. The fix uses the passed-in `endpoint` consistently. Pull Request resolved: https://github.com/react/metro/pull/1751 Test Plan: The existing retry tests only use the single-options form via `get()`, where `#getEndpoint === #setEndpoint`, so they can't catch this. Added a regression test that builds the store with the split form (reads: `maxAttempts: 1`; writes: `maxAttempts: 2, retryStatuses: [503]`) and asserts `set()` retries a 503: - Before: `set()` uses the read config (no retry) and throws the 503 → test fails. - After: `set()` retries per `setOptions` and resolves → test passes. ``` yarn jest packages/metro-cache/src/stores/__tests__/HttpStore-test.js # Tests: 19 passed (full metro-cache package: 45 passed) ``` Reviewed By: Abbondanzo Differential Revision: D109932333 Pulled By: robhogan fbshipit-source-id: d7cdf9f1f128a611424df001ff885093682ed1ab (cherry picked from commit 22fa6dd726a4c0a20b61dece1a1cdafce3f096bc) --- packages/metro-cache/src/stores/HttpStore.js | 8 ++--- .../src/stores/__tests__/HttpStore-test.js | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/metro-cache/src/stores/HttpStore.js b/packages/metro-cache/src/stores/HttpStore.js index 951671b30b..97c90645b5 100644 --- a/packages/metro-cache/src/stores/HttpStore.js +++ b/packages/metro-cache/src/stores/HttpStore.js @@ -396,14 +396,12 @@ export default class HttpStore { return backOff(fn, { jitter: 'full', maxDelay: 30000, - numOfAttempts: this.#getEndpoint.maxAttempts || Number.POSITIVE_INFINITY, + numOfAttempts: endpoint.maxAttempts || Number.POSITIVE_INFINITY, retry: (e: Error) => { if (e instanceof HttpError) { - return this.#getEndpoint.retryStatuses.has(e.code); + return endpoint.retryStatuses.has(e.code); } - return ( - e instanceof NetworkError && this.#getEndpoint.retryNetworkErrors - ); + return e instanceof NetworkError && endpoint.retryNetworkErrors; }, }); } diff --git a/packages/metro-cache/src/stores/__tests__/HttpStore-test.js b/packages/metro-cache/src/stores/__tests__/HttpStore-test.js index e8496ab2b9..6802648844 100644 --- a/packages/metro-cache/src/stores/__tests__/HttpStore-test.js +++ b/packages/metro-cache/src/stores/__tests__/HttpStore-test.js @@ -290,6 +290,39 @@ describe('HttpStore', () => { }); }); + test('set() retries use the set endpoint config, not the get endpoint', async () => { + jest.useRealTimers(); + // Distinct read/write retry config: reads do not retry, writes retry 503s. + const store = new HttpStore({ + getOptions: {endpoint: 'http://example.com', maxAttempts: 1}, + setOptions: { + endpoint: 'http://example.com', + maxAttempts: 2, + retryStatuses: [503], + }, + }); + const {request} = require('http'); + + request.mockImplementation((opts, callback) => { + if (request.mock.calls.length === 1) { + callback(responseHttpError(503)); + } else { + callback(responseHttpOk('')); + } + return new PassThrough(); + }); + + let error; + try { + await store.set(Buffer.from('key-set'), {foo: 42}); + } catch (e) { + error = e; + } + + expect(error).toBeUndefined(); + expect(request).toHaveBeenCalledTimes(2); + }); + test('sets using the network via PUT method', done => { const store = new HttpStore({endpoint: 'http://www.example.com/endpoint'}); const promise = store.set(Buffer.from('key-set'), {foo: 42}); From 5aca28791e7c81736a4000251a7a182d399f8bb6 Mon Sep 17 00:00:00 2001 From: Durvesh Pilankar Date: Mon, 6 Jul 2026 14:27:52 -0700 Subject: [PATCH 11/45] Fix mergeConfig dropping state when a config in the chain is async (#1754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: When `mergeConfig(base, ...configs)` encounters a config that resolves to a `Promise` (e.g. an async `metro.config.js` / a config function returning a promise) and at least one more config follows it, it hands off to `mergeConfigAsync` with two defects: 1. **Missing spread.** `mergeConfigAsync(baseConfig, ...reversedConfigs)` takes a rest parameter, but the call passed `reversedConfigs.toReversed()` as a single array argument. The remaining configs end up wrapped one level too deep, so they're merged as a bogus object (producing junk keys like `"0"`) instead of being applied as configs. 2. **Dropped accumulator.** `currentConfig` (the base merged with every config to the *left* of the async one) was discarded — the unresolved promise was passed as the base, so all prior merges were lost. The fix spreads the remaining configs into the rest parameter and folds `currentConfig` into the resolved async config so both the accumulated state and the trailing overrides are preserved. Pull Request resolved: https://github.com/react/metro/pull/1754 Test Plan: The existing `mergeConfig` tests only cover sync configs, so this path was uncovered. Added a regression test (an async config function followed by a trailing override) asserting the base, the async config, and the trailing override all survive: ``` yarn jest packages/metro-config/src/__tests__/mergeConfig-test.js # Tests: 19 passed (full metro-config package: 40 passed) ``` - Before: `result.server.port` is `undefined` (base dropped); trailing override mangled. - After: all three configs merge correctly. Reviewed By: huntie, robhogan Differential Revision: D110207664 Pulled By: rozele fbshipit-source-id: 804041a89c05a08aee0823e91cf22f4539e927ca (cherry picked from commit 9dbe0cce7914977dcc3822534135bb0678312467) --- .../metro-config/src/__tests__/mergeConfig-test.js | 14 ++++++++++++++ packages/metro-config/src/loadConfig.js | 7 ++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/metro-config/src/__tests__/mergeConfig-test.js b/packages/metro-config/src/__tests__/mergeConfig-test.js index 569d3541b3..4a9c78a4e5 100644 --- a/packages/metro-config/src/__tests__/mergeConfig-test.js +++ b/packages/metro-config/src/__tests__/mergeConfig-test.js @@ -29,6 +29,20 @@ describe('mergeConfig', () => { }); }); + test('applies trailing overrides after an async config function', async () => { + const base: InputConfigT = {server: {port: 8081}}; + const asyncOverride = (): Promise => + Promise.resolve({transformer: {assetPlugins: ['async-plugin']}}); + const trailing: InputConfigT = {resolver: {sourceExts: ['ts']}}; + + const result = await mergeConfig(base, asyncOverride, trailing); + + // The base and every config in the chain must survive the async branch. + expect(result.server?.port).toBe(8081); + expect(result.transformer?.assetPlugins).toEqual(['async-plugin']); + expect(result.resolver?.sourceExts).toEqual(['ts']); + }); + describe('server.tls merging', () => { describe('override IS applied when tls is false or object', () => { test('override tls: object replaces base tls: false', () => { diff --git a/packages/metro-config/src/loadConfig.js b/packages/metro-config/src/loadConfig.js index 72d73a8561..b121ed79b7 100644 --- a/packages/metro-config/src/loadConfig.js +++ b/packages/metro-config/src/loadConfig.js @@ -244,7 +244,12 @@ function mergeConfig< typeof next === 'function' ? next(currentConfig) : next; if (nextConfig instanceof Promise) { // $FlowFixMe[incompatible-type] Not clear why Flow doesn't like this - return mergeConfigAsync(nextConfig, reversedConfigs.toReversed()); + return mergeConfigAsync( + nextConfig.then(resolved => + mergeConfigObjects(currentConfig, resolved), + ), + ...reversedConfigs.toReversed(), + ); } currentConfig = mergeConfigObjects(currentConfig, nextConfig) as T; } From 1f7315dc637b2a3ed51dbdc1ed90cfccd24e1649 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 9 Jul 2026 07:21:54 -0700 Subject: [PATCH 12/45] Stop publishing integration_tests, __fixtures__, etc to npm (#1769) Summary: Pull Request resolved: https://github.com/react/metro/pull/1769 The build script previously only excluded `__tests__`, and had an incorrect comment that `IGNORE_PATTERNS` was for passthrough, whereas it does actually exclude files from the build. This expands to an array of patterns and covers other non-prod files. Changelog: Internal Reviewed By: huntie Differential Revision: D111232823 fbshipit-source-id: 0e994803d2f5b036e4208686d28775a780ea442f Adapted for 0.84.x: Uses micromatch, as 0.84.x's build script predates the switch to path.matchesGlob. (cherry picked from commit bbdf03224c034ed849a7ee04de34bfe37e48b570) --- scripts/build.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/build.js b/scripts/build.js index e4116336e3..ada3102f03 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -15,7 +15,10 @@ * script to build (transpile) files. * By default it transpiles all files for all packages and writes them * into `build/` directory. - * Non-js or files matching IGNORE_PATTERN will be copied without transpiling. + * + * Files matching IGNORE_PATTERNS will not be copied to the build directory. + * + * Non-js will be copied without transpiling. * * Example: * node ./scripts/build.js @@ -37,7 +40,13 @@ const SRC_DIR = 'src'; const TYPES_DIR = 'types'; const BUILD_DIR = 'build'; const JS_FILES_PATTERN = '**/*.js'; -const IGNORE_PATTERN = '**/__tests__/**'; +const IGNORE_PATTERNS = [ + '**/__tests__/**', + '**/__fixtures__/**', + '**/__flowtests__/**', + '**/__mocks__/**', + '**/integration_tests/**', +]; const PACKAGES_DIR = path.resolve(__dirname, '../packages'); const fixedWidth = function(str/*: string*/) { @@ -91,7 +100,7 @@ async function buildFile(file /*: string */, silent /*: number | boolean */) { const destPath = getBuildPath(file, BUILD_DIR); fs.mkdirSync(path.dirname(destPath), {recursive: true}); - if (micromatch.isMatch(file, IGNORE_PATTERN)) { + if (micromatch.isMatch(file, IGNORE_PATTERNS)) { silent || process.stdout.write( chalk.dim(' \u2022 ') + From 6872323bb250b593540561cdd8b3b0712ad9e969 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 9 Jul 2026 08:51:58 -0700 Subject: [PATCH 13/45] Flow: Fix `@babel/types` libdef for `getBindingIdentifiers` (#1770) Summary: Pull Request resolved: https://github.com/react/metro/pull/1770 X-link: https://github.com/react/react-native/pull/57488 The generated Flow libdef for `babel/types` declared `duplicates` as a required parameter on `getBindingIdentifiers` and `getOuterBindingIdentifiers`. It is actually optional, both at runtime (only read as a truthy flag, defaulting to the non-duplicate map) and in the upstream TS types: https://github.com/babel/babel/blob/7.x/packages/babel-types/src/retrievers/getBindingIdentifiers.ts#L17-L36 This prevented legitimate single-argument use later in this stack. Separating it for export to RN + Metro. Changelog: [Internal] Reviewed By: GijsWeterings Differential Revision: D111056985 fbshipit-source-id: f3f07617e2fb010b08e83d7d012800a884f5bd15 (cherry picked from commit 25e322ad4a405696de47048b36a6bd4e22079cd3) --- flow-typed/npm/babel-types_v7.x.x.js | 4 ++-- scripts/support/generateBabelTypesFlowLibraryDefinition.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flow-typed/npm/babel-types_v7.x.x.js b/flow-typed/npm/babel-types_v7.x.x.js index 00aaa15964..c6b6cd7e64 100644 --- a/flow-typed/npm/babel-types_v7.x.x.js +++ b/flow-typed/npm/babel-types_v7.x.x.js @@ -3848,8 +3848,8 @@ declare module "@babel/types" { declare export function prependToMemberExpression(member: BabelNodeMemberExpression, prepend: BabelNodeExpression): BabelNodeMemberExpression declare export function removeProperties(n: T, opts: ?{}): void; declare export function removePropertiesDeep(n: T, opts: ?{}): T; - declare export function getBindingIdentifiers(node: BabelNode, duplicates: boolean, outerOnly?: boolean): { [key: string]: BabelNodeIdentifier | Array } - declare export function getOuterBindingIdentifiers(node: Node, duplicates: boolean): { [key: string]: BabelNodeIdentifier | Array } + declare export function getBindingIdentifiers(node: BabelNode, duplicates?: boolean, outerOnly?: boolean): { [key: string]: BabelNodeIdentifier | Array } + declare export function getOuterBindingIdentifiers(node: Node, duplicates?: boolean): { [key: string]: BabelNodeIdentifier | Array } declare export type TraversalAncestors = Array<{ node: BabelNode, key: string, diff --git a/scripts/support/generateBabelTypesFlowLibraryDefinition.js b/scripts/support/generateBabelTypesFlowLibraryDefinition.js index 6bdab43ef8..48164b69ad 100644 --- a/scripts/support/generateBabelTypesFlowLibraryDefinition.js +++ b/scripts/support/generateBabelTypesFlowLibraryDefinition.js @@ -221,9 +221,9 @@ function main() { // retrievers/ // eslint-disable-next-line max-len - `declare export function getBindingIdentifiers(node: ${NODE_PREFIX}, duplicates: boolean, outerOnly?: boolean): { [key: string]: ${NODE_PREFIX}Identifier | Array<${NODE_PREFIX}Identifier> }`, + `declare export function getBindingIdentifiers(node: ${NODE_PREFIX}, duplicates?: boolean, outerOnly?: boolean): { [key: string]: ${NODE_PREFIX}Identifier | Array<${NODE_PREFIX}Identifier> }`, // eslint-disable-next-line max-len - `declare export function getOuterBindingIdentifiers(node: Node, duplicates: boolean): { [key: string]: ${NODE_PREFIX}Identifier | Array<${NODE_PREFIX}Identifier> }`, + `declare export function getOuterBindingIdentifiers(node: Node, duplicates?: boolean): { [key: string]: ${NODE_PREFIX}Identifier | Array<${NODE_PREFIX}Identifier> }`, // traverse/ `declare export type TraversalAncestors = Array<{ From 2b8b084fd1a87fa15f920e99bbcafbafaafa4563 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 9 Jul 2026 10:47:44 -0700 Subject: [PATCH 14/45] Add namespace re-export support to import-export plugin (#1768) Summary: Pull Request resolved: https://github.com/react/metro/pull/1768 Add static import/export plugin (`experimentalImportSupport`) support for namespace re-exports, i.e. `export * as Name from "module"`. This was added to Expo's fork in https://github.com/expo/expo/pull/38055, (h/t krystofwoldrich). Despite this being perfectly valid syntax it currently fails to transform under `experimantalImportSupport`, with: ``` Cannot read properties of undefined (reading 'name') at <..>/metro-transform-plugins/src/import-export-plugin.js:289:67 ``` (On access of `local.name`, because this type of export has no `local` binding) ## Spec ECMA-262 includes namespace re-export syntax in the ExportDeclaration grammar: https://tc39.es/ecma262/#prod-ExportDeclaration ## Implementation Metro's existing `experimentalImportSupport` transform already has namespace import helper plumbing via `importAll`. This change reuses that helper and then emits an explicit export assignment for the namespace object, keeping the change isolated to the static transform path. ``` - **[Experimental]** Support `export * as Name from "module"` under `experimentalImportSupport` ``` Reviewed By: huntie Differential Revision: D111013905 fbshipit-source-id: 101c89427b04263e641c769f4ed4c6641a18af09 (cherry picked from commit 59a1072b3d764857bacc41fa2c21005096df328e) --- .../__tests__/import-export-plugin-test.js | 21 +++++++++++++ .../src/import-export-plugin.js | 30 +++++++++++++++++-- .../types/import-export-plugin.d.ts | 4 +-- .../__snapshots__/import-export-test.js.snap | 2 ++ .../__tests__/import-export-test.js | 2 ++ .../import-export/export-namespace.js | 13 ++++++++ .../basic_bundle/import-export/index.js | 3 ++ 7 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 packages/metro/src/integration_tests/basic_bundle/import-export/export-namespace.js diff --git a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js index d6efe7b5c8..de4a65164a 100644 --- a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js @@ -294,6 +294,27 @@ test('exports members of another module directly from an import (as all)', () => `); }); +test('exports members of another module directly from an import (as namespace)', () => { + const code = ` + export * as AppleIcons from 'apple-icons'; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', {value: true}); + + var _AppleIcons = _$$_IMPORT_ALL('apple-icons'); + exports.AppleIcons = _AppleIcons; + `; + + compare([importExportPlugin], code, expected, opts); + + expect(showTransformedDeps(code)).toMatchInlineSnapshot(` + " + > 2 | export * as AppleIcons from 'apple-icons'; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dep #0 (apple-icons)" + `); +}); + test('enables module exporting when something is exported', () => { const code = ` foo(); diff --git a/packages/metro-transform-plugins/src/import-export-plugin.js b/packages/metro-transform-plugins/src/import-export-plugin.js index f93e04aea6..cf5c3d4dbf 100644 --- a/packages/metro-transform-plugins/src/import-export-plugin.js +++ b/packages/metro-transform-plugins/src/import-export-plugin.js @@ -1,5 +1,5 @@ /** - * Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. @@ -9,6 +9,8 @@ * @oncall react_native */ +// Portions Copyright (c) 2015-present 650 Industries, Inc. (aka Expo), under MIT. + import type {PluginObj} from '@babel/core'; import type {NodePath} from '@babel/traverse'; import type { @@ -274,7 +276,6 @@ export default function importExportPlugin({ const specifiers = path.node.specifiers; if (specifiers) { specifiers.forEach(s => { - const local = s.local; const remote = s.exported; if (remote.type === 'StringLiteral') { @@ -284,6 +285,31 @@ export default function importExportPlugin({ ); } + if (s.type === 'ExportNamespaceSpecifier') { + const source = nullthrows(path.node.source); + const temp = path.scope.generateUidIdentifier(remote.name); + + path.insertBefore( + withLocation( + importTemplate({ + IMPORT: t.cloneNode(state.importAll), + FILE: resolvePath(t.cloneNode(source), state.opts.resolve), + LOCAL: temp, + }), + loc, + ), + ); + + state.exportNamed.push({ + local: temp.name, + remote: remote.name, + loc, + }); + return; + } + + const local = s.local; + if (path.node.source) { // $FlowFixMe[incompatible-use] const temp = path.scope.generateUidIdentifier(local.name); diff --git a/packages/metro-transform-plugins/types/import-export-plugin.d.ts b/packages/metro-transform-plugins/types/import-export-plugin.d.ts index 869c88655c..4506bdf8b7 100644 --- a/packages/metro-transform-plugins/types/import-export-plugin.d.ts +++ b/packages/metro-transform-plugins/types/import-export-plugin.d.ts @@ -1,12 +1,12 @@ /** - * Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<587cbe7792cfd21ce4abb4bfb5bac2f8>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-transform-plugins/src/import-export-plugin.js diff --git a/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap b/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap index c7ef3f9cb1..5233683c96 100644 --- a/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap +++ b/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap @@ -25,6 +25,8 @@ Object { "myDefault": "export-1: DEFAULT", "myFoo": "export-1: FOO", "myFunction": "export-1: MY_FUNCTION", + "namespaceReExportDefault": "export-2: DEFAULT", + "namespaceReExportFoo": "export-2: FOO", "primitiveDefault": "export-primitive-default: DEFAULT", "primitiveFoo": "export-primitive-default: FOO", }, diff --git a/packages/metro/src/integration_tests/__tests__/import-export-test.js b/packages/metro/src/integration_tests/__tests__/import-export-test.js index e384dba72f..55646a2625 100644 --- a/packages/metro/src/integration_tests/__tests__/import-export-test.js +++ b/packages/metro/src/integration_tests/__tests__/import-export-test.js @@ -27,6 +27,8 @@ test('builds a simple bundle', async () => { const object = execBundle(result.code); const cjs = await object.asyncImportCJS; + expect(object.extraData.namespaceReExportDefault).toBe('export-2: DEFAULT'); + expect(object.extraData.namespaceReExportFoo).toBe('export-2: FOO'); expect(object).toMatchSnapshot(); expect(cjs).toEqual(expect.objectContaining(cjs.default)); diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/export-namespace.js b/packages/metro/src/integration_tests/basic_bundle/import-export/export-namespace.js new file mode 100644 index 0000000000..a510a10938 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/export-namespace.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict + */ + +'use strict'; + +export * as namespaceReExport from './export-2'; diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/index.js b/packages/metro/src/integration_tests/basic_bundle/import-export/index.js index 654a2e9b65..9c3ceadfb5 100644 --- a/packages/metro/src/integration_tests/basic_bundle/import-export/index.js +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/index.js @@ -14,6 +14,7 @@ import type {RequireWithUnstableImportMaybeSync} from './utils'; import {default as myDefault, foo as myFoo, myFunction} from './export-1'; import * as importStar from './export-2'; +import {namespaceReExport} from './export-namespace'; import {foo} from './export-null'; import primitiveDefault, { foo as primitiveFoo, @@ -30,6 +31,8 @@ export const extraData = { myDefault, myFoo, myFunction: myFunction() as string, + namespaceReExportDefault: namespaceReExport.default, + namespaceReExportFoo: namespaceReExport.foo, primitiveDefault, primitiveFoo, }; From f8e9c2f1a984f56fc2532fbe221784e17970da13 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 9 Jul 2026 16:10:28 -0700 Subject: [PATCH 15/45] experimentalImportSupport: Fix exported names under relabelling and rest spread (#1772) Summary: Pull Request resolved: https://github.com/react/metro/pull/1772 Fixes https://github.com/react/metro/issues/1460 Fixes two related bugs in Metro's `experimentalImportSupport`. ## Exports with inline relabelling ```js export const { A: B } = {}; ``` **Currently transforms to** ```js const { A: B } = {}; exports.A = A; ``` Which is invalid: `A` is a reference error. **After this diff** ```js const { A: B } = {}; exports.A = B; ``` ## Exports with spreads Currently both of these fail to transform: **Object spread** ```js export const {A, ...others} = {A: 'A', B: 'B', C: 'C'}; ``` Plugin throws `TypeError: Cannot read properties of undefined (reading 'name')` when attempting to iterate over the object for key: val pairs. **Array spread** ```js export const [A, ...others] = ['A', 'B', 'C']; ``` Plugin throws `Property name expected type of string but got undefined` after extracting an `undefined` name and then attempting to use it at as prop of `exports`. **After this diff** ```js const {A, ...others} = {A: 'A', B: 'B', C: 'C'}; exports.A = A; exports.others = others; ``` and ```js const [A, ...others] = ['A', 'B', 'C']; exports.A = A; exports.others = others; ``` ## Spec Almost goes without saying in this case - exported variable declarations should expose the declaration's bound names. ECMA-262 defines those names through Static Semantics: `BoundNames`, including `BindingPattern` forms: https://tc39.es/ecma262/#sec-static-semantics-boundnames ## Implementation Metro had naive `ObjectPattern`/`ArrayPattern` extraction that read object keys or array elements directly. This fixes the same issues Expo did in [#38058](https://github.com/expo/expo/pull/38058) and [#38118](https://github.com/expo/expo/pull/38118), but uses Babel's own `getBindingIdentifiers` for a simpler implementation. ## Changelog ``` - **[Experimental]**: experimentalImportSupport: Fix exports renamed by destructuring, and support exports of rest spreads. ``` Reviewed By: GijsWeterings, huntie Differential Revision: D111013897 fbshipit-source-id: 6f0b0e135f5014006d2d1b0851ae26903e21355b (cherry picked from commit 0dc50304a44acbc82c061986b8839be0503d6c62) --- .../__tests__/import-export-plugin-test.js | 45 +++++++++++++++++++ .../src/import-export-plugin.js | 37 ++------------- .../__snapshots__/import-export-test.js.snap | 8 ++++ .../__tests__/import-export-test.js | 10 +++++ .../import-export/export-destructuring.js | 26 +++++++++++ .../basic_bundle/import-export/index.js | 10 +++++ 6 files changed, 102 insertions(+), 34 deletions(-) create mode 100644 packages/metro/src/integration_tests/basic_bundle/import-export/export-destructuring.js diff --git a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js index de4a65164a..4eade05492 100644 --- a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js @@ -255,6 +255,36 @@ test('exports destructured named object members', () => { compare([importExportPlugin], code, expected, opts); }); +test('exports destructured renamed object members', () => { + const code = ` + export const {foo: bar, baz} = {foo: 'bar', baz: 'baz'}; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', {value: true}); + const {foo: bar,baz} = {foo: 'bar', baz: 'baz'}; + exports.bar = bar; + exports.baz = baz; + `; + + compare([importExportPlugin], code, expected, opts); +}); + +test('exports destructured object rest members', () => { + const code = ` + export const {foo, ...bar} = {foo: 'foo', bar: 'bar', baz: 'baz'}; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', {value: true}); + const {foo,...bar} = {foo: 'foo', bar: 'bar', baz: 'baz'}; + exports.foo = foo; + exports.bar = bar; + `; + + compare([importExportPlugin], code, expected, opts); +}); + test('exports destructured named array members', () => { const code = ` export const [foo,bar] = ['bar','baz']; @@ -270,6 +300,21 @@ test('exports destructured named array members', () => { compare([importExportPlugin], code, expected, opts); }); +test('exports destructured array rest members', () => { + const code = ` + export const [foo, ...bar] = ['foo','bar','baz']; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', {value: true}); + const [foo,...bar] = ['foo','bar','baz']; + exports.foo = foo; + exports.bar = bar; + `; + + compare([importExportPlugin], code, expected, opts); +}); + test('exports members of another module directly from an import (as all)', () => { const code = ` export * from 'bar'; diff --git a/packages/metro-transform-plugins/src/import-export-plugin.js b/packages/metro-transform-plugins/src/import-export-plugin.js index cf5c3d4dbf..db8295491f 100644 --- a/packages/metro-transform-plugins/src/import-export-plugin.js +++ b/packages/metro-transform-plugins/src/import-export-plugin.js @@ -224,40 +224,9 @@ export default function importExportPlugin({ if (declaration) { if (isVariableDeclaration(declaration)) { - declaration.declarations.forEach(d => { - switch (d.id.type) { - case 'ObjectPattern': - { - const properties = d.id.properties; - properties.forEach(p => { - // $FlowFixMe[incompatible-use] Flow error uncovered by typing Babel more strictly - // $FlowFixMe[prop-missing] - const name = p.key.name; - // $FlowFixMe[incompatible-type] - state.exportNamed.push({local: name, remote: name, loc}); - }); - } - break; - case 'ArrayPattern': - { - const elements = d.id.elements; - elements.forEach(e => { - // $FlowFixMe[incompatible-use] Flow error uncovered by typing Babel more strictly - // $FlowFixMe[prop-missing] - const name = e.name; - // $FlowFixMe[incompatible-type] - state.exportNamed.push({local: name, remote: name, loc}); - }); - } - break; - default: - { - const name = d.id.name; - // $FlowFixMe[incompatible-type] - state.exportNamed.push({local: name, remote: name, loc}); - } - break; - } + const bindings = t.getBindingIdentifiers(declaration); + Object.keys(bindings).forEach(name => { + state.exportNamed.push({local: name, remote: name, loc}); }); } else { const id = declaration.id || path.scope.generateUidIdentifier(); diff --git a/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap b/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap index 5233683c96..b20e405717 100644 --- a/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap +++ b/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap @@ -16,6 +16,10 @@ Object { }, "default": "export-4: FOO", "extraData": Object { + "arrayFirst": "export-destructuring: ARRAY_FIRST", + "arrayRest": Array [ + "export-destructuring: ARRAY_REST", + ], "foo": "export-null: FOO", "importStar": Object { "default": "export-2: DEFAULT", @@ -27,8 +31,12 @@ Object { "myFunction": "export-1: MY_FUNCTION", "namespaceReExportDefault": "export-2: DEFAULT", "namespaceReExportFoo": "export-2: FOO", + "objectRest": Object { + "remaining": "export-destructuring: OBJECT_REST", + }, "primitiveDefault": "export-primitive-default: DEFAULT", "primitiveFoo": "export-primitive-default: FOO", + "renamedObject": "export-destructuring: RENAMED_OBJECT", }, "namedDefaultExported": "export-3: DEFAULT", } diff --git a/packages/metro/src/integration_tests/__tests__/import-export-test.js b/packages/metro/src/integration_tests/__tests__/import-export-test.js index 55646a2625..a26f5da727 100644 --- a/packages/metro/src/integration_tests/__tests__/import-export-test.js +++ b/packages/metro/src/integration_tests/__tests__/import-export-test.js @@ -27,6 +27,16 @@ test('builds a simple bundle', async () => { const object = execBundle(result.code); const cjs = await object.asyncImportCJS; + expect(object.extraData.renamedObject).toBe( + 'export-destructuring: RENAMED_OBJECT', + ); + expect(object.extraData.objectRest).toEqual({ + remaining: 'export-destructuring: OBJECT_REST', + }); + expect(object.extraData.arrayFirst).toBe('export-destructuring: ARRAY_FIRST'); + expect(object.extraData.arrayRest).toEqual([ + 'export-destructuring: ARRAY_REST', + ]); expect(object.extraData.namespaceReExportDefault).toBe('export-2: DEFAULT'); expect(object.extraData.namespaceReExportFoo).toBe('export-2: FOO'); expect(object).toMatchSnapshot(); diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/export-destructuring.js b/packages/metro/src/integration_tests/basic_bundle/import-export/export-destructuring.js new file mode 100644 index 0000000000..8d36febb73 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/export-destructuring.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict + */ + +'use strict'; + +const objectSource = { + original: 'export-destructuring: RENAMED_OBJECT', + remaining: 'export-destructuring: OBJECT_REST', +}; + +export const {original: renamedObject, ...objectRest} = objectSource; + +const arraySource = [ + 'export-destructuring: ARRAY_FIRST', + 'export-destructuring: ARRAY_REST', +]; + +// $FlowFixMe[invalid-exported-annotation] Flow can't infer an exported annotation for an array rest binding +export const [arrayFirst, ...arrayRest] = arraySource; diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/index.js b/packages/metro/src/integration_tests/basic_bundle/import-export/index.js index 9c3ceadfb5..c0574ed7e9 100644 --- a/packages/metro/src/integration_tests/basic_bundle/import-export/index.js +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/index.js @@ -14,6 +14,12 @@ import type {RequireWithUnstableImportMaybeSync} from './utils'; import {default as myDefault, foo as myFoo, myFunction} from './export-1'; import * as importStar from './export-2'; +import { + arrayFirst, + arrayRest, + objectRest, + renamedObject, +} from './export-destructuring'; import {namespaceReExport} from './export-namespace'; import {foo} from './export-null'; import primitiveDefault, { @@ -26,6 +32,8 @@ export {default as namedDefaultExported} from './export-3'; export {foo as default} from './export-4'; export const extraData = { + arrayFirst, + arrayRest, foo, importStar, myDefault, @@ -33,8 +41,10 @@ export const extraData = { myFunction: myFunction() as string, namespaceReExportDefault: namespaceReExport.default, namespaceReExportFoo: namespaceReExport.foo, + objectRest, primitiveDefault, primitiveFoo, + renamedObject, }; export const asyncImportCJS = import('./export-5'); From d3f8dfe8c2aa7425e06b2b88cfba45d9d3bb460e Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Fri, 10 Jul 2026 03:46:33 -0700 Subject: [PATCH 16/45] experimentalImportSupport: Place export-all assignments before explicit exports, fix precedence of `export default` > `export * from` (#1773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/react/metro/pull/1773 Fixes a bug in `experimentalImportSupport` where `export * from 'foo'` would incorrectly overwrite an `export default`, if `'foo'` also has a default export. # Example ## Input source ```js // foo.js (the star source) export default 'star default'; export const overridden = 'star named'; export const sourceOnly = 'source only'; ``` ```js // entry.js export * from 'foo'; export const overridden = 'explicit named'; export default 'explicit default'; ``` Per the ES spec, importing from `entry.js` should give `overridden === 'explicit named'`, `default === 'explicit default'`, and `sourceOnly === 'source only'`. Explicit exports take precedence over names supplied via `export *`, regardless of the position of export declarations. ## Before ```js 'use strict'; Object.defineProperty(exports, '__esModule', {value: true}); var _overridden = 'explicit named'; var _default = 'explicit default'; exports.overridden = _overridden; // 'explicit named' exports.default = _default; // 'explicit default' var _foo = require('foo'); for (var _key in _foo) { // star copy runs LAST... exports[_key] = _foo[_key]; // ...clobbers the explicit exports } ``` Runtime result (wrong): ```js exports.overridden // 'star named' ❌ should be 'explicit named' exports.default // 'star default' ❌ should be 'explicit default' exports.sourceOnly // 'source only' ✅ ``` ## After ```js 'use strict'; Object.defineProperty(exports, '__esModule', {value: true}); var _overridden = 'explicit named'; var _default = 'explicit default'; var _foo = require('foo'); for (var _key in _foo) { // star copy runs FIRST exports[_key] = _foo[_key]; } exports.overridden = _overridden; // explicit exports now win exports.default = _default; ``` Runtime result (spec-correct): ```js exports.overridden // 'explicit named' ✅ exports.default // 'explicit default' ✅ exports.sourceOnly // 'source only' ✅ ``` # Spec [ECMA-262 Source Text Module Record](https://tc39.es/ecma262/#sec-source-text-module-records-resolveexport-exportname-resolve-set) `ResolveExport` checks local `ExportEntries` and indirect `ExportEntries` before considering `StarExportEntries`, so explicit exports take precedence over names supplied through `export *`. # Provenance This is part of the change made in Expo's fork in [#38111](https://github.com/expo/expo/pull/38111) (though that's mainly concerned with introducing live bindings), so this is "in the wild" already for Expo users. # Implementation This is just a straightforward ordering swap of the emitted assignments. # Changelog ``` - **[Experimental]**: `experimentalImportSupport` - fix precedence of `export default` over `export * from`. ``` Reviewed By: huntie Differential Revision: D111013892 fbshipit-source-id: 937f7aabdbe6760425072153f2093fe4e45a37e3 (cherry picked from commit e6b154f9bf310caf941f538a00113d60ef42efe7) --- .../__tests__/import-export-plugin-test.js | 69 ++++++++++++++++++- .../src/import-export-plugin.js | 28 ++++---- .../__snapshots__/import-export-test.js.snap | 3 + .../__tests__/import-export-test.js | 9 +++ .../import-export/export-star-overrides.js | 17 +++++ .../import-export/export-star-source.js | 17 +++++ .../basic_bundle/import-export/index.js | 7 ++ 7 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 packages/metro/src/integration_tests/basic_bundle/import-export/export-star-overrides.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/import-export/export-star-source.js diff --git a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js index 4eade05492..0b0b266478 100644 --- a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js @@ -17,6 +17,8 @@ const {compare, transformToAst} = require('../__mocks__/test-helpers'); const importExportPlugin = require('../import-export-plugin'); // $FlowFixMe[untyped-import] @babel/code-frame const {codeFrameColumns} = require('@babel/code-frame'); +const generate = require('@babel/generator').default; +const vm = require('vm'); const opts = { importAll: '_$$_IMPORT_ALL', @@ -156,8 +158,8 @@ test('exports members of another module directly from an import (as default)', ( var _foo = require('bar').foo; var _baz = require('bar').baz; - exports.default = _foo; exports.baz = _baz; + exports.default = _foo; `; compare([importExportPlugin], code, expected, opts); @@ -233,8 +235,8 @@ test('allows mixed esm and cjs exports', () => { exports.bar = 'bar'; module.exports.baz = 'baz'; class _default {} - exports.default = _default; exports.foo = foo; + exports.default = _default; `; compare([importExportPlugin], code, expected, opts); @@ -360,6 +362,69 @@ test('exports members of another module directly from an import (as namespace)', `); }); +test('places export all above explicit exports', () => { + const code = ` + export * from 'foo'; + export {baz} from 'bar'; + const bax = 'bax'; + export default bax; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', {value: true}); + + var _baz = require('bar').baz; + const bax = 'bax'; + + var _default = bax; + + var _foo = require("foo"); + + for (var _key in _foo) { + exports[_key] = _foo[_key]; + } + + exports.baz = _baz; + exports.default = _default; + `; + + compare([importExportPlugin], code, expected, opts); +}); + +test('explicit exports override export all at runtime', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + ` + export * from 'foo'; + export const overridden = 'explicit named'; + export default 'explicit default'; + `, + opts, + ), + ).code; + const context = { + exports: {} as {[string]: unknown}, + require: (id: string) => { + if (id !== 'foo') { + throw new Error(`Unexpected module: ${id}`); + } + return { + default: 'star default', + overridden: 'star named', + sourceOnly: 'source only', + }; + }, + }; + + vm.runInNewContext(transformedCode, context); + + expect(context.exports.__esModule).toBe(true); + expect(context.exports.default).toBe('explicit default'); + expect(context.exports.overridden).toBe('explicit named'); + expect(context.exports.sourceOnly).toBe('source only'); +}); + test('enables module exporting when something is exported', () => { const code = ` foo(); diff --git a/packages/metro-transform-plugins/src/import-export-plugin.js b/packages/metro-transform-plugins/src/import-export-plugin.js index db8295491f..a838355d37 100644 --- a/packages/metro-transform-plugins/src/import-export-plugin.js +++ b/packages/metro-transform-plugins/src/import-export-plugin.js @@ -522,20 +522,6 @@ export default function importExportPlugin({ body.unshift(e.node); }); - state.exportDefault.forEach( - (e: {local: string, loc: ?SourceLocation, ...}) => { - body.push( - withLocation( - exportTemplate({ - LOCAL: t.identifier(e.local), - REMOTE: t.identifier('default'), - }), - e.loc, - ), - ); - }, - ); - state.exportAll.forEach( (e: {file: string, loc: ?SourceLocation, ...}) => { body.push( @@ -569,6 +555,20 @@ export default function importExportPlugin({ }, ); + state.exportDefault.forEach( + (e: {local: string, loc: ?SourceLocation, ...}) => { + body.push( + withLocation( + exportTemplate({ + LOCAL: t.identifier(e.local), + REMOTE: t.identifier('default'), + }), + e.loc, + ), + ); + }, + ); + if ( state.exportDefault.length || state.exportAll.length || diff --git a/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap b/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap index b20e405717..7418d1fa9b 100644 --- a/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap +++ b/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap @@ -20,6 +20,9 @@ Object { "arrayRest": Array [ "export-destructuring: ARRAY_REST", ], + "exportStarDefault": "export-star-overrides: DEFAULT", + "exportStarOverridden": "export-star-overrides: OVERRIDDEN", + "exportStarSourceOnly": "export-star-source: SOURCE_ONLY", "foo": "export-null: FOO", "importStar": Object { "default": "export-2: DEFAULT", diff --git a/packages/metro/src/integration_tests/__tests__/import-export-test.js b/packages/metro/src/integration_tests/__tests__/import-export-test.js index a26f5da727..9b5d7b3a33 100644 --- a/packages/metro/src/integration_tests/__tests__/import-export-test.js +++ b/packages/metro/src/integration_tests/__tests__/import-export-test.js @@ -39,6 +39,15 @@ test('builds a simple bundle', async () => { ]); expect(object.extraData.namespaceReExportDefault).toBe('export-2: DEFAULT'); expect(object.extraData.namespaceReExportFoo).toBe('export-2: FOO'); + expect(object.extraData.exportStarDefault).toBe( + 'export-star-overrides: DEFAULT', + ); + expect(object.extraData.exportStarOverridden).toBe( + 'export-star-overrides: OVERRIDDEN', + ); + expect(object.extraData.exportStarSourceOnly).toBe( + 'export-star-source: SOURCE_ONLY', + ); expect(object).toMatchSnapshot(); expect(cjs).toEqual(expect.objectContaining(cjs.default)); diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-overrides.js b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-overrides.js new file mode 100644 index 0000000000..d17eef6ef9 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-overrides.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict + */ + +'use strict'; + +export * from './export-star-source'; + +export const overridden = 'export-star-overrides: OVERRIDDEN'; + +export default 'export-star-overrides: DEFAULT'; diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-source.js b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-source.js new file mode 100644 index 0000000000..db1c34a509 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-source.js @@ -0,0 +1,17 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow strict + */ + +'use strict'; + +export default 'export-star-source: DEFAULT'; + +export const overridden = 'export-star-source: OVERRIDDEN'; + +export const sourceOnly = 'export-star-source: SOURCE_ONLY'; diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/index.js b/packages/metro/src/integration_tests/basic_bundle/import-export/index.js index c0574ed7e9..7a67bd69a8 100644 --- a/packages/metro/src/integration_tests/basic_bundle/import-export/index.js +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/index.js @@ -25,6 +25,10 @@ import {foo} from './export-null'; import primitiveDefault, { foo as primitiveFoo, } from './export-primitive-default'; +import exportStarDefault, { + overridden as exportStarOverridden, + sourceOnly as exportStarSourceOnly, +} from './export-star-overrides'; declare var require: RequireWithUnstableImportMaybeSync; @@ -34,6 +38,9 @@ export {foo as default} from './export-4'; export const extraData = { arrayFirst, arrayRest, + exportStarDefault, + exportStarOverridden, + exportStarSourceOnly, foo, importStar, myDefault, From 7ccb568b0153ea660dd69dcd1cf8c1a1c4311578 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Fri, 10 Jul 2026 05:49:00 -0700 Subject: [PATCH 17/45] Hoist re-export dependencies before module body (#1774) Summary: Pull Request resolved: https://github.com/react/metro/pull/1774 In ESM, static imports (including `export * from`) must be evaluated before the current module's body. Metro's `import-export-plugin` currently evaluates named re-exports at their declared position within the body (by turning them into a CJS `require` at that posiiton), and `export *` after the body - both are incorrect. Move static re-export helper requires and export-star copies into the import prelude instead of leaving them at the original export declaration location or after the module body. ## `inlineRequires` Under `inlineRequires`, which runs after this plugin, evaluation of imported modules may be deferred back to where they were before this diff - contrary to the spec but by design for startup performance. ## Spec - Source Text Module Records collect imported and re-exported module specifiers as requested modules: https://tc39.es/ecma262/#sec-source-text-module-records - ECMA-262 `InnerModuleEvaluation` evaluates requested modules before executing the current module body: https://tc39.es/ecma262/#sec-innermoduleevaluation ## Expo Along with the previous diff, this is another change introduced in Expo last year in [#38111](https://github.com/expo/expo/pull/38111), on by default for Expo apps. ## Changelog ```javascript - **[Experimental]**: `experimentalImportSupport`: Move `export from` imports to be evaluated at module prelude, rather than within/after module body ``` Reviewed By: huntie Differential Revision: D111013896 fbshipit-source-id: 069438ab66a206471fc9f948efc924ce881c8d6d (cherry picked from commit 27a5adc79f257371142da908b6cda1e5137b736b) --- .../__tests__/import-export-plugin-test.js | 43 +++++++++++--- .../src/import-export-plugin.js | 59 ++++++++----------- .../__snapshots__/import-export-test.js.snap | 4 ++ .../__tests__/import-export-test.js | 4 ++ .../import-export/export-star-overrides.js | 6 ++ .../import-export/export-star-source.js | 4 ++ .../basic_bundle/import-export/index.js | 2 + 7 files changed, 82 insertions(+), 40 deletions(-) diff --git a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js index 0b0b266478..0852228a04 100644 --- a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js @@ -325,7 +325,7 @@ test('exports members of another module directly from an import (as all)', () => const expected = ` Object.defineProperty(exports, '__esModule', {value: true}); - var _bar = require("bar"); + var _bar = require('bar'); for (var _key in _bar) { exports[_key] = _bar[_key]; @@ -373,17 +373,17 @@ test('places export all above explicit exports', () => { const expected = ` Object.defineProperty(exports, '__esModule', {value: true}); - var _baz = require('bar').baz; - const bax = 'bax'; - - var _default = bax; - - var _foo = require("foo"); + var _foo = require('foo'); for (var _key in _foo) { exports[_key] = _foo[_key]; } + var _baz = require('bar').baz; + const bax = 'bax'; + + var _default = bax; + exports.baz = _baz; exports.default = _default; `; @@ -425,6 +425,35 @@ test('explicit exports override export all at runtime', () => { expect(context.exports.sourceOnly).toBe('source only'); }); +test('re-export dependencies evaluate before module body at runtime', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + ` + events.push('body'); + export {value} from 'foo'; + export * from 'bar'; + `, + opts, + ), + ).code; + const events = []; + const context = { + events, + exports: {} as {[string]: unknown}, + require: (id: string) => { + events.push(`require ${id}`); + return id === 'foo' ? {value: 'foo value'} : {star: 'bar star'}; + }, + }; + + vm.runInNewContext(transformedCode, context); + + expect(events).toEqual(['require foo', 'require bar', 'body']); + expect(context.exports.value).toBe('foo value'); + expect(context.exports.star).toBe('bar star'); +}); + test('enables module exporting when something is exported', () => { const code = ` foo(); diff --git a/packages/metro-transform-plugins/src/import-export-plugin.js b/packages/metro-transform-plugins/src/import-export-plugin.js index a838355d37..5d941bab5f 100644 --- a/packages/metro-transform-plugins/src/import-export-plugin.js +++ b/packages/metro-transform-plugins/src/import-export-plugin.js @@ -169,11 +169,23 @@ export default function importExportPlugin({ path: NodePath, state: State, ): void { + const loc = path.node.loc; + const file = path.node.source; + state.exportAll.push({ - file: path.node.source.value, - loc: path.node.loc, + file: file.value, + loc, }); + withLocation( + exportAllTemplate({ + FILE: resolvePath(t.cloneNode(file), state.opts.resolve), + REQUIRED: path.scope.generateUidIdentifier(file.value), + KEY: path.scope.generateUidIdentifier('key'), + }), + loc, + ).forEach(node => state.imports.push({node})); + path.remove(); }, @@ -258,8 +270,8 @@ export default function importExportPlugin({ const source = nullthrows(path.node.source); const temp = path.scope.generateUidIdentifier(remote.name); - path.insertBefore( - withLocation( + state.imports.push({ + node: withLocation( importTemplate({ IMPORT: t.cloneNode(state.importAll), FILE: resolvePath(t.cloneNode(source), state.opts.resolve), @@ -267,7 +279,7 @@ export default function importExportPlugin({ }), loc, ), - ); + }); state.exportNamed.push({ local: temp.name, @@ -286,8 +298,8 @@ export default function importExportPlugin({ // $FlowFixMe[incompatible-type] // $FlowFixMe[incompatible-use] if (local.name === 'default') { - path.insertBefore( - withLocation( + state.imports.push({ + node: withLocation( importTemplate({ IMPORT: t.cloneNode(state.importDefault), FILE: resolvePath( @@ -298,7 +310,7 @@ export default function importExportPlugin({ }), loc, ), - ); + }); state.exportNamed.push({ local: temp.name, @@ -306,8 +318,8 @@ export default function importExportPlugin({ loc, }); } else if (remote.name === 'default') { - path.insertBefore( - withLocation( + state.imports.push({ + node: withLocation( importNamedTemplate({ FILE: resolvePath( t.cloneNode(nullthrows(path.node.source)), @@ -318,12 +330,12 @@ export default function importExportPlugin({ }), loc, ), - ); + }); state.exportDefault.push({local: temp.name, loc}); } else { - path.insertBefore( - withLocation( + state.imports.push({ + node: withLocation( importNamedTemplate({ FILE: resolvePath( t.cloneNode(nullthrows(path.node.source)), @@ -334,7 +346,7 @@ export default function importExportPlugin({ }), loc, ), - ); + }); state.exportNamed.push({ local: temp.name, @@ -522,25 +534,6 @@ export default function importExportPlugin({ body.unshift(e.node); }); - state.exportAll.forEach( - (e: {file: string, loc: ?SourceLocation, ...}) => { - body.push( - // $FlowFixMe[incompatible-call] - ...withLocation( - exportAllTemplate({ - FILE: resolvePath( - t.stringLiteral(e.file), - state.opts.resolve, - ), - REQUIRED: path.scope.generateUidIdentifier(e.file), - KEY: path.scope.generateUidIdentifier('key'), - }), - e.loc, - ), - ); - }, - ); - state.exportNamed.forEach( (e: {local: string, remote: string, loc: ?SourceLocation, ...}) => { body.push( diff --git a/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap b/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap index 7418d1fa9b..40e4368fad 100644 --- a/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap +++ b/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap @@ -21,6 +21,10 @@ Object { "export-destructuring: ARRAY_REST", ], "exportStarDefault": "export-star-overrides: DEFAULT", + "exportStarEvaluationOrder": Array [ + "source module", + "barrel body", + ], "exportStarOverridden": "export-star-overrides: OVERRIDDEN", "exportStarSourceOnly": "export-star-source: SOURCE_ONLY", "foo": "export-null: FOO", diff --git a/packages/metro/src/integration_tests/__tests__/import-export-test.js b/packages/metro/src/integration_tests/__tests__/import-export-test.js index 9b5d7b3a33..3ed3cc79b2 100644 --- a/packages/metro/src/integration_tests/__tests__/import-export-test.js +++ b/packages/metro/src/integration_tests/__tests__/import-export-test.js @@ -42,6 +42,10 @@ test('builds a simple bundle', async () => { expect(object.extraData.exportStarDefault).toBe( 'export-star-overrides: DEFAULT', ); + expect(object.extraData.exportStarEvaluationOrder).toEqual([ + 'source module', + 'barrel body', + ]); expect(object.extraData.exportStarOverridden).toBe( 'export-star-overrides: OVERRIDDEN', ); diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-overrides.js b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-overrides.js index d17eef6ef9..00319cde38 100644 --- a/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-overrides.js +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-overrides.js @@ -12,6 +12,12 @@ export * from './export-star-source'; +(global.__exportStarEvents || (global.__exportStarEvents = [])).push( + 'barrel body', +); + +export const evaluationOrder = global.__exportStarEvents; + export const overridden = 'export-star-overrides: OVERRIDDEN'; export default 'export-star-overrides: DEFAULT'; diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-source.js b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-source.js index db1c34a509..2d3a39fbe3 100644 --- a/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-source.js +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-source.js @@ -10,6 +10,10 @@ 'use strict'; +(global.__exportStarEvents || (global.__exportStarEvents = [])).push( + 'source module', +); + export default 'export-star-source: DEFAULT'; export const overridden = 'export-star-source: OVERRIDDEN'; diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/index.js b/packages/metro/src/integration_tests/basic_bundle/import-export/index.js index 7a67bd69a8..0c331a5c62 100644 --- a/packages/metro/src/integration_tests/basic_bundle/import-export/index.js +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/index.js @@ -26,6 +26,7 @@ import primitiveDefault, { foo as primitiveFoo, } from './export-primitive-default'; import exportStarDefault, { + evaluationOrder as exportStarEvaluationOrder, overridden as exportStarOverridden, sourceOnly as exportStarSourceOnly, } from './export-star-overrides'; @@ -39,6 +40,7 @@ export const extraData = { arrayFirst, arrayRest, exportStarDefault, + exportStarEvaluationOrder, exportStarOverridden, exportStarSourceOnly, foo, From 47f2d13a0856d83b84292998fb2916c53264f0fd Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Sat, 18 Jul 2026 06:13:55 -0700 Subject: [PATCH 18/45] experimentalImportSupport: Fix `export * from` incorrectly re-exporting the default export (#1777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull request resolved: https://github.com/react/metro/pull/1777 Fix a spec conformance bug in `export * from 'foo'` under `experimentalImportSupport` (`import-export-plugin`). Currently, Metro re-exports all exports including the source module's default export - the default export should be excluded. ## Spec https://tc39.es/ecma262/multipage/ecmascript-language-scripts-and-modules.html#sec-getexportednames `8.d.i` ``` 8. For each ExportEntry Record exportEntry of module.[[StarExportEntries]], do a. Assert: exportEntry.[[ModuleRequest]] is not null. b. Let requestedModule be GetImportedModule(module, exportEntry.[[ModuleRequest]]). c. Let starNames be requestedModule.GetExportedNames(exportStarSet). d. For each element name of starNames, do -> i. If name is not "default", then 1. If exportedNames does not contain name, then a. Append name to exportedNames. ``` Node.js, Babel's `transform-modules-commonjs`, and browsers all conform to this spec. ## Fix Trivially `continue` through a key named "default" in our `exportAllTemplate`. NOTE: `export * as ns from 'foo'` is unaffected: it goes through the `ExportNamespaceSpecifier` / `importAll` path, and per spec the namespace object still exposes the source's default. ## Possible alternatives + benchmarks Compiled release HBC under SH trunk (`hermesc -O -emit-binary`) and run in the `hermes` VM. - One "op" = one full re-export of a module with 20 named exports + `default` (where noted). - Throughput = median of 9 runs × 250k ops. - Bytecode = marginal HBC size of the isolated `f(exports, REQUIRED)` body vs an empty-body baseline. | Form | Input | Throughput (ops/s) | stddev | rel | HBC bytes (Δ) | |---|---|--:|--:|--:|--:| | `for-in`, no `default` guard (re-exports default) | 20 + `default` | 99,602 | 1,041 | 0.95× | +96 | | `for-in` + `if (KEY === "default") continue;` (current) | 20 + `default` | 104,778 | 1,025 | 1.00× | +126 | | `for-in` + `if (KEY !== "default") { ... }` | 20 + `default` | 104,515 | 879 | 1.00× | +127 | | object spread + `delete default` | 20 + `default` | 91,709 | 583 | 0.88× | +148 | | `Object.keys` + indexed for-loop | 20 + `default` | 90,351 | 672 | 0.86× | +206 | | `Object.getOwnPropertyNames` + for-loop | 20 + `default` | 89,831 | 680 | 0.86× | +224 | | `for-in` + `hasOwnProperty` guard | 20 + `default` | 78,939 | 862 | 0.75× | +239 | | `Object.keys().forEach` | 20 + `default` | 73,746 | 346 | 0.70× | +273 | | `Object.assign` + `delete default` | 20 + `default` | 61,395 | 567 | 0.59× | +139 | | `for-in` + `default` guard (guard is pure overhead) | 20, **no** `default` | 104,080 | 538 | 0.99× | +126 | | `for-in`, no guard | 20, **no** `default` | 104,866 | 634 | 1.00× | +96 | **Conclusion:** both `for-in` guard styles are fastest and smallest of all semantically-equivalent forms (differing by ~3% throughput / 1 byte — within noise). - The `default` guard's runtime effect flips with the input: - **default present:** guard is **+5.2%** faster (104,778 vs 99,602) — skipping the `default` property write outweighs the per-key comparisons. - **default absent:** guard is **−0.7%** slower (104,080 vs 104,866) — pure overhead, nothing to skip. - The guard is order of magnitudes cheaper than prop assignment, adding it has negligible effect. - Adding the guard adds 5 bytes of HBC per occurrence (plus one-time 16 bytes for the "default" string table entry - which is there in a real app anyway). ## Changelog ``` - **[Experimental]** experimentalImportSupport: Fix `export * from` incorrectly re-exporting default exports ``` Reviewed By: huntie Differential Revision: D111910097 fbshipit-source-id: 5c93c35fa8fd17470bed14caae9f95347fccf66d (cherry picked from commit 11ad083acb9e134a2d583cea697640ef6592c95b) --- .../__tests__/import-export-plugin-test.js | 78 +++++++++++++++++++ .../src/import-export-plugin.js | 1 + 2 files changed, 79 insertions(+) diff --git a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js index 0852228a04..2995a584ec 100644 --- a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js @@ -328,6 +328,7 @@ test('exports members of another module directly from an import (as all)', () => var _bar = require('bar'); for (var _key in _bar) { + if (_key === "default") continue; exports[_key] = _bar[_key]; } `; @@ -376,6 +377,7 @@ test('places export all above explicit exports', () => { var _foo = require('foo'); for (var _key in _foo) { + if (_key === "default") continue; exports[_key] = _foo[_key]; } @@ -425,6 +427,82 @@ test('explicit exports override export all at runtime', () => { expect(context.exports.sourceOnly).toBe('source only'); }); +test('export all does not re-export the default of the source', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + ` + export * from 'foo'; + `, + opts, + ), + ).code; + const context = { + exports: {} as {[string]: unknown}, + require: (id: string) => { + if (id !== 'foo') { + throw new Error(`Unexpected module: ${id}`); + } + return { + default: 'star default', + named: 'star named', + }; + }, + }; + + vm.runInNewContext(transformedCode, context); + + // Per the ES spec (GetExportedNames) and Node.js, `export *` re-exports + // named exports but never the source module's default export. + expect(context.exports.named).toBe('star named'); + expect('default' in context.exports).toBe(false); +}); + +test('export all as namespace includes the default of the source', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + ` + export * as ns from 'foo'; + `, + opts, + ), + ).code; + const source = { + __esModule: true, + default: 'star default', + named: 'star named', + }; + // Faithful stand-in for metroImportAll: resolve the module via require, + // then expose an ES module's namespace as-is (default included). + const requireMock = (id: string) => { + if (id !== 'foo') { + throw new Error(`Unexpected module: ${id}`); + } + return source; + }; + const exportsObj: {[string]: unknown} = {}; + const context: {[string]: unknown} = { + exports: exportsObj, + require: requireMock, + }; + context[opts.importAll] = (id: string) => { + const mod = requireMock(id); + return mod.__esModule === true ? mod : {...mod, default: mod}; + }; + + vm.runInNewContext(transformedCode, context); + + // `export * as ns` (ExportNamespaceSpecifier) creates a namespace object, + // which per the ES spec DOES expose the source module's default export - + // unlike bare `export *`. + expect(exportsObj.ns).toEqual({ + __esModule: true, + default: 'star default', + named: 'star named', + }); +}); + test('re-export dependencies evaluate before module body at runtime', () => { const transformedCode = generate( transformToAst( diff --git a/packages/metro-transform-plugins/src/import-export-plugin.js b/packages/metro-transform-plugins/src/import-export-plugin.js index 5d941bab5f..fac55bcdd3 100644 --- a/packages/metro-transform-plugins/src/import-export-plugin.js +++ b/packages/metro-transform-plugins/src/import-export-plugin.js @@ -87,6 +87,7 @@ const exportAllTemplate = template.statements(` var REQUIRED = require(FILE); for (var KEY in REQUIRED) { + if (KEY === "default") continue; exports[KEY] = REQUIRED[KEY]; } `); From 9c66034859de8f108ec95cd984069f2c9858e0c4 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Wed, 5 Aug 2026 08:17:14 -0700 Subject: [PATCH 19/45] Variable naming: clarify what "filename" is Summary: I need to double-check this every time, so use some clearer labelling in opaque types + names + comments, where it's API neutral. Changelog: Internal Reviewed By: huntie Differential Revision: D114616012 fbshipit-source-id: a615e4467ba249f5a815d527457cd88407884bd9 (cherry picked from commit 42a6502adce682e075d19ef33298aad1ec705068) --- packages/metro-transform-worker/src/index.js | 21 ++++++++++--------- .../metro-transform-worker/types/index.d.ts | 4 ++-- .../metro/src/DeltaBundler/Transformer.js | 9 +++++--- .../metro/src/DeltaBundler/Worker.flow.js | 8 +++---- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/metro-transform-worker/src/index.js b/packages/metro-transform-worker/src/index.js index 8f7cb85d01..acff7eb377 100644 --- a/packages/metro-transform-worker/src/index.js +++ b/packages/metro-transform-worker/src/index.js @@ -130,11 +130,12 @@ export type JsTransformOptions = Readonly<{ unstable_transformProfile: TransformProfile, }>; -opaque type Path = string; +opaque type AbsolutePath = string; +opaque type ProjectRelativePath = string; type BaseFile = Readonly<{ code: string, - filename: Path, + filename: ProjectRelativePath, inputFileSize: number, }>; @@ -160,7 +161,7 @@ type JSONFile = { type TransformationContext = Readonly<{ config: JsTransformerConfig, - projectRoot: Path, + projectRoot: AbsolutePath, options: JsTransformOptions, }>; @@ -630,13 +631,13 @@ async function transformJSON( } function getBabelTransformArgs( - file: Readonly<{filename: Path, code: string, ...}>, + file: Readonly<{filename: ProjectRelativePath, code: string, ...}>, {options, config, projectRoot}: TransformationContext, plugins?: Plugins = [], ): BabelTransformerArgs { const {inlineRequires: _, ...babelTransformerOptions} = options; return { - filename: file.filename, + filename: file.filename, // System-separated, project-root-relative options: { ...babelTransformerOptions, enableBabelRCLookup: config.enableBabelRCLookup, @@ -654,7 +655,7 @@ function getBabelTransformArgs( export const transform = async ( config: JsTransformerConfig, projectRoot: string, - filename: string, + projectRelativePath: string, data: Buffer, options: JsTransformOptions, ): Promise => { @@ -687,10 +688,10 @@ export const transform = async ( } } - if (filename.endsWith('.json')) { + if (projectRelativePath.endsWith('.json')) { const jsonFile: JSONFile = { code: sourceCode, - filename, + filename: projectRelativePath, inputFileSize: data.length, type: options.type, }; @@ -701,7 +702,7 @@ export const transform = async ( if (options.type === 'asset') { const file: AssetFile = { code: sourceCode, - filename, + filename: projectRelativePath, inputFileSize: data.length, type: options.type, }; @@ -711,7 +712,7 @@ export const transform = async ( const file: JSFile = { code: sourceCode, - filename, + filename: projectRelativePath, functionMap: null, inputFileSize: data.length, type: options.type === 'script' ? 'js/script' : 'js/module', diff --git a/packages/metro-transform-worker/types/index.d.ts b/packages/metro-transform-worker/types/index.d.ts index 5878494cfe..0ce6451fa2 100644 --- a/packages/metro-transform-worker/types/index.d.ts +++ b/packages/metro-transform-worker/types/index.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<5fe0f33f61a5a4a94ab429ad35b5e04a>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-transform-worker/src/index.js @@ -102,7 +102,7 @@ type TransformResponse = Readonly<{ export declare const transform: ( config: JsTransformerConfig, projectRoot: string, - filename: string, + projectRelativePath: string, data: Buffer, options: JsTransformOptions, ) => Promise; diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index 28c4a502da..d3c92a0dfd 100644 --- a/packages/metro/src/DeltaBundler/Transformer.js +++ b/packages/metro/src/DeltaBundler/Transformer.js @@ -108,7 +108,10 @@ export default class Transformer { } } - const localPath = path.relative(this._config.projectRoot, filePath); + const projectRelativePath = path.relative( + this._config.projectRoot, + filePath, + ); const partialKey = stableHash([ // This is the hash related to the global Bundler config. @@ -117,7 +120,7 @@ export default class Transformer { // Project-relative, posix-separated path for portability. Necessary in // addition to content hash because transformers receive path as an // input, and may apply e.g. extension-based logic. - normalizePathSeparatorsToPosix(localPath), + normalizePathSeparatorsToPosix(projectRelativePath), customTransformOptions, dev, experimentalImportSupport, @@ -166,7 +169,7 @@ export default class Transformer { }> = result ? {result, sha1} : await this._workerFarm.transform( - localPath, + projectRelativePath, transformerOptions, content, ); diff --git a/packages/metro/src/DeltaBundler/Worker.flow.js b/packages/metro/src/DeltaBundler/Worker.flow.js index 9ee67f53f6..27e2776cfc 100644 --- a/packages/metro/src/DeltaBundler/Worker.flow.js +++ b/packages/metro/src/DeltaBundler/Worker.flow.js @@ -95,7 +95,7 @@ export type Worker = { }; async function transformFile( - filename: string, + projectRelativePath: string, data: Buffer, transformOptions: JsTransformOptions, projectRoot: string, @@ -110,7 +110,7 @@ async function transformFile( const transformFileStartLogEntry: LogEntry = { action_name: 'Transforming file', action_phase: 'start', - file_name: filename, + file_name: projectRelativePath, log_entry_label: 'Transforming file', start_timestamp: process.hrtime(), }; @@ -120,7 +120,7 @@ async function transformFile( const result = await Transformer.transform( transformerConfig.transformerConfig, projectRoot, - filename, + projectRelativePath, data, transformOptions, ); @@ -135,7 +135,7 @@ async function transformFile( const transformFileEndLogEntry = getEndLogEntry( transformFileStartLogEntry, - filename, + projectRelativePath, ); return { From d7106e546598f1cfcb3acca5bd85e253da6f9d8d Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Fri, 14 Aug 2026 10:05:29 -0700 Subject: [PATCH 20/45] Runtime: Materialise lazily-registered modules during Fast Refresh Summary: Fast Refresh's dependency walk (`metroHotUpdateModule` in the require polyfill) assumes every module reachable through `inverseDependencies` is already present in the client module registry as an (uninitialised) `ModuleDefinition`. This is true for single-segment "plain" bundles, but doesn't hold for lazily defined segments. Currently these are only used in non-`dev` builds so Fast Refresh doesn't need to care about them, but in the spirit of bringing dev and prod as close together as possible, and unforking serialiser implementations in the process, we want to expose segmented bundles in dev. Fortunately it's quite easy - the pieces we need (`definingSegmentByModuleID`, `moduleDefinersBySegmentID`) are already in the runtime. We only need to define (note, not initialise/evalaute - we're not running user code) them, just in time. This extra step - `ensureModuleRegistered` - is entirely contained within the Fast Refresh pass so there's no prod impact at all, and it's a no-op for plain (eager) bundles. Changelog: ``` - **[Fix]** Fix Fast Refresh hitting undefined modules when using lazy (segmented/switch) bundles in dev ``` Reviewed By: vzaidman Differential Revision: D113665912 fbshipit-source-id: c648c0457d33b468ae73402b1a5c85b333bd2eaa (cherry picked from commit c70d0ae66cf381d50e1fc59c4c4aa806d050e9dd) --- .../__tests__/MetroFastRefreshMockRuntime.js | 12 ++++ .../fast-refresh-integration-test.js | 66 +++++++++++++++++++ .../metro-runtime/src/polyfills/require.js | 21 ++++++ 3 files changed, 99 insertions(+) diff --git a/packages/metro-runtime/src/polyfills/__tests__/MetroFastRefreshMockRuntime.js b/packages/metro-runtime/src/polyfills/__tests__/MetroFastRefreshMockRuntime.js index d24841019f..2a313ba860 100644 --- a/packages/metro-runtime/src/polyfills/__tests__/MetroFastRefreshMockRuntime.js +++ b/packages/metro-runtime/src/polyfills/__tests__/MetroFastRefreshMockRuntime.js @@ -41,6 +41,17 @@ export class Runtime { */ metroRequire: RequireFn; + /** + * Registers a lazy segment module definer (see require.js + * `__registerSegment`). Used to model bundles that define modules lazily, + * such as the Buck "plain bundle with switch" output. + */ + registerSegment: ( + segmentId: number, + moduleDefiner: (moduleId: number) => void, + moduleIds?: ?ReadonlyArray, + ) => void; + // Special modules /** @@ -83,6 +94,7 @@ export class Runtime { createModuleSystem(this.#global, /* __DEV__ */ true, this.#globalPrefix); this.define = this.#global[this.#globalPrefix + '__d']; this.metroRequire = this.#global[this.#globalPrefix + '__r']; + this.registerSegment = this.#global.__registerSegment; // Set up Fast Refresh. Adapted from `setUpReactRefresh.js` in React Native. jest.isolateModules(() => { diff --git a/packages/metro-runtime/src/polyfills/__tests__/fast-refresh-integration-test.js b/packages/metro-runtime/src/polyfills/__tests__/fast-refresh-integration-test.js index 4ac612f7af..f5e878b197 100644 --- a/packages/metro-runtime/src/polyfills/__tests__/fast-refresh-integration-test.js +++ b/packages/metro-runtime/src/polyfills/__tests__/fast-refresh-integration-test.js @@ -128,4 +128,70 @@ describe('Fast Refresh integration with require()', () => { ['Fast Refresh - Invalidated boundary '], ]); }); + + test('handles a lazily-registered (unloaded) parent during Fast Refresh', async () => { + const {define, metroRequire, registerSegment, events} = new Runtime(); + + const ids = { + 'util.js': 1, + 'UnloadedScreen.js': 2, + }; + + // A parent module that imports util.js but is never required in this + // session. It is only reachable through a lazy segment definer, mirroring + // the Buck "plain bundle with switch" output: it is absent from the module + // registry until first required, whereas an eager bundle would have defined + // it (uninitialized) at startup. + registerSegment(0, moduleId => { + if (moduleId === ids['UnloadedScreen.js']) { + define( + (global, require, _2, _3, module) => { + require(ids['util.js']); + module.exports = {}; + }, + ids['UnloadedScreen.js'], + {0: ids['util.js']}, + 'UnloadedScreen.js', + ); + } + }); + + // util.js is a plain (non-component) module, loaded directly. + define( + (global, _1, _2, _3, module) => { + module.exports = {value: 'v1'}; + }, + ids['util.js'], + undefined, + 'util.js', + ); + expect(metroRequire(ids['util.js'])).toEqual({value: 'v1'}); + + // Edit util.js. Its inverse dependencies include UnloadedScreen.js, which + // has never been required and so is absent from the registry. Before + // materialising it on demand, the Fast Refresh dependency walk would throw + // "[Refresh] Expected to find the updated module." + expect(() => { + define( + (global, _1, _2, _3, module) => { + module.exports = {value: 'v2'}; + }, + ids['util.js'], + {}, + 'util.js', + // Inverse dependency map + { + [ids['util.js']]: [ids['UnloadedScreen.js']], + [ids['UnloadedScreen.js']]: [], + }, + ); + jest.runAllTimers(); + }).not.toThrow(); + + // Matches the behaviour of an eager bundle: no boundary is found up the + // chain of the (now materialised, uninitialized) unloaded parent, so we + // fall back to a full reload rather than crashing. + expect(events.onFullReload).toHaveBeenCalled(); + expect(events.onFastRefresh).not.toHaveBeenCalled(); + }); }); diff --git a/packages/metro-runtime/src/polyfills/require.js b/packages/metro-runtime/src/polyfills/require.js index 660444bae6..e263bad03e 100644 --- a/packages/metro-runtime/src/polyfills/require.js +++ b/packages/metro-runtime/src/polyfills/require.js @@ -577,6 +577,23 @@ if (__DEV__) { let reactRefreshTimeout: null | TimeoutID = null; + // When a module is defined lazily via a segment definer, modules that + // have not been required yet are absent from `modules`, and can't be walked + // by Fast Refresh. This materialises an absent module definition on demand + // by invoking its segment definer, which only *defines* the module + // (registers its factory) without executing it. No-op if already defined. + const ensureModuleRegistered = function (moduleId: ModuleID): void { + if (modules.has(moduleId) || moduleDefinersBySegmentID.length === 0) { + return; + } + const segmentId = definingSegmentByModuleID.get(moduleId) ?? 0; + const definer = moduleDefinersBySegmentID[segmentId]; + if (definer != null) { + definer(moduleId); + definingSegmentByModuleID.delete(moduleId); + } + }; + const metroHotUpdateModule = function ( id: ModuleID, factory: FactoryFn, @@ -650,6 +667,9 @@ if (__DEV__) { updatedModuleIDs = topologicalSort( [id], // Start with the changed module and go upwards pendingID => { + // Modules delivered by a lazy segment definer are not present in + // `modules` until first required - ensure they are defined. + ensureModuleRegistered(pendingID); const pendingModule = modules.get(pendingID); if (pendingModule == null) { // Nothing to do. @@ -778,6 +798,7 @@ if (__DEV__) { // Schedule all parent refresh boundaries to re-run in this loop. for (let j = 0; j < parentIDs.length; j++) { const parentID = parentIDs[j]; + ensureModuleRegistered(parentID); const parentMod = modules.get(parentID); if (parentMod == null) { throw new Error('[Refresh] Expected to find parent module.'); From 6889e7ca36dac95b4e981e530a70ad3f639d15fb Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 17 Aug 2026 02:11:25 -0700 Subject: [PATCH 21/45] Drop ci-info dependency, respect process.env.CI directly for watch default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: `ci-info` was a direct runtime dependency of `metro` with exactly one use: choosing the default value of `watch` in `createFileMap`. Replace it with a local `isCIEnv()` helper reading `process.env.CI`, and drop the dependency along with its now-unused Flow libdef. This also fixes an opt-out bug. Metro pinned `ci-info@^2.0.0`, whose `isCI` is a plain truthiness check: ``` exports.isCI = !!(env.CI || env.CONTINUOUS_INTEGRATION || env.BUILD_NUMBER || env.RUN_ID || exports.name || false) ``` so `CI=false` and `CI=0` were both treated as "in CI" — the opposite of what someone setting them intends. `ci-info@3` later added an `env.CI !== 'false'` bypass for precisely this reason. `isCIEnv()` adopts the v3 semantics (and additionally honours `CI=0`, another conventional falsey spelling) without taking a major upgrade of a dependency we no longer want. The tradeoff is narrower CI detection: we no longer infer CI from `CONTINUOUS_INTEGRATION`, `BUILD_NUMBER`, `RUN_ID`, or ci-info's `vendors.json` list, so a CI that does not export `CI` (Jenkins, TeamCity) now defaults to `watch: true`. That is acceptable here because: - every mainstream CI (GitHub Actions, CircleCI, GitLab CI, Buildkite, Travis) sets `CI=true` — that is the convention this variable exists for; - this is only a *default*. `watch` is an explicit option on `runServer` / `DependencyGraph` / `createFileMap`, and `runBuild` already passes `watch: false`; - the cost of a miss is a filesystem watcher we did not need in a `metro serve` process, not incorrect output. `ci-info@3` remains in the lockfiles as a transitive dev dependency of Jest. Reviewed By: vzaidman Differential Revision: D116225836 fbshipit-source-id: f8d1a7a6cfc98face0ad99255ad63556a7e27f2e (cherry picked from commit 61e4592f92f028d3c5f33af101d41654e88fe151) --- flow-typed/npm/ci-info_v2.x.x.js | 19 ------------------- packages/metro/package.json | 1 - .../DependencyGraph/createFileMap.js | 8 ++++++-- yarn.lock | 5 ----- 4 files changed, 6 insertions(+), 27 deletions(-) delete mode 100644 flow-typed/npm/ci-info_v2.x.x.js diff --git a/flow-typed/npm/ci-info_v2.x.x.js b/flow-typed/npm/ci-info_v2.x.x.js deleted file mode 100644 index ae8f285367..0000000000 --- a/flow-typed/npm/ci-info_v2.x.x.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict - * @format - * @oncall react_native - */ - -// $FlowFixMe[unsupported-syntax] -declare module 'ci-info' { - declare module.exports: { - isCI: boolean, - name: string, - isPR: boolean, - }; -} diff --git a/packages/metro/package.json b/packages/metro/package.json index 5b19820705..242ed2de56 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -27,7 +27,6 @@ "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", - "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", diff --git a/packages/metro/src/node-haste/DependencyGraph/createFileMap.js b/packages/metro/src/node-haste/DependencyGraph/createFileMap.js index c3c311153f..1fd5a4f68f 100644 --- a/packages/metro/src/node-haste/DependencyGraph/createFileMap.js +++ b/packages/metro/src/node-haste/DependencyGraph/createFileMap.js @@ -12,7 +12,6 @@ import type {ConfigT} from 'metro-config'; import type {HasteMap, InputFileMapPlugin} from 'metro-file-map'; -import ci from 'ci-info'; import MetroFileMap, { DependencyPlugin, DiskCacheManager, @@ -57,6 +56,11 @@ function getIgnorePattern(config: ConfigT): RegExp { return ignorePattern; } +function isCIEnv() { + const CI = process.env.CI; + return typeof CI === 'string' && CI !== '' && CI !== '0' && CI !== 'false'; +} + export default function createFileMap( config: ConfigT, options?: Readonly<{ @@ -70,7 +74,7 @@ export default function createFileMap( hasteMap: HasteMap, dependencyPlugin: ?DependencyPlugin, } { - const watch = options?.watch == null ? !ci.isCI : options.watch; + const watch = options?.watch ?? !isCIEnv(); const {enabled: autoSaveEnabled, ...autoSaveOpts} = config.watcher.unstable_autoSaveCache ?? {}; const autoSave = watch && autoSaveEnabled ? autoSaveOpts : false; diff --git a/yarn.lock b/yarn.lock index c7c366c50f..8853815c66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2177,11 +2177,6 @@ char-regex@^2.0.0: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-2.0.1.tgz#6dafdb25f9d3349914079f010ba8d0e6ff9cd01e" integrity sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw== -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== - ci-info@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.0.tgz#b4ed1fb6818dea4803a55c623041f9165d2066b2" From 0ee53c101e8524b51be4f587e6ef32657783b09b Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 17 Aug 2026 04:38:28 -0700 Subject: [PATCH 22/45] Babel preset: Make Platform inlining opt-in via inlinePlatform (default on via `@react-native/metro-babel-transformer`) Summary: X-link: https://github.com/react/react-native/pull/57973 `react-native/babel-preset` inlines `Platform.OS` and `Platform.select(...)` whenever it is handed a `platform` (since yesterday - https://github.com/react/react-native/pull/57848 ), but this is over-zealous - `platform` is a pre-existing transform option whose intent is to set the transform target. In some cases (eg, to allow `Platform` mocking from tests), we don't want `Platform` inlining, even though `platform` may already be passed for other reasons. Gate the inlining behind a separate `inlinePlatform` opt-in, resolved as `options.inlinePlatform ?? babel.caller(...) ?? false`, mirroring how `platform` and `unstable_transformProfile` are already resolved. Metro already models this as a distinct transform option, so both Babel transformers pass it straight through. The caller channel covers the case where the preset is named in a `babel.config.js` and so receives no preset options at all. Bundling is unaffected: Metro sets `inlinePlatform` on every transform, so `Platform` continues to be inlined through `react-native/metro-babel-transformer`. Changelog: [General][Changed] - `Platform.OS` and `Platform.select(...)` inlining in `react-native/babel-preset` now requires the `inlinePlatform` option in addition to `platform` Reviewed By: GijsWeterings Differential Revision: D116281656 fbshipit-source-id: 0230cd74f38661862396391da62cf81b34773b85 (cherry picked from commit 6bbe095c127803c62402c2a21d7b2bb819f1b11e) --- packages/metro-babel-transformer/src/index.js | 8 +++++++- packages/metro-babel-transformer/types/index.d.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/metro-babel-transformer/src/index.js b/packages/metro-babel-transformer/src/index.js index 4a6eb8aa8d..b9ee815a8d 100644 --- a/packages/metro-babel-transformer/src/index.js +++ b/packages/metro-babel-transformer/src/index.js @@ -40,6 +40,7 @@ type BabelTransformerOptions = Readonly<{ extendsBabelConfigPath?: string, experimentalImportSupport?: boolean, hermesParser?: boolean, + inlinePlatform?: boolean, minify: boolean, platform: ?string, projectRoot: string, @@ -105,7 +106,12 @@ function transform( const babelConfig: BabelTransformOptions = { ast: true, babelrc: options.enableBabelRCLookup, - caller: {bundler: 'metro', name: 'metro', platform: options.platform}, + caller: { + bundler: 'metro', + name: 'metro', + platform: options.platform, + inlinePlatform: options.inlinePlatform, + }, // NOTE(EvanBacon): We split the parse/transform steps up to accommodate // Hermes parsing, but this defaults to cloning the AST which increases // the transformation time by a fair amount. diff --git a/packages/metro-babel-transformer/types/index.d.ts b/packages/metro-babel-transformer/types/index.d.ts index 2a38a188d4..508593dac8 100644 --- a/packages/metro-babel-transformer/types/index.d.ts +++ b/packages/metro-babel-transformer/types/index.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<2503f88efd80b39e4d860870017af66d>> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-babel-transformer/src/index.js @@ -35,6 +35,7 @@ type BabelTransformerOptions = Readonly<{ extendsBabelConfigPath?: string; experimentalImportSupport?: boolean; hermesParser?: boolean; + inlinePlatform?: boolean; minify: boolean; platform: null | undefined | string; projectRoot: string; From 31488eeac16c29eeb10259ada6b89db90fb13a79 Mon Sep 17 00:00:00 2001 From: generatedunixname1608173377072046 Date: Tue, 25 Aug 2026 12:42:02 -0700 Subject: [PATCH 23/45] Handle request socket errors when writing to HttpStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: WARNING: Generated by Autopilot (alpha) — review carefully, verify the underlying claim before accepting. Agent: React Native Oncall Tests Fixer | Trajectory: https://www.internalfb.com/intern/devai/devmate/inspector/7753ac91-3b3f-43d3-a8aa-d9e336e50d0d/ | SC job: https://www.internalfb.com/intern/sandcastle/instance/67553994997036426/ --- The `react_native` Metro remote-cache warmer (`//tools/skycastle/workflows2/react_native/fb-metro-cache-warmer.sky:main`, test issue 237151770) has been failing at a 25-46% rate. The failing shard's log shows the actual cause once, at the top, followed by a long tail of consequences: ``` Uncaught exception: Error: read ECONNRESET at TLSWrap.onStreamRead (node:internal/stream_base_commons:216:20) Reporting error to the React Native team. ... 38 of 112 failures exceeds tolerance of 10%. Failing job. FetchError: request to http://localhost:37907/fb-metro-cache-stats failed, reason: connect ECONNREFUSED ::1:37907 ``` `HttpStore.#setOnce` attaches `req.on('timeout')` but — unlike `#getOnce` — never attaches `req.on('error')`. A Node `ClientRequest` that emits `'error'` with no listener throws it as an uncaught exception. Because the event is emitted asynchronously, it escapes the `new Promise` executor entirely, so `reject` is never called: `Cache.set`'s try/catch cannot see it and it lands in the CLI's `uncaughtException` handler, which calls `process.exit(1)`. That kills the Metro server the warmer spawned. Every bundle still queued then fails with `connect ECONNREFUSED` against the now-dead local server, which is why a single socket reset produces dozens of bundle failures and trips the 10% tolerance. The `ECONNREFUSED` tail is the symptom, not the cause — it accounts for 114 of the 177 recent failed runs. The reset itself is expected and benign: writes go to Manifold over TLS through a `keepAlive` agent with `maxSockets: 64`, so a pooled socket being reaped server-side races the next PUT. `setOptions` already specifies `retryNetworkErrors: true, maxAttempts: 3` for exactly this, but that retry path was dead for socket errors — a failure that never rejects can never be retried. Attach `req.on('error')` in `#setOnce`, mirroring `#getOnce`, rejecting with a `NetworkError` so `#withRetries` can retry it. Also attach `gzip.on('error')`: `gzip.pipe(req)` does not forward errors, so a destroyed request mid-upload can leave the upload stream emitting unhandled `'error'` too. This also restores the warmer's documented intent — `fill-http-cache` already treats `cache_write_error` as non-fatal ("Cache warming is best-effort") — which the uncaught path was defeating, since the error never became a `cache_write_error` event. Reviewed By: javache Differential Revision: D116631955 fbshipit-source-id: b80baceb2b812d5f0b885175299233c73a5cd637 Adapted for 0.84.x: Tests mock `http`, not `node:http`. (cherry picked from commit fad3b88742034a8c1cbb9cc098fd35128b597870) --- packages/metro-cache/src/stores/HttpStore.js | 12 +++++ .../src/stores/__tests__/HttpStore-test.js | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/packages/metro-cache/src/stores/HttpStore.js b/packages/metro-cache/src/stores/HttpStore.js index 97c90645b5..c0d8f43ebe 100644 --- a/packages/metro-cache/src/stores/HttpStore.js +++ b/packages/metro-cache/src/stores/HttpStore.js @@ -369,10 +369,22 @@ export default class HttpStore { res.resume(); }); + // Without this listener a socket-level failure mid-upload (e.g. the peer + // resetting a pooled keep-alive connection) is emitted as an unhandled + // 'error' and takes down the whole process instead of rejecting, which + // also makes #withRetries unable to retry the write. + req.on('error', err => { + reject(new NetworkError(err.message, err.code)); + }); + req.on('timeout', () => { req.destroy(new Error('Request timed out')); }); + gzip.on('error', err => { + reject(err); + }); + gzip.pipe(req); if (value instanceof Buffer) { diff --git a/packages/metro-cache/src/stores/__tests__/HttpStore-test.js b/packages/metro-cache/src/stores/__tests__/HttpStore-test.js index 6802648844..7cb8ec1190 100644 --- a/packages/metro-cache/src/stores/__tests__/HttpStore-test.js +++ b/packages/metro-cache/src/stores/__tests__/HttpStore-test.js @@ -365,6 +365,56 @@ describe('HttpStore', () => { }); }); + test('rejects with a NetworkError when the set request socket errors', async () => { + const store = new HttpStore({ + endpoint: 'http://example.com', + maxAttempts: 1, + }); + const promise = store.set(Buffer.from('key-set'), {foo: 42}); + const [opts] = require('http').request.mock.calls[0]; + + expect(opts.method).toEqual('PUT'); + + const err = new Error('read ECONNRESET'); + err.code = 'ECONNRESET'; + httpPassThrough.emit('error', err); + + await expect(promise).rejects.toMatchObject({ + constructor: HttpStore.NetworkError, + code: 'ECONNRESET', + message: 'read ECONNRESET', + }); + }); + + test('set() retries a request socket error when retryNetworkErrors is set', async () => { + jest.useRealTimers(); + const store = new HttpStore({ + endpoint: 'http://example.com', + maxAttempts: 2, + retryNetworkErrors: true, + }); + const {request} = require('http'); + + request.mockImplementation((opts, callback) => { + const req = new PassThrough(); + + if (request.mock.calls.length === 1) { + const err = new Error('read ECONNRESET'); + err.code = 'ECONNRESET'; + process.nextTick(() => req.emit('error', err)); + } else { + callback(responseHttpOk('')); + req.resume(); + } + + return req; + }); + + await store.set(Buffer.from('key-set'), {foo: 42}); + + expect(request).toHaveBeenCalledTimes(2); + }); + test('set() resolves when the HTTP code is in additionalSuccessStatuses', done => { const store = new HttpStore({ endpoint: 'http://www.example.com/endpoint', From d00434b7d86c0f6e04d928c2fdbfe8804f598cae Mon Sep 17 00:00:00 2001 From: Oskar Eichler Date: Fri, 4 Sep 2026 03:19:05 -0700 Subject: [PATCH 24/45] Preserve Platform write targets during inlining (#1890) Summary: Metro replaces `Platform.OS` with a string literal unless it is the direct left side of an assignment. Update/delete expressions, destructuring assignment targets, and direct or nested `for...in` / `for...of` targets can therefore be changed semantically or make Babel abort with an invalid literal assignment target. Walk the member expression's ancestors and preserve it whenever the containing expression is the left side of an assignment or loop, or the argument of an update/delete operation. The React Native Babel preset has a parallel transform; companion [React Native PR #58252](https://github.com/react/react-native/pull/58252) applies the same guard there. Changelog: ``` - **[Fix]**: Preserve `Platform.OS` write targets during constant inlining ``` Pull Request resolved: https://github.com/react/metro/pull/1890 Test Plan: - Added one combined regression for update/delete, array/object destructuring assignments, and direct/nested `for...in` and `for...of` targets. - Exact baseline aborts on the direct `for...of` target; the fixed suite preserves all eight forms. - All `metro-transform-plugins` suites pass: 6/6 suites, 160/160 tests, 41 snapshots. - Fresh full Flow check reports 0 errors. - Full monorepo build passes for all 17 packages. - Targeted ESLint, Prettier, and `git diff --check` pass. No public API or intended read-transform behavior changes. Reviewed By: GijsWeterings Differential Revision: D118440964 Pulled By: vzaidman fbshipit-source-id: eb4a934c1c8d469618af290356377555bcca6733 (cherry picked from commit b15a67b449be74d2387c35f163ab1935a6d682b4) --- .../src/__tests__/inline-plugin-test.js | 31 ++++++++++++ .../src/inline-plugin.js | 47 ++++++++++++++++--- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js index a1b1010354..711781d543 100644 --- a/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js @@ -709,6 +709,37 @@ describe('inline constants', () => { }); }); + test("doesn't replace Platform.OS in other write targets", () => { + const code = ` + Platform.OS++; + delete Platform.OS; + [Platform.OS] = values; + ({os: Platform.OS} = value); + for (Platform.OS in object) {} + for ([Platform.OS] in nestedObject) {} + for (Platform.OS of values) {} + for ([Platform.OS] of nestedValues) {} + `; + + compare([inlinePlugin], code, code, { + inlinePlatform: true, + platform: 'ios', + }); + }); + + test('replaces Platform.OS when it is read inside a write target', () => { + const code = ` + target[Platform.OS] = value; + [target[Platform.OS]] = values; + ({[Platform.OS]: target} = value); + `; + + compare([inlinePlugin], code, code.replaceAll('Platform.OS', '"ios"'), { + inlinePlatform: true, + platform: 'ios', + }); + }); + test('replaces Platform.OS in the code if Platform is the right hand side of an assignment expression', () => { const code = ` function a() { diff --git a/packages/metro-transform-plugins/src/inline-plugin.js b/packages/metro-transform-plugins/src/inline-plugin.js index 2cc055d260..d548fcb1f5 100644 --- a/packages/metro-transform-plugins/src/inline-plugin.js +++ b/packages/metro-transform-plugins/src/inline-plugin.js @@ -44,7 +44,6 @@ export default function inlinePlugin( options: Options, ): PluginObj { const { - isAssignmentExpression, isIdentifier, isMemberExpression, isObjectExpression, @@ -69,10 +68,46 @@ export default function inlinePlugin( return !binding || isFlowDeclared(binding); } - const isLeftHandSideOfAssignmentExpression = ( - node: Node, - parent: Node, - ): boolean => isAssignmentExpression(parent) && parent.left === node; + function isWriteTarget(path: NodePath): boolean { + let child: Node = path.node; + let parentPath = path.parentPath; + + while (parentPath != null) { + const parent = parentPath.node; + if ( + (parent.type === 'AssignmentExpression' || + parent.type === 'ForInStatement' || + parent.type === 'ForOfStatement') && + parent.left === child + ) { + return true; + } + if (parent.type === 'UpdateExpression' && parent.argument === child) { + return true; + } + if ( + parent.type === 'UnaryExpression' && + parent.operator === 'delete' && + parent.argument === child + ) { + return true; + } + + const nestedWriteTarget = + parent.type === 'ArrayPattern' || + parent.type === 'ObjectPattern' || + (parent.type === 'ObjectProperty' && parent.value === child) || + (parent.type === 'RestElement' && parent.argument === child) || + (parent.type === 'AssignmentPattern' && parent.left === child); + if (!nestedWriteTarget) { + return false; + } + + child = parent; + parentPath = parentPath.parentPath; + } + return false; + } const isProcessEnvNodeEnv = (node: MemberExpression, scope: Scope): boolean => isIdentifier(node.property, nodeEnv) && @@ -138,7 +173,7 @@ export default function inlinePlugin( const scope = path.scope; const opts = state.opts; - if (!isLeftHandSideOfAssignmentExpression(node, path.parent)) { + if (!isWriteTarget(path)) { if ( opts.inlinePlatform && isPlatformNode(node, scope, !!opts.isWrapped) From f8f03702f3872c6a4d370eb4fa9f864c6205cecd Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Fri, 4 Sep 2026 03:28:03 -0700 Subject: [PATCH 25/45] Server: Add a charset to Content-Type for text assets and sources (#1888) Summary: Metro's dev server sets `Content-Type` with `mime.lookup()`, both for `/assets/` and for source files under `/[metro-project]/`. That returns a bare type like `text/html` with no charset, so clients fall back to their own default - https://github.com/react/metro/issues/859 is a `WebView` rendering a UTF-8 `.html` asset as mojibake. `mime-types` has `contentType()` for this. It appends `; charset=utf-8` where a charset applies and is otherwise identical, including returning `false` for an unrecognised extension, so binary assets are unaffected. Fixes: https://github.com/react/metro/issues/859 Changelog: ``` - **[Fix]**: Include `charset=utf-8` in the `Content-Type` of text assets and source files served by the dev server ``` Pull Request resolved: https://github.com/react/metro/pull/1888 Test Plan: ``` yarn jest packages/metro/src/Server yarn flow check ``` New cases for a text asset, a source file and a binary file. The charset assertions fail on `main`. Reviewed By: GijsWeterings Differential Revision: D118437775 Pulled By: vzaidman fbshipit-source-id: 57a7c5809fa8638f33025b96a4aa1c90f8795b12 (cherry picked from commit eeb7934c5579a1a045411c6cb1443741f9c8763e) --- packages/metro/src/Server.js | 4 +-- .../metro/src/Server/__tests__/Server-test.js | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 6aa06a2dc1..9909887e64 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -579,7 +579,7 @@ export default class Server { if (process.env.REACT_NATIVE_ENABLE_ASSET_CACHING === true) { res.setHeader('Cache-Control', 'max-age=31536000'); } - res.setHeader('Content-Type', mime.lookup(path.basename(assetPath))); + res.setHeader('Content-Type', mime.contentType(path.basename(assetPath))); res.end(this._rangeRequestMiddleware(req, res, data, assetPath)); process.nextTick(() => { log(createActionEndEntry(processingAssetRequestLogEntry)); @@ -746,7 +746,7 @@ export default class Server { res.end(); return; } - const mimeType = mime.lookup(path.basename(relativeFilePathname)); + const mimeType = mime.contentType(path.basename(relativeFilePathname)); res.setHeader('Content-Type', mimeType); const stream = fs.createReadStream(filePath); stream.pipe(res); diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 47d7e6f954..43190396cc 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -312,6 +312,7 @@ describe('processRequest', () => { load: jest.fn(() => Promise.resolve()), getWatcher: jest.fn(() => ({})), doesFileExist: jest.fn().mockReturnValue(true), + getOrComputeSha1: jest.fn(() => Promise.resolve({sha1: 'abcdef'})), }), ); @@ -901,6 +902,17 @@ describe('processRequest', () => { ); }); + test('should return a charset in the content-type header for a text asset', async () => { + const mockData = 'i am html'; + getAsset.mockResolvedValue(mockData); + + const response = await makeRequest('/assets/docs/a.html?platform=ios'); + + expect(response.getHeader('content-type')).toBe( + 'text/html; charset=utf-8', + ); + }); + test("should serve assets files's name contain non-latin letter", async () => { getAsset.mockResolvedValue('i am image'); @@ -964,6 +976,29 @@ describe('processRequest', () => { }); }); + describe('source requests', () => { + beforeEach(() => { + fs.mkdirSync('/root'); + fs.writeFileSync('/root/foo.js', '// \u3053\u3093\u306b\u3061\u306f\n'); + fs.writeFileSync('/root/logo.png', 'not really a png'); + }); + + test('serves a source file with a utf-8 charset', async () => { + const response = await makeRequest('/[metro-project]/foo.js'); + + expect(response.getHeader('content-type')).toBe( + 'text/javascript; charset=utf-8', + ); + expect(response._getString()).toBe('// \u3053\u3093\u306b\u3061\u306f\n'); + }); + + test('does not add a charset to a binary file', async () => { + const response = await makeRequest('/[metro-project]/logo.png'); + + expect(response.getHeader('content-type')).toBe('image/png'); + }); + }); + describe('build(options)', () => { test('Calls the delta bundler with the correct args', async () => { await server.build({ From df380ed9f2c3d0eba8ddff8641efdd75a0638338 Mon Sep 17 00:00:00 2001 From: Oskar Eichler Date: Fri, 4 Sep 2026 05:11:31 -0700 Subject: [PATCH 26/45] Inline the last duplicate Platform.select key (#1889) Summary: Metro statically replaces `Platform.select({...})` for the target platform. Its property scan currently stops at the first matching key, while JavaScript object-literal evaluation keeps the last duplicate definition. For example, `Platform.select({ios: 1, ios: 2})` runs as `2` but Metro compiles it to `1`. Scan the already-validated static properties from the end and return the first reverse match. This preserves O(n) behavior, avoids an unnecessary nullable temporary, and keeps compiled output consistent with runtime object semantics. The React Native Babel preset contains a parallel transform; companion [React Native PR #58249](https://github.com/facebook/react-native/pull/58249) applies the same rule there. Changelog: ``` - **[Fix]**: Inline the last duplicate key from static `Platform.select` object literals ``` Pull Request resolved: https://github.com/react/metro/pull/1889 Test Plan: - Added a focused `metro-transform-plugins` regression; it fails on pristine main with `1` and passes with `2` after the fix. - All package suites pass: 6/6 suites, 160/160 tests, 41 snapshots. - Full Flow check reports no errors. - Full monorepo build passes for all 17 packages. - Targeted ESLint, Prettier, and `git diff --check` pass. No behavior changes for object literals without duplicate static keys. Reviewed By: GijsWeterings Differential Revision: D118499357 Pulled By: vzaidman fbshipit-source-id: 1036c78826c4e7d12a94baa8b8eeb1a04f9f8f60 (cherry picked from commit c7bd299fe08486043da6179022e29b66003fe140) --- .../src/__tests__/inline-plugin-test.js | 11 +++++++++++ .../metro-transform-plugins/src/inline-plugin.js | 14 ++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js index 711781d543..fe2fb8d154 100644 --- a/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js @@ -399,6 +399,17 @@ describe('inline constants', () => { }); }); + test('uses the last definition when Platform.select has duplicate keys', () => { + const code = ` + var value = Platform.select({ios: 1, ios: 2}); + `; + + compare([inlinePlugin], code, 'var value = 2;', { + inlinePlatform: true, + platform: 'ios', + }); + }); + test('inlines Platform.select in the code when using an ObjectMethod', () => { const code = ` function a() { diff --git a/packages/metro-transform-plugins/src/inline-plugin.js b/packages/metro-transform-plugins/src/inline-plugin.js index d548fcb1f5..4fc5dfb49d 100644 --- a/packages/metro-transform-plugins/src/inline-plugin.js +++ b/packages/metro-transform-plugins/src/inline-plugin.js @@ -125,9 +125,9 @@ export default function inlinePlugin( key: string, fallback: () => Node, ): Node { - let value = null; - - for (const p of objectExpression.properties) { + // Object literal evaluation keeps the last definition of a duplicate key. + for (let i = objectExpression.properties.length - 1; i >= 0; i--) { + const p = objectExpression.properties[i]; if (!isObjectProperty(p) && !isObjectMethod(p)) { continue; } @@ -136,16 +136,14 @@ export default function inlinePlugin( (isStringLiteral(p.key) && p.key.value === key) ) { if (isObjectProperty(p)) { - value = p.value; - break; + return p.value; } else if (isObjectMethod(p)) { - value = t.toExpression(p); - break; + return t.toExpression(p); } } } - return value ?? fallback(); + return fallback(); } function hasStaticProperties(objectExpression: ObjectExpression): boolean { From 7590c5270e91160b6256c6811f5bf54509c153f4 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Mon, 7 Sep 2026 07:55:24 -0700 Subject: [PATCH 27/45] metro-file-map: Drop `walker` dependency from `FallbackWatcher`, reduce RSS+heap (#1906) Summary: `FallbackWatcher`'s `recReaddir` is the only consumer of [`walker`](https://www.npmjs.com/package/walker) (unmaintained since 2021, three deps) in Metro. `walker` schedules an `lstat` for every entry the moment the parent directory is read, so a crawl has as many in-flight `fs` requests. Those requests queue against a libuv thread pool of 4, so the concurrency buys nothing and the queued request state is pure memory overhead. Note - I *don't* believe this is responsible for `EMFILE: Too many open files`, as libuv's limits should prevent that. This diff replaces it with a plain `async` `recReaddir` - a depth-first stack drained by a pool of at most 32 concurrent visits. The syscalls are identical, one `lstat` per entry and one `readdir` per directory, and so are the filters: `ignored` is still evaluated against a directory before it's descended into or reported. `endCallback` goes away in favour of awaiting, and `normalizeProxy` also goes because `path.join` already normalises. The win32 settling delay is preserved and moves up into `startWatching`, which is the only place it was used. Crawl of this repo's `node_modules`, 33,394 entries (3,160 directories, 30,054 files, 180 symlinks), median of 5 runs, macOS 25.6 on APFS, node 22.13.1: | variant | wall (ms) | RSS (MB) | peak heap (MB) | max in-flight `readdir` | |---|---|---|---|---| | `walker` | 107.2 | 102.8 | 21.7 | 818 | | bounded, 16 | 107.4 | 66.3 | 11.4 | 16 | | bounded, 32 | 103.9 | 67.5 | 12.8 | 32 | | bounded, 64 | 100.9 | 71.8 | 14.5 | 64 | | bounded, 256 | 97.2 | 85.4 | 20.2 | 239 | Both report the same 3,160/30,054/180 and issue the same number of syscalls. Wall time is constrained by the libuv pool concurrency, so 32 is chosen for the memory: **-34% RSS and -41% peak heap** against `walker`. Changelog: Internal Pull Request resolved: https://github.com/react/metro/pull/1906 Test Plan: - `watchers/__tests__/integration-test.js` on all supported OSes, which drives `FallbackWatcher` against a real temporary tree - new/changed/deleted files, symlinks to a file, to a directory and to a non-existent target, pre-existing files, a directory moved in from outside the watch root, a directory moved out, and deletion of a directory with files under it. ``` yarn jest packages/metro-file-map yarn jest packages/metro/src/DeltaBundler/__tests__/resolver-test.js yarn flow check yarn lint ``` Reviewed By: GijsWeterings Differential Revision: D119076649 Pulled By: vzaidman fbshipit-source-id: f85fd675e37d8c4360d8ee2fef8e48cfdb952e81 Adapted for 0.84.x: Node.js builtins imported without the `node:` scheme. (cherry picked from commit 3173de195c607508a9ff1bfaa9c6cd5410bbd9b9) --- packages/metro-file-map/package.json | 3 +- .../src/watchers/FallbackWatcher.js | 139 +++++++++++------- yarn.lock | 2 +- 3 files changed, 85 insertions(+), 59 deletions(-) diff --git a/packages/metro-file-map/package.json b/packages/metro-file-map/package.json index a9f7c6ff1e..0a5f7ac799 100644 --- a/packages/metro-file-map/package.json +++ b/packages/metro-file-map/package.json @@ -26,8 +26,7 @@ "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", - "nullthrows": "^1.1.1", - "walker": "^1.0.7" + "nullthrows": "^1.1.1" }, "devDependencies": { "slash": "^3.0.0" diff --git a/packages/metro-file-map/src/watchers/FallbackWatcher.js b/packages/metro-file-map/src/watchers/FallbackWatcher.js index a9b2d4b8d9..9499623cf7 100644 --- a/packages/metro-file-map/src/watchers/FallbackWatcher.js +++ b/packages/metro-file-map/src/watchers/FallbackWatcher.js @@ -24,8 +24,6 @@ import * as common from './common'; import fs from 'fs'; import os from 'os'; import path from 'path'; -// $FlowFixMe[untyped-import] - Write libdefs for `walker` -import walker from 'walker'; const platform = os.platform(); @@ -41,6 +39,13 @@ const DELETE_EVENT = common.DELETE_EVENT; */ const DEBOUNCE_MS = 100; +/** + * The number of entries `recReaddir` visits at a time. Enough to keep the libuv + * thread pool saturated, few enough that a large tree does not queue up an + * unbounded number of fs requests. + */ +const CRAWL_CONCURRENCY = 32; + export default class FallbackWatcher extends AbstractWatcher { +#changeTimers: Map = new Map(); +#dirRegistry: { @@ -52,25 +57,26 @@ export default class FallbackWatcher extends AbstractWatcher { async startWatching(): Promise { this.#watchdir(this.root); - await new Promise(resolve => { - recReaddir( - this.root, - dir => { - this.#watchdir(dir); - }, - filename => { - this.#register(filename, 'f'); - }, - symlink => { - this.#register(symlink, 'l'); - }, - () => { - resolve(); - }, - this.#checkedEmitError, - this.ignored, - ); - }); + await recReaddir( + this.root, + dir => { + this.#watchdir(dir); + }, + filename => { + this.#register(filename, 'f'); + }, + symlink => { + this.#register(symlink, 'l'); + }, + this.#checkedEmitError, + this.ignored, + ); + + if (platform === 'win32') { + // Inherited from sane: win32 needs a settling period after the watches + // are registered before events can be relied on. + await new Promise(resolve => setTimeout(resolve, 1000)); + } } /** @@ -296,7 +302,7 @@ export default class FallbackWatcher extends AbstractWatcher { ) { return; } - recReaddir( + await recReaddir( path.resolve(this.root, relativePath), (dir, stats) => { if (this.#watchdir(dir)) { @@ -337,7 +343,6 @@ export default class FallbackWatcher extends AbstractWatcher { }); } }, - function endCallback() {}, this.#checkedEmitError, this.ignored, ); @@ -425,45 +430,67 @@ function isIgnorableFileError(error: Error | {code: string}) { } /** - * Traverse a directory recursively calling `callback` on every directory. + * Traverse a directory recursively, reporting every entry with its stats. + * + * Entries are visited depth-first by a bounded pool, so neither the queue of + * pending entries nor the number of in-flight fs requests grows with the size + * of the tree. */ -function recReaddir( - dir: string, +async function recReaddir( + root: string, dirCallback: (string, Stats) => void, fileCallback: (string, Stats) => void, symlinkCallback: (string, Stats) => void, - endCallback: () => void, errorCallback: Error => void, ignored: ?RegExp, -) { - const walk = walker(dir); - if (ignored) { - walk.filterDir( - (currentDir: string) => - !common.posixPathMatchesPattern(ignored, currentDir), - ); - } - walk - .on('dir', normalizeProxy(dirCallback)) - .on('file', normalizeProxy(fileCallback)) - .on('symlink', normalizeProxy(symlinkCallback)) - .on('error', errorCallback) - .on('end', () => { - if (platform === 'win32') { - setTimeout(endCallback, 1000); - } else { - endCallback(); +): Promise { + const pending = [root]; + + const visit = async (entry: string) => { + let stats; + let names; + try { + stats = await fsPromises.lstat(entry); + if (!stats.isDirectory()) { + if (stats.isSymbolicLink()) { + symlinkCallback(entry, stats); + } else if (stats.isFile()) { + fileCallback(entry, stats); + } + return; } - }); -} + if (ignored != null && common.posixPathMatchesPattern(ignored, entry)) { + return; + } + names = await fsPromises.readdir(entry); + } catch (error) { + errorCallback(error); + return; + } + dirCallback(entry, stats); + for (const name of names) { + pending.push(path.join(entry, name)); + } + }; -/** - * Returns a callback that when called will normalize a path and call the - * original callback - */ -function normalizeProxy( - callback: (filepath: string, stats: Stats) => T, -): (string, Stats) => T { - return (filepath: string, stats: Stats) => - callback(path.normalize(filepath), stats); + await new Promise((resolve, reject) => { + let active = 0; + const pump = () => { + while (active < CRAWL_CONCURRENCY) { + const entry = pending.pop(); + if (entry == null) { + break; + } + active++; + visit(entry).then(() => { + active--; + pump(); + }, reject); + } + if (active === 0) { + resolve(); + } + }; + pump(); + }); } diff --git a/yarn.lock b/yarn.lock index 8853815c66..c14dd6e48b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5733,7 +5733,7 @@ vlq@^1.0.0: resolved "https://registry.yarnpkg.com/vlq/-/vlq-1.0.1.tgz#c003f6e7c0b4c1edd623fd6ee50bbc0d6a1de468" integrity sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w== -walker@^1.0.7, walker@^1.0.8: +walker@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== From d5b387afbe502d4358273ab0e06cd62ec82f4f31 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Wed, 9 Sep 2026 08:46:00 -0700 Subject: [PATCH 28/45] metro-file-map: Watch directories before listing them in fallback (Linux + Windows) watcher (#1907) Summary: `FallbackWatcher` starts an `fs.watch` on each directory from `recReaddir`'s `dirCallback`, and the crawl calls that after `readdir` has returned. Anything written into a directory *between the two* appears in neither the listing nor the watch, so it stays invisible until the next full crawl. There's no recovery on Linux or Windows. This is https://github.com/expo/expo/issues/48950 - `npx expo install` against a running dev server leaves the new modules unresolvable until restart, which is a lot more painful than it sounds now that agents routinely run Metro in a VM. brentvatne diagnosed it in https://github.com/expo/expo/pull/49363, with a fix in Expo's fork that we can hopefully replace with this one. Now that we own the crawl (https://github.com/react/metro/issues/1906), the fix is very simply to call `dirCallback` before `readdir` instead of after, which also moves it inside `recReaddir`'s existing `try`. Separate issues not fixed here, both pre-existing and both covered by https://github.com/expo/expo/pull/49363 : - An `fs.watch` that emits `error` is never removed from `#watched`. Node emits no `close` after `error`, so the path can never be re-watched, and `#stopWatching` waits on a `close` that will not arrive. - On win32, `fs.watch` can report an event with no filename. `#detectChangedFile` drops it when `#dirRegistry[dir]` is empty, which is exactly the state a newly watched directory is in. Changelog: ``` - **[Fix]**: `FallbackWatcher` no longer misses files written to a directory while it is being crawled ``` Pull Request resolved: https://github.com/react/metro/pull/1907 Test Plan: ``` yarn jest packages/metro-file-map yarn flow check yarn lint ``` New `watchers/__tests__/FallbackWatcher-test.js` asserts the ordering directly, that `fs.watch` precedes `readdir` for the same directory, on the initial crawl and on a directory created while watching, plus that a directory whose `fs.watch` throws is skipped without failing the crawl. All three fail on the parent commit and pass here. The race can't be asserted behaviourally on macOS, because FSEvents delivers with a latency window and a watch started immediately after a write still reports it: ``` $ node -e "fs.writeFileSync(d+'/raced.js', ''); fs.watch(d, (e, f) => console.log(e, f))" rename raced.js ``` That's why the issue is Linux/Windows only, and why the assertion is on the ordering rather than on a missed event. Metro has no Windows coverage for this backend, so the win32 path is unexercised either way. Reviewed By: motiz88, javache Differential Revision: D119187785 Pulled By: vzaidman fbshipit-source-id: 5b69c83db65fa546a7f4ead5e4fb75b112cad9b9 (cherry picked from commit f63b251862e91c1fb1fe18ad28788deeb7f961f1) --- .../src/watchers/FallbackWatcher.js | 18 ++- .../__tests__/FallbackWatcher-test.js | 137 ++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js diff --git a/packages/metro-file-map/src/watchers/FallbackWatcher.js b/packages/metro-file-map/src/watchers/FallbackWatcher.js index 9499623cf7..aff2097698 100644 --- a/packages/metro-file-map/src/watchers/FallbackWatcher.js +++ b/packages/metro-file-map/src/watchers/FallbackWatcher.js @@ -181,9 +181,17 @@ export default class FallbackWatcher extends AbstractWatcher { if (this.#watched[dir]) { return false; } - const watcher = fs.watch(dir, {persistent: true}, (event, filename) => - this.#normalizeChange(dir, event, filename), - ); + let watcher: FSWatcher; + try { + watcher = fs.watch(dir, {persistent: true}, (event, filename) => + this.#normalizeChange(dir, event, filename), + ); + } catch (error) { + // A directory we cannot watch is still worth crawling, so report the + // error and carry on rather than losing the subtree under it. + this.#checkedEmitError(error); + return false; + } this.#watched[dir] = watcher; watcher.on('error', this.#checkedEmitError); @@ -462,12 +470,14 @@ async function recReaddir( if (ignored != null && common.posixPathMatchesPattern(ignored, entry)) { return; } + // Report the directory before listing it. A consumer that starts watching + // here would otherwise miss anything written between the two. + dirCallback(entry, stats); names = await fsPromises.readdir(entry); } catch (error) { errorCallback(error); return; } - dirCallback(entry, stats); for (const name of names) { pending.push(path.join(entry, name)); } diff --git a/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js new file mode 100644 index 0000000000..c23224c3a6 --- /dev/null +++ b/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js @@ -0,0 +1,137 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import FallbackWatcher from '../FallbackWatcher'; +import {createTempWatchRoot} from './helpers'; +import fs from 'node:fs'; +import {join} from 'node:path'; + +jest.useRealTimers(); +jest.setTimeout(10 * 1000); + +const {mkdir, rm, writeFile} = fs.promises; + +describe('FallbackWatcher', () => { + let watchRoot: string; + let watcher: ?FallbackWatcher; + let calls: Array; + let watchFailure: ?{code: string, path: string}; + + const indexOfCall = (op: 'watch' | 'readdir', dir: string) => + calls.indexOf(`${op}:${dir}`); + + const expectWatchedBeforeListed = (dir: string) => { + expect(indexOfCall('watch', dir)).toBeGreaterThanOrEqual(0); + expect(indexOfCall('watch', dir)).toBeLessThan(indexOfCall('readdir', dir)); + }; + + beforeEach(async () => { + watchRoot = await createTempWatchRoot('Fallback', false); + calls = []; + watchFailure = null; + + const {watch} = fs; + jest.spyOn(fs, 'watch').mockImplementation((dir, ...args) => { + calls.push(`watch:${String(dir)}`); + const failure = watchFailure; + if (failure != null && dir === failure.path) { + const error = new Error(`Cannot watch path '${String(dir)}'.`); + // $FlowFixMe[prop-missing] code + error.code = failure.code; + throw error; + } + return watch(dir, ...args); + }); + const {readdir} = fs.promises; + // $FlowFixMe[incompatible-call] - variadic passthrough + jest.spyOn(fs.promises, 'readdir').mockImplementation((dir, ...args) => { + calls.push(`readdir:${String(dir)}`); + return readdir(dir, ...args); + }); + + watcher = new FallbackWatcher(watchRoot, { + dot: true, + globs: [], + ignored: null, + watchmanDeferStates: [], + }); + }); + + afterEach(async () => { + await watcher?.stopWatching(); + jest.restoreAllMocks(); + await rm(watchRoot, {recursive: true}); + }); + + // A file written into a directory after it has been listed but before it is + // watched is reported by neither the listing nor any subsequent event, and is + // missed until the next full crawl. This is how installing a package against + // a running server loses files: https://github.com/expo/expo/issues/48950 + describe('watches each directory before listing it', () => { + test('during the initial crawl', async () => { + await mkdir(join(watchRoot, 'a', 'b'), {recursive: true}); + + await watcher?.startWatching(); + + for (const dir of ['', 'a', join('a', 'b')]) { + expectWatchedBeforeListed(join(watchRoot, dir)); + } + }); + + test('for a directory created while watching', async () => { + await watcher?.startWatching(); + calls = []; + + const nested = join(watchRoot, 'new', 'nested'); + await mkdir(nested, {recursive: true}); + await writeFile(join(nested, 'file.js'), ''); + await waitFor(() => indexOfCall('readdir', nested) >= 0); + + for (const dir of [join(watchRoot, 'new'), nested]) { + expectWatchedBeforeListed(dir); + } + }); + }); + + // A watch we cannot establish - one directory over the inotify limit, say - + // must not cost us the files under it, which would silently truncate the + // file map. + test.each([ + ['ENOSPC', 1], + ['ENOENT', 0], + ])( + 'crawls past a directory it cannot watch (%s)', + async (code, expectedErrors) => { + await mkdir(join(watchRoot, 'a', 'b'), {recursive: true}); + watchFailure = {code, path: join(watchRoot, 'a')}; + const errors: Array = []; + watcher?.onError(error => { + errors.push(error); + }); + + await expect(watcher?.startWatching()).resolves.toBeUndefined(); + + expect(errors).toHaveLength(expectedErrors); + for (const dir of ['a', join('a', 'b')]) { + expect( + indexOfCall('readdir', join(watchRoot, dir)), + ).toBeGreaterThanOrEqual(0); + } + }, + ); +}); + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 5000; + while (!predicate() && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 20)); + } +} From e6cd57636a351d343ac8638733b8886a47ff6c00 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:33:23 -0700 Subject: [PATCH 29/45] Fix mismatched asset paths in builds when using `unstable_serverRoot` (#1917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: ## 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.** `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 https://github.com/react/metro/issues/1892. 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. Pull Request resolved: https://github.com/react/metro/pull/1917 Test Plan: ### Unit New test pins `_getAssetsFromDependencies` to `projectRoot` with `unstable_serverRoot` set to `/`. Restoring `_getServerRootDir()` fails it: ```console $ 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: ```text 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`: ```text 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: ```text 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: ```console $ 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: ```console $ 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 ``` Reviewed By: GijsWeterings Differential Revision: D120129779 Pulled By: vzaidman fbshipit-source-id: 19f1dbc5e9be35f89e4a034b6e43b2ee143bf736 (cherry picked from commit 37c767e35ce210cbbf1b798dadf125ad226c6ff1) --- packages/metro/src/Server.js | 13 ++++------ .../metro/src/Server/__tests__/Server-test.js | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 9909887e64..ad89cb16dd 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -441,7 +441,7 @@ export default class Server { processModuleFilter: this._config.serializer.processModuleFilter, assetPlugins: this._config.transformer.assetPlugins, platform, - projectRoot: this._getServerRootDir(), + projectRoot: this._config.projectRoot, publicPath: this._config.transformer.publicPath, }); } @@ -1353,13 +1353,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 43190396cc..eb9d754f43 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('crypto', () => ({ @@ -111,6 +113,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; }); @@ -1472,6 +1479,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 a54976947876497c78823bd1e7e6d28c94aa2207 Mon Sep 17 00:00:00 2001 From: Langning Zhang Date: Wed, 23 Sep 2026 19:54:33 +0800 Subject: [PATCH 30/45] Propagate Bundler transformer initialization errors (#1854) * Propagate transformer initialization errors * Type Bundler-test with Flow and ESM imports Adds `@flow strict-local` to the new `Bundler-test.js` and switches its `require` calls to ESM imports, matching neighboring tests such as `HmrServer-test.js`. Changelog: [Internal] --------- Co-authored-by: Rob Hogan <2590098+robhogan@users.noreply.github.com> (cherry picked from commit 1f83bfcf10a6bb279a618b11a8c4e014f63e8885) --- packages/metro/src/Bundler.js | 5 + packages/metro/src/__tests__/Bundler-test.js | 99 ++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 packages/metro/src/__tests__/Bundler-test.js diff --git a/packages/metro/src/Bundler.js b/packages/metro/src/Bundler.js index 0178235d6d..eff3efe312 100644 --- a/packages/metro/src/Bundler.js +++ b/packages/metro/src/Bundler.js @@ -46,7 +46,12 @@ export default class Bundler { type: 'transformer_load_failed', error, }); + throw error; }); + + // Observe initialization failures immediately so callers can await the + // original promise later without triggering an unhandled rejection. + this._initializedPromise.catch(() => {}); } getWatcher(): EventEmitter { diff --git a/packages/metro/src/__tests__/Bundler-test.js b/packages/metro/src/__tests__/Bundler-test.js new file mode 100644 index 0000000000..8515b01c5f --- /dev/null +++ b/packages/metro/src/__tests__/Bundler-test.js @@ -0,0 +1,99 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {ConfigT} from 'metro-config'; + +import Bundler from '../Bundler'; +import Transformer from '../DeltaBundler/Transformer'; +import DependencyGraph from '../node-haste/DependencyGraph'; +import {getDefaultConfig} from 'metro-config'; + +jest.mock('../DeltaBundler/Transformer'); +jest.mock('../node-haste/DependencyGraph'); + +type ClassMock = JestMockFn, unknown>; + +// $FlowFixMe[incompatible-type] Jest automocks the default export +const MockTransformer: ClassMock = Transformer; +// $FlowFixMe[incompatible-type] Jest automocks the default export +const MockDependencyGraph: ClassMock = DependencyGraph; + +describe('Bundler', () => { + let config: ConfigT; + let reporter; + + beforeEach(() => { + reporter = {update: jest.fn()}; + config = {...getDefaultConfig.getDefaultValues('/'), reporter}; + + MockDependencyGraph.mockImplementation(() => ({ + ready: jest.fn().mockResolvedValue(), + })); + + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test.each([ + ['ready', (bundler: Bundler) => bundler.ready()], + [ + 'transformFile', + // $FlowFixMe[incompatible-type] Transform options are unused before initialization fails + (bundler: Bundler) => bundler.transformFile('/entry.js', {}), + ], + ])( + 'propagates Transformer initialization errors from %s', + async (_method, invoke) => { + const error = new Error('Transformer initialization failed'); + MockTransformer.mockImplementation(() => { + throw error; + }); + + const bundler = new Bundler(config); + + await expect(invoke(bundler)).rejects.toBe(error); + expect(reporter.update).toHaveBeenCalledWith({ + type: 'transformer_load_failed', + error, + }); + }, + ); + + test('does not emit an unhandled rejection before ready is called', async () => { + jest.useRealTimers(); + + const error = new Error('Transformer initialization failed'); + const unhandledRejections: Array = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + + MockTransformer.mockImplementation(() => { + throw error; + }); + process.on('unhandledRejection', onUnhandledRejection); + + try { + const bundler = new Bundler(config); + + await new Promise(resolve => setImmediate(resolve)); + + await expect(bundler.ready()).rejects.toBe(error); + expect(unhandledRejections).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + jest.useFakeTimers(); + } + }); +}); From dad7f0c7ce8aa6a03434e0a6ee0c3b9a50bf6dc8 Mon Sep 17 00:00:00 2001 From: Durvesh Pilankar Date: Wed, 23 Sep 2026 05:12:52 -0700 Subject: [PATCH 31/45] Fix RAM bundle passing the deps function (not its result) to getTransformOptions (#1757) * Fix RAM bundle passing the deps function (not its result) to getTransformOptions getRamBundleInfo gives custom getTransformOptions a callback to look up a module's transitive dependencies, but it called `Array.from(getDependencies)` on the function itself instead of `Array.from(getDependencies(x))`. Since the closure has arity 1, Array.from treated it as array-like and returned [undefined], so any getTransformOptions relying on this callback received garbage regardless of the file queried. Invoke getDependencies(x). Existing tests never exercised the callback, which masked this. Adds a regression test that calls the callback and asserts the real transitive deps (fails before / passes after). * Fix Flow error in the new getRamBundleInfo regression test `resolvedDeps` was declared without a type or initialiser, so spreading it fails `flow check` with "possibly uninitialized variable is incompatible with $Iterable". Annotate it and assert it was assigned before spreading, which also gives a clearer failure if `getTransformOptions` is never called with a dependency lookup. Changelog: [Internal] * Remove stale $FlowFixMe above the getTransformOptions dependency callback The suppression dates to Flow v0.99 in 2019 and no longer suppresses anything: `flow check` reports no error on this line with it removed, either with the fix in this stack or with the original `Array.from(getDependencies)`. Worth noting Flow did not catch this bug in either form, for the same reason it was silent at runtime - `Array.from` accepts array-likes, and a function satisfies that via `.length`. Changelog: [Internal] * Apply Prettier formatting to the regression test The guard added in the previous commit exceeded the print width, failing `prettier --check` and so `yarn lint`. Changelog: [Internal] * Share base options across the getRamBundleInfo tests Each test repeated the full serializer options object. Extract it into `baseOptions` so each test overrides only what it exercises. --------- Co-authored-by: Rob Hogan <2590098+robhogan@users.noreply.github.com> (cherry picked from commit bd4534de1a0d7c2d2f320c8e3afb1759fe0a4c16) --- .../__tests__/getRamBundleInfo-test.js | 128 ++++++++---------- .../Serializers/getRamBundleInfo.js | 5 +- 2 files changed, 58 insertions(+), 75 deletions(-) diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/getRamBundleInfo-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/getRamBundleInfo-test.js index c988afebe4..9e9678dbfd 100644 --- a/packages/metro/src/DeltaBundler/Serializers/__tests__/getRamBundleInfo-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/getRamBundleInfo-test.js @@ -76,74 +76,78 @@ const pre = [createModule('pre', [], 'js/script')[1]]; const getRunModuleStatement = (moduleId: string | number) => `require(${JSON.stringify(moduleId)});`; +const baseOptions = { + asyncRequireModulePath: '', + // $FlowFixMe[incompatible-type] createModuleId assumes numeric IDs - is this too strict? + createModuleId: (path: string): number => path, + dev: true, + excludeSource: false, + getRunModuleStatement, + getTransformOptions: async () => ({ + preloadedModules: {}, + ramGroups: [], + }), + globalPrefix: '', + includeAsyncPaths: false, + inlineSourceMap: false, + modulesOnly: false, + platform: null, + processModuleFilter: (module: Module<>) => true, + projectRoot: '/root', + runBeforeMainModule: [], + runModule: true, + serverRoot: '/root', + shouldAddToIgnoreList: () => false, + sourceMapUrl: 'http://localhost/bundle.map', + sourceUrl: null, + getSourceUrl: null, +}; + test('should return the RAM bundle info', async () => { expect( await getRamBundleInfo( '/root/entry.js', pre, {...graph, entryPoints: new Set(['/root/entry.js'])}, - { - asyncRequireModulePath: '', - // $FlowFixMe[incompatible-type] createModuleId assumes numeric IDs - is this too strict? - createModuleId: path => path, - dev: true, - excludeSource: false, - getRunModuleStatement, - getTransformOptions: async () => ({ - preloadedModules: {}, - ramGroups: [], - }), - globalPrefix: '', - includeAsyncPaths: false, - inlineSourceMap: false, - modulesOnly: false, - platform: null, - processModuleFilter: module => true, - projectRoot: '/root', - runBeforeMainModule: [], - runModule: true, - serverRoot: '/root', - shouldAddToIgnoreList: () => false, - sourceMapUrl: 'http://localhost/bundle.map', - sourceUrl: null, - getSourceUrl: null, - }, + baseOptions, ), ).toMatchSnapshot(); }); +test('passes a working transitive-dependency lookup to getTransformOptions', async () => { + let resolvedDeps: ?Array; + await getRamBundleInfo( + '/root/entry.js', + pre, + {...graph, entryPoints: new Set(['/root/entry.js'])}, + { + ...baseOptions, + getTransformOptions: async (entryPoints, opts, getDependenciesOf) => { + resolvedDeps = await getDependenciesOf('/root/foo.js'); + return {preloadedModules: {}, ramGroups: []}; + }, + }, + ); + // foo depends on bar, baz, qux — the callback must return those, not [undefined]. + if (resolvedDeps == null) { + throw new Error( + 'getTransformOptions was not called with a dependency lookup', + ); + } + expect([...resolvedDeps].sort()).toEqual([ + '/root/bar.js', + '/root/baz.js', + '/root/qux.js', + ]); +}); + test('emits x_google_ignoreList based on shouldAddToIgnoreList', async () => { expect( await getRamBundleInfo( '/root/entry.js', pre, {...graph, entryPoints: new Set(['/root/entry.js'])}, - { - asyncRequireModulePath: '', - // $FlowFixMe[incompatible-type] createModuleId assumes numeric IDs - is this too strict? - createModuleId: path => path, - dev: true, - excludeSource: false, - getRunModuleStatement, - getTransformOptions: async () => ({ - preloadedModules: {}, - ramGroups: [], - }), - globalPrefix: '', - includeAsyncPaths: false, - inlineSourceMap: false, - modulesOnly: false, - platform: null, - processModuleFilter: module => true, - projectRoot: '/root', - runBeforeMainModule: [], - runModule: true, - serverRoot: '/root', - shouldAddToIgnoreList: () => true, - sourceMapUrl: 'http://localhost/bundle.map', - sourceUrl: null, - getSourceUrl: null, - }, + {...baseOptions, shouldAddToIgnoreList: () => true}, ), ).toMatchSnapshot(); }); @@ -159,29 +163,11 @@ test('should use the preloadedModules and ramGroup configs to build a RAM bundle pre, {...graph, entryPoints: new Set(['/root/entry.js'])}, { - asyncRequireModulePath: '', - // $FlowFixMe[incompatible-type] createModuleId assumes numeric IDs - is this too strict? - createModuleId: path => path, - dev: true, - excludeSource: false, - getRunModuleStatement, + ...baseOptions, /* $FlowFixMe[incompatible-type] Natural Inference rollout. See * https://fburl.com/workplace/6291gfvu */ getTransformOptions, - globalPrefix: '', - includeAsyncPaths: false, inlineSourceMap: null, - modulesOnly: false, - platform: null, - processModuleFilter: module => true, - projectRoot: '/root', - runBeforeMainModule: [], - runModule: true, - serverRoot: '/root', - shouldAddToIgnoreList: () => false, - sourceMapUrl: 'http://localhost/bundle.map', - sourceUrl: null, - getSourceUrl: null, }, ); diff --git a/packages/metro/src/DeltaBundler/Serializers/getRamBundleInfo.js b/packages/metro/src/DeltaBundler/Serializers/getRamBundleInfo.js index 9b8047ec08..9d1d613b2a 100644 --- a/packages/metro/src/DeltaBundler/Serializers/getRamBundleInfo.js +++ b/packages/metro/src/DeltaBundler/Serializers/getRamBundleInfo.js @@ -157,10 +157,7 @@ async function _getRamOptions( const {preloadedModules, ramGroups} = await getTransformOptions( [entryFile], {dev: options.dev, hot: true, platform: options.platform}, - /* $FlowFixMe[incompatible-type](>=0.99.0 site=react_native_fb) This comment suppresses an - * error found when Flow v0.99 was deployed. To see the error, delete this - * comment and run Flow. */ - async (x: string) => Array.from(getDependencies), + async (x: string) => Array.from(getDependencies(x)), ); return { From 8b075d0584322038fb36da14ba934e5d6e0c40cd Mon Sep 17 00:00:00 2001 From: Durvesh Pilankar Date: Wed, 23 Sep 2026 23:40:03 -0700 Subject: [PATCH 32/45] Fix MockPlugin clobbering the active mock when a non-active duplicate is removed (#1756) (cherry picked from commit c165e313ee69ef8b45109f0e046b0f748ae7645f) --- .../metro-file-map/src/plugins/MockPlugin.js | 11 ++++++++--- .../mocks/__tests__/MockPlugin-test.js | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/metro-file-map/src/plugins/MockPlugin.js b/packages/metro-file-map/src/plugins/MockPlugin.js index 305b317558..dffb8fcd41 100644 --- a/packages/metro-file-map/src/plugins/MockPlugin.js +++ b/packages/metro-file-map/src/plugins/MockPlugin.js @@ -147,13 +147,18 @@ export default class MockPlugin const duplicates = this.#raw.duplicates.get(mockName); if (duplicates != null) { const posixRelativePath = normalizePathSeparatorsToPosix(canonicalPath); + const wasActiveMock = this.#raw.mocks.get(mockName) === posixRelativePath; duplicates.delete(posixRelativePath); if (duplicates.size === 1) { this.#raw.duplicates.delete(mockName); } - // Set the mock to a remaining duplicate. Should never be empty. - const remaining = nullthrows(duplicates.values().next().value); - this.#raw.mocks.set(mockName, remaining); + // Only reassign the active mock if the file we removed *was* the active + // one; otherwise a non-active duplicate's removal would clobber it. + if (wasActiveMock) { + // Set the mock to a remaining duplicate. Should never be empty. + const remaining = nullthrows(duplicates.values().next().value); + this.#raw.mocks.set(mockName, remaining); + } } else { this.#raw.mocks.delete(mockName); } diff --git a/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js b/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js index 43f2835646..36e95abf24 100644 --- a/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js +++ b/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js @@ -107,6 +107,25 @@ Duplicate manual mock found for \`foo\`: }); }); + test('removing a non-active duplicate keeps the active mock', () => { + onFileAdded(p('a/__mocks__/foo.js')); + onFileAdded(p('b/__mocks__/foo.js')); + onFileAdded(p('c/__mocks__/foo.js')); // latest wins -> active mock is c + + expect(mockMap.getMockModule('foo')).toBe(p('/root/c/__mocks__/foo.js')); + + // Remove a NON-active duplicate (b); the active mock (c) must be kept. + mockMap.onChanged({ + addedFiles: new Map(), + modifiedFiles: new Map(), + removedFiles: new Map([[p('b/__mocks__/foo.js'), null]]), + addedDirectories: new Set(), + removedDirectories: new Set(), + }); + + expect(mockMap.getMockModule('foo')).toBe(p('/root/c/__mocks__/foo.js')); + }); + test('loads from a snapshot', async () => { await mockMap.initialize({ files: { From 8a4eb528d9257a7f407fb6d84d8e2a1a48b9aaf9 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:36:53 +0100 Subject: [PATCH 33/45] metro-file-map: Fix missed changes and a stop hang in FallbackWatcher (#1967) (cherry picked from commit 5e87b2ca45f394dc51e9c02bc2ada0d05d032a48) --- .../src/watchers/FallbackWatcher.js | 96 +++++++++---------- 1 file changed, 46 insertions(+), 50 deletions(-) diff --git a/packages/metro-file-map/src/watchers/FallbackWatcher.js b/packages/metro-file-map/src/watchers/FallbackWatcher.js index aff2097698..70f31707cb 100644 --- a/packages/metro-file-map/src/watchers/FallbackWatcher.js +++ b/packages/metro-file-map/src/watchers/FallbackWatcher.js @@ -31,6 +31,7 @@ const fsPromises = fs.promises; const TOUCH_EVENT = common.TOUCH_EVENT; const DELETE_EVENT = common.DELETE_EVENT; +const RECRAWL_EVENT = common.RECRAWL_EVENT; /** * This setting delays all events. It suppresses 'change' events that @@ -194,7 +195,15 @@ export default class FallbackWatcher extends AbstractWatcher { } this.#watched[dir] = watcher; - watcher.on('error', this.#checkedEmitError); + watcher.on('error', error => { + // Node has already closed the watcher, and will not emit 'close'. Forget + // it, so that stopping doesn't wait on it and the path can be watched + // again. + if (this.#watched[dir] === watcher) { + delete this.#watched[dir]; + } + this.#checkedEmitError(error); + }); if (this.root !== dir) { this.#register(dir, 'd'); @@ -226,60 +235,12 @@ export default class FallbackWatcher extends AbstractWatcher { await Promise.all(promises); } - /** - * On some platforms, as pointed out on the fs docs (most likely just win32) - * the file argument might be missing from the fs event. Try to detect what - * change by detecting if something was deleted or the most recent file change. - */ - #detectChangedFile( - dir: string, - event: string, - callback: (file: string) => void, - ) { - if (!this.#dirRegistry[dir]) { - return; - } - - let found = false; - let closest: ?Readonly<{file: string, mtime: Stats['mtime']}> = null; - let c = 0; - Object.keys(this.#dirRegistry[dir]).forEach((file, i, arr) => { - fs.lstat(path.join(dir, file), (error, stat) => { - if (found) { - return; - } - - if (error) { - if (isIgnorableFileError(error)) { - found = true; - callback(file); - } else { - this.emitError(error); - } - } else { - if (closest == null || stat.mtime > closest.mtime) { - closest = {file, mtime: stat.mtime}; - } - if (arr.length === ++c) { - callback(closest.file); - } - } - }); - }); - } - /** * Normalize fs events and pass it on to be processed. */ #normalizeChange(dir: string, event: string, file: string) { if (!file) { - this.#detectChangedFile(dir, event, actualFile => { - if (actualFile) { - this.#processChange(dir, event, actualFile).catch(error => - this.emitError(error), - ); - } - }); + this.#processUnnamedChange(dir).catch(error => this.emitError(error)); } else { this.#processChange(dir, event, path.normalize(file)).catch(error => this.emitError(error), @@ -287,6 +248,35 @@ export default class FallbackWatcher extends AbstractWatcher { } } + /** + * Process an event that doesn't name the changed entry. Windows sends these + * when changes to a directory overflow the buffer they're reported through, + * so any number of entries under `dir` may have been added, changed or + * removed. + */ + async #processUnnamedChange(dir: string) { + // Watch and register anything new, so that later changes are reported. + await recReaddir( + dir, + subdir => { + this.#watchdir(subdir); + }, + filename => { + this.#register(filename, 'f'); + }, + symlink => { + this.#register(symlink, 'l'); + }, + this.#checkedEmitError, + this.ignored, + ); + // Then have the file map reconcile everything under `dir`. + this.#emitEvent({ + event: RECRAWL_EVENT, + relativePath: path.relative(this.root, dir), + }); + } + /** * Process changes. */ @@ -354,6 +344,12 @@ export default class FallbackWatcher extends AbstractWatcher { this.#checkedEmitError, this.ignored, ); + // A directory we already knew about has been replaced, so entries we + // registered under it may since have changed or been removed. Have the + // file map reconcile it. + if (registered) { + this.#emitEvent({event: RECRAWL_EVENT, relativePath}); + } } else { const type = common.typeFromStat(stat); if (type == null) { From f9ad6bff16af9fa6080009b3424c2b5292c32293 Mon Sep 17 00:00:00 2001 From: Gabriel Dumbrava Date: Fri, 25 Sep 2026 14:57:04 +0300 Subject: [PATCH 34/45] Fix SVG dimension parsing treating stroke-width as width (#1972) (cherry picked from commit a95869a010a654d08cbc8018fea41bd265d22a0e) --- .../metro/src/lib/__tests__/imageSize-test.js | 26 +++++++++++++++++++ packages/metro/src/lib/imageSize.js | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/metro/src/lib/__tests__/imageSize-test.js b/packages/metro/src/lib/__tests__/imageSize-test.js index 02e4e62975..af382640ce 100644 --- a/packages/metro/src/lib/__tests__/imageSize-test.js +++ b/packages/metro/src/lib/__tests__/imageSize-test.js @@ -53,6 +53,32 @@ describe('getImageDimensions', () => { ).toEqual({width: WIDTH, height: HEIGHT}); }); + test.each([ + [ + 'stroke-width after width and height', + ``, + ], + [ + 'stroke-width with only a viewBox', + ``, + ], + [ + 'other hyphenated attributes', + ``, + ], + [ + 'a namespaced attribute after width and height', + ``, + ], + ])( + 'ignores SVG attributes that only end in a dimension name: %s', + (_, svg) => { + expect( + getImageDimensions('svg', Buffer.from(svg), '/root/icon.svg'), + ).toEqual({width: WIDTH, height: HEIGHT}); + }, + ); + test('rejects unsupported SVG units', () => { expect(() => getImageDimensions( diff --git a/packages/metro/src/lib/imageSize.js b/packages/metro/src/lib/imageSize.js index 46ede3a06f..fc623dd309 100644 --- a/packages/metro/src/lib/imageSize.js +++ b/packages/metro/src/lib/imageSize.js @@ -312,7 +312,7 @@ function parseSvg(content: Buffer): ?Dimensions { const root = header.slice(rootStart, rootEnd + 1); const attributes: {[string]: string} = {}; const attributePattern = - /\b(width|height|viewBox)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi; + /(? Date: Fri, 25 Sep 2026 18:09:31 +0100 Subject: [PATCH 35/45] metro-file-map: Identify TreeFS directory nodes by shape, not realm (#1934) (cherry picked from commit 207497f5199a093fd335104e5b817d1e8ce6686e) --- packages/metro-file-map/src/lib/TreeFS.js | 2 +- .../src/lib/__tests__/TreeFS-test.js | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 2790ce44ae..0df66c84af 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -30,7 +30,7 @@ type FileNode = FileMetadata; type MixedNode = FileNode | DirectoryNode; function isDirectory(node: ?MixedNode): node is DirectoryNode { - return node instanceof Map; + return node != null && !Array.isArray(node); } function isRegularFile(node: FileNode): boolean { diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index bc5923cece..716647af71 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -90,6 +90,20 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { expect(tfs.exists(p('/project/link-to-nowhere'))).toBe(false); }); + test('traverses a snapshot deserialized in another realm', () => { + // Under Jest, `node:v8` is a host module, so `deserialize` returns `Map`s + // whose prototype is not the sandbox's `Map.prototype`. + const {deserialize, serialize} = require('node:v8'); + const deserialized = TreeFS.fromDeserializedSnapshot({ + rootDir: p('/project'), + fileSystemData: deserialize(serialize(tfs.getSerializableSnapshot())), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + expect(deserialized.exists(p('/project/foo/another.js'))).toBe(true); + }); + test('implements linkStats()', () => { expect(tfs.linkStats(p('/project/link-to-foo/another.js'))).toEqual({ fileType: 'f', From c1e7fe44236a69898165d759dbb5efad05b0cc6a Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:49:28 +0100 Subject: [PATCH 36/45] NativeWatcher: Emit events in the order fs.watch reported them - fix flaky integration tests (#1974) Adapted for 0.84.x: Resolved a conflict with the `Promise` annotation added in 0.84.6. (cherry picked from commit 88f95b0935c7e396c0cf6cc84fb4c67b948a8f6b) --- .../src/watchers/NativeWatcher.js | 58 +++++-- .../watchers/__tests__/NativeWatcher-test.js | 164 ++++++++++++++++++ .../types/watchers/NativeWatcher.d.ts | 3 +- 3 files changed, 209 insertions(+), 16 deletions(-) create mode 100644 packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js diff --git a/packages/metro-file-map/src/watchers/NativeWatcher.js b/packages/metro-file-map/src/watchers/NativeWatcher.js index d687fd1f1e..1c24ed9cde 100644 --- a/packages/metro-file-map/src/watchers/NativeWatcher.js +++ b/packages/metro-file-map/src/watchers/NativeWatcher.js @@ -8,6 +8,7 @@ * @format */ +import type {WatcherBackendChangeEvent} from '../flow-types'; import type {FSWatcher} from 'fs'; import {AbstractWatcher} from './AbstractWatcher'; @@ -46,6 +47,11 @@ const RECRAWL_EVENT = 'recrawl'; export default class NativeWatcher extends AbstractWatcher { #fsWatcher: ?FSWatcher; + /** + * Promise chain to emit events in the order they were received. + */ + #emitQueue: Promise = Promise.resolve(); + static isSupported(): boolean { return platform() === 'darwin'; } @@ -59,7 +65,7 @@ export default class NativeWatcher extends AbstractWatcher { ... }>, ) { - if (!NativeWatcher.isSupported) { + if (!NativeWatcher.isSupported()) { throw new Error('This watcher can only be used on macOS'); } super(dir, opts); @@ -76,7 +82,26 @@ export default class NativeWatcher extends AbstractWatcher { recursive: true, }, (event, relativePath) => { - this._handleEvent(event, relativePath).catch(error => { + // Start handling immediately so that stats are gathered concurrently + // and as close as possible to the event, but emit in arrival order. + const settled = this.#handleEvent(event, relativePath).then( + change => ({change, error: null}), + (error: Error) => ({change: null, error}), + ); + const emitted = this.#emitQueue.then(() => + settled.then(({change, error}) => { + if (error != null) { + throw error; + } + if (change != null) { + this.emitFileEvent(change); + } + }), + ); + // Report failures outside the queue, so that a throwing emitError + // (e.g. with no error listener) can't suppress later events. + this.#emitQueue = emitted.catch(() => {}); + emitted.catch(error => { this.emitError(error); }); }, @@ -95,7 +120,14 @@ export default class NativeWatcher extends AbstractWatcher { } } - async _handleEvent(event: string, relativePath: string): Promise { + /** + * Resolve a raw `fs.watch` event into the event to emit for it, or `null` if + * it should be dropped. + */ + async #handleEvent( + event: string, + relativePath: string, + ): Promise> { const absolutePath = path.resolve(this.root, relativePath); if (this.doIgnore(relativePath)) { debug( @@ -104,7 +136,7 @@ export default class NativeWatcher extends AbstractWatcher { relativePath, this.root, ); - return; + return null; } debug( 'Handling event "%s" on %s (root: %s)', @@ -119,11 +151,11 @@ export default class NativeWatcher extends AbstractWatcher { // Ignore files of an unrecognized type if (!type) { - return; + return null; } if (!includedByGlob(type, this.globs, this.dot, relativePath)) { - return; + return null; } // For directory "rename" events, notify that we need a recrawl since we @@ -136,14 +168,13 @@ export default class NativeWatcher extends AbstractWatcher { 'Directory rename detected on %s, requesting recrawl', relativePath, ); - this.emitFileEvent({ + return { event: RECRAWL_EVENT, relativePath, - }); - return; + }; } - this.emitFileEvent({ + return { event: TOUCH_EVENT, relativePath, metadata: { @@ -151,14 +182,13 @@ export default class NativeWatcher extends AbstractWatcher { modifiedTime: stat.mtime.getTime(), size: stat.size, }, - }); + }; } catch (error) { if (error?.code !== 'ENOENT') { - this.emitError(error); - return; + throw error; } - this.emitFileEvent({event: DELETE_EVENT, relativePath}); + return {event: DELETE_EVENT, relativePath}; } } } diff --git a/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js new file mode 100644 index 0000000000..f79f21fc71 --- /dev/null +++ b/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js @@ -0,0 +1,164 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {WatcherBackendChangeEvent} from '../../flow-types'; + +import NativeWatcher from '../NativeWatcher'; +import fs from 'fs'; +import os from 'os'; +import {join, resolve} from 'path'; + +jest.useRealTimers(); + +// Absolute on every platform, with a drive letter on Windows, as the watcher +// resolves its root. +const ROOT = resolve('/', 'project'); + +type Deferred = { + promise: Promise, + resolve: T => void, + reject: Error => void, +}; + +function deferred(): Deferred { + let resolve: T => void = () => {}; + let reject: Error => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return {promise, resolve, reject}; +} + +function fileStat(mtimeMs: number): fs.Stats { + // $FlowFixMe[incompatible-type] - only the fields NativeWatcher reads + return { + isSymbolicLink: () => false, + isDirectory: () => false, + isFile: () => true, + mtime: new Date(mtimeMs), + size: 42, + }; +} + +function enoent(): Error { + const error = new Error('ENOENT: no such file or directory'); + // $FlowFixMe[prop-missing] - Node system errors carry a code + error.code = 'ENOENT'; + return error; +} + +// Run every pending promise continuation, including those queued by others. +const flush = () => new Promise(resolve => setImmediate(resolve)); + +describe('NativeWatcher', () => { + let watcher: NativeWatcher; + let emitFsEvent: (event: string, relativePath: string) => void; + let pendingStats: Map>; + let events: Array; + + beforeEach(async () => { + jest.spyOn(os, 'platform').mockReturnValue('darwin'); + jest.spyOn(fs, 'watch').mockImplementation((_root, _opts, listener) => { + emitFsEvent = listener; + return {close: () => {}}; + }); + pendingStats = new Map(); + jest.spyOn(fs.promises, 'lstat').mockImplementation(absolutePath => { + const stat = deferred(); + pendingStats.set(String(absolutePath), stat); + return stat.promise; + }); + + watcher = new NativeWatcher(ROOT, {dot: true, globs: [], ignored: null}); + events = []; + watcher.onFileEvent(event => { + events.push(event); + }); + await watcher.startWatching(); + }); + + afterEach(async () => { + await watcher.stopWatching(); + jest.restoreAllMocks(); + }); + + function settleStat(relativePath: string, result: fs.Stats | Error): void { + const stat = pendingStats.get(join(ROOT, relativePath)); + if (stat == null) { + throw new Error(`No lstat pending for ${relativePath}`); + } + if (result instanceof Error) { + stat.reject(result); + } else { + stat.resolve(result); + } + } + + test('emits events in the order fs.watch reported them, however their stats settle', async () => { + emitFsEvent('rename', join('app', 'moved-in', 'file.js')); + emitFsEvent('rename', join('app', 'moved-in')); + emitFsEvent('change', join('app', 'other.js')); + + // All stats start immediately, before any has settled. + expect([...pendingStats.keys()]).toEqual([ + join(ROOT, 'app', 'moved-in', 'file.js'), + join(ROOT, 'app', 'moved-in'), + join(ROOT, 'app', 'other.js'), + ]); + + // Settle in reverse order. Nothing can be emitted until the first settles. + settleStat(join('app', 'other.js'), fileStat(1000)); + settleStat(join('app', 'moved-in'), enoent()); + await flush(); + expect(events).toEqual([]); + + settleStat(join('app', 'moved-in', 'file.js'), enoent()); + await flush(); + expect(events).toEqual([ + { + event: 'delete', + relativePath: join('app', 'moved-in', 'file.js'), + root: ROOT, + }, + {event: 'delete', relativePath: join('app', 'moved-in'), root: ROOT}, + { + event: 'touch', + relativePath: join('app', 'other.js'), + root: ROOT, + metadata: {type: 'f', modifiedTime: 1000, size: 42}, + }, + ]); + }); + + test('an lstat failure is reported in order and does not block later events', async () => { + const order: Array = []; + watcher.onFileEvent(event => { + order.push(`${event.event}:${event.relativePath}`); + }); + watcher.onError(error => { + order.push(`error:${error.message}`); + }); + + emitFsEvent('change', 'first.js'); + emitFsEvent('change', 'second.js'); + emitFsEvent('change', 'third.js'); + + settleStat('third.js', fileStat(3000)); + settleStat('second.js', new Error('EACCES')); + await flush(); + expect(order).toEqual([]); + + settleStat('first.js', fileStat(1000)); + await flush(); + expect(order).toEqual(['touch:first.js', 'error:EACCES', 'touch:third.js']); + }); +}); diff --git a/packages/metro-file-map/types/watchers/NativeWatcher.d.ts b/packages/metro-file-map/types/watchers/NativeWatcher.d.ts index 8b2069ffc6..95f70bbf82 100644 --- a/packages/metro-file-map/types/watchers/NativeWatcher.d.ts +++ b/packages/metro-file-map/types/watchers/NativeWatcher.d.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @noformat - * @generated SignedSource<<99faac6446a910cc70728424f342908c>> + * @generated SignedSource<<6297e6ebfbeb2920f00b15cba9acbce2>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-file-map/src/watchers/NativeWatcher.js @@ -50,6 +50,5 @@ declare class NativeWatcher extends AbstractWatcher { * End watching. */ stopWatching(): Promise; - _handleEvent(event: string, relativePath: string): Promise; } export default NativeWatcher; From a162cacd7ea4d4b27b7fedfda3d9b36145846610 Mon Sep 17 00:00:00 2001 From: Alexandre Kohler <64209066+kwy404@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:11:30 -0300 Subject: [PATCH 37/45] Fix section offsets in generatedMappings() for indexed source maps (#1976) (cherry picked from commit ab82c6a21a5b35a685478d3b14f6f47b320d4dfd) --- .../src/Consumer/SectionsConsumer.js | 12 +++--- .../src/__tests__/Consumer-test.js | 42 +++++++++++++++++++ .../src/__tests__/composeSourceMaps-test.js | 34 +++++++++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/metro-source-map/src/Consumer/SectionsConsumer.js b/packages/metro-source-map/src/Consumer/SectionsConsumer.js index 3eec4df156..37fa097567 100644 --- a/packages/metro-source-map/src/Consumer/SectionsConsumer.js +++ b/packages/metro-source-map/src/Consumer/SectionsConsumer.js @@ -69,8 +69,8 @@ export default class SectionsConsumer (get1(mapping.generatedLine) > 1 || get0(mapping.generatedColumn) > 0) ) { yield { - generatedLine: FIRST_LINE, - generatedColumn: FIRST_COLUMN, + generatedLine: add(FIRST_LINE, generatedOffset.lines), + generatedColumn: add(FIRST_COLUMN, generatedOffset.columns), source: null, name: null, originalLine: null, @@ -81,10 +81,10 @@ export default class SectionsConsumer yield { ...mapping, generatedLine: add(mapping.generatedLine, generatedOffset.lines), - generatedColumn: add( - mapping.generatedColumn, - generatedOffset.columns, - ), + generatedColumn: + mapping.generatedLine === FIRST_LINE + ? add(mapping.generatedColumn, generatedOffset.columns) + : mapping.generatedColumn, }; } } diff --git a/packages/metro-source-map/src/__tests__/Consumer-test.js b/packages/metro-source-map/src/__tests__/Consumer-test.js index c7b056bc76..9a5e2baf8c 100644 --- a/packages/metro-source-map/src/__tests__/Consumer-test.js +++ b/packages/metro-source-map/src/__tests__/Consumer-test.js @@ -527,6 +527,48 @@ describe('indexed (sectioned) maps', () => { }); }); + describe('generatedMappings()', () => { + test('applies the section offset to generated positions', () => { + const consumer = new Consumer({ + version: 3, + sections: [ + { + offset: {line: 0, column: 0}, + map: { + version: 3, + names: [], + sources: ['section0_source0'], + mappings: 'AAAA', + }, + }, + { + offset: {line: 0, column: 4}, + map: { + version: 3, + names: [], + sources: ['section1_source0'], + mappings: 'CAAA;AACA', + }, + }, + ], + }); + expect( + [...consumer.generatedMappings()].map(mapping => [ + mapping.generatedLine, + mapping.generatedColumn, + mapping.source, + ]), + ).toEqual([ + [1, 0, 'section0_source0'], + // Unmapped from the start of section 1 until its first mapping + [1, 4, null], + [1, 5, 'section1_source0'], + // The column offset only applies to the first line of a section + [2, 0, 'section1_source0'], + ]); + }); + }); + describe('sourceContentFor', () => { test('empty map', () => { const consumer = new Consumer({ diff --git a/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js b/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js index 5e90b5f8ed..1d161fe2fd 100644 --- a/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js +++ b/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js @@ -331,6 +331,40 @@ describe('composeSourceMaps', () => { `); }); + test('composes a last map with multi-line sections at a column offset', () => { + const indexedMap: IndexMap = { + version: 3, + sections: [ + { + offset: {line: 0, column: 0}, + map: {version: 3, names: [], sources: ['a.js'], mappings: 'AAAA'}, + }, + { + offset: {line: 0, column: 4}, + map: { + version: 3, + names: [], + sources: ['b.js'], + mappings: 'CAAA;AACA', + }, + }, + ], + }; + const composed = new Consumer(composeSourceMaps([indexedMap])); + const indexed = new Consumer(indexedMap); + for (const [line, column] of [ + [1, 0], + [1, 4], + [1, 5], + [2, 0], + ]) { + const position = {line: add1(line - 1), column: add0(column)}; + expect(composed.originalPositionFor(position)).toEqual( + indexed.originalPositionFor(position), + ); + } + }); + test('Propagate x_hermes_function_offsets', () => { const map1 = { version: 3, From 59395884af135353670e3621ab34ea4180b8dd3e Mon Sep 17 00:00:00 2001 From: Alexandre Kohler <64209066+kwy404@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:35:09 -0300 Subject: [PATCH 38/45] Fix exact "exports" and "imports" keys with no target falling back to a pattern (#1987) (cherry picked from commit f8269d5ffe9b083c6af7a6ab69cebfeb526df792) --- .../src/__tests__/package-exports-test.js | 39 +++++++++++++++++++ .../src/__tests__/package-imports-test.js | 25 ++++++++++++ .../src/utils/matchSubpathFromExportsLike.js | 11 ++++-- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index 3d63e835c7..cbd622da03 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -655,6 +655,45 @@ describe('with package exports resolution enabled', () => { }); }); + test.each([ + ['null', 'internal'], + ['no matching condition', 'server'], + ])( + 'exact subpath with %s target does not fall back to a pattern', + (_, subpath) => { + const logWarning = jest.fn(); + const context = { + ...createResolutionContext({ + '/root/src/main.js': '', + '/root/node_modules/test-pkg/package.json': JSON.stringify({ + name: 'test-pkg', + exports: { + './*': './lib/*.js', + './internal': null, + './server': {browser: './server-browser.js'}, + }, + }), + '/root/node_modules/test-pkg/lib/internal.js': '', + '/root/node_modules/test-pkg/lib/server.js': '', + '/root/node_modules/test-pkg/internal.js': '', + '/root/node_modules/test-pkg/server.js': '', + }), + originModulePath: '/root/src/main.js', + unstable_enablePackageExports: true, + unstable_logWarning: logWarning, + }; + + expect(Resolver.resolve(context, `test-pkg/${subpath}`, null)).toEqual({ + type: 'sourceFile', + filePath: `/root/node_modules/test-pkg/${subpath}.js`, + }); + expect(logWarning).toHaveBeenCalledTimes(1); + expect(logWarning.mock.calls[0][0]).toContain( + `"/root/node_modules/test-pkg/${subpath}" which is listed in the "exports"`, + ); + }, + ); + describe('package encapsulation', () => { test('[nonstrict] should fall back to "browser" spec resolution and log inaccessible import warning', () => { const logWarning = jest.fn(); diff --git a/packages/metro-resolver/src/__tests__/package-imports-test.js b/packages/metro-resolver/src/__tests__/package-imports-test.js index 4a08ceb122..c1559900ee 100644 --- a/packages/metro-resolver/src/__tests__/package-imports-test.js +++ b/packages/metro-resolver/src/__tests__/package-imports-test.js @@ -85,6 +85,31 @@ describe('import subpath patterns resolution support', () => { filePath: p('/root/node_modules/test-pkg/src/features/foo.js.js'), }); }); + + test('exact subpath with a null or unmatched target does not fall back to a pattern', () => { + const logWarning = jest.fn(); + const context = { + ...createResolutionContext({ + [p('/root/node_modules/test-pkg/package.json')]: JSON.stringify({ + name: 'test-pkg', + imports: { + '#features/*': './src/features/*.js', + '#features/foo': null, + '#features/bar': {browser: './src/features/bar.web.js'}, + }, + }), + [p('/root/node_modules/test-pkg/src/index.js')]: '', + [p('/root/node_modules/test-pkg/src/features/foo.js')]: '', + [p('/root/node_modules/test-pkg/src/features/bar.js')]: '', + }), + originModulePath: p('/root/node_modules/test-pkg/src/index.js'), + unstable_logWarning: logWarning, + }; + + expect(() => Resolver.resolve(context, '#features/foo', null)).toThrow(); + expect(() => Resolver.resolve(context, '#features/bar', null)).toThrow(); + expect(logWarning).toHaveBeenCalledTimes(2); + }); }); describe('import subpath conditional imports resolution', () => { diff --git a/packages/metro-resolver/src/utils/matchSubpathFromExportsLike.js b/packages/metro-resolver/src/utils/matchSubpathFromExportsLike.js index 2caed7d08d..17e24f35e8 100644 --- a/packages/metro-resolver/src/utils/matchSubpathFromExportsLike.js +++ b/packages/metro-resolver/src/utils/matchSubpathFromExportsLike.js @@ -49,11 +49,16 @@ export function matchSubpathFromExportsLike( createConfigError, ); - let target = exportsLikeMapAfterConditions.get(subpath); + let target = null; let patternMatch = null; - // Attempt to match after expanding any subpath pattern keys - if (target == null) { + // Attempt to match subpath pattern keys only if there is no exact key. An + // exact key takes precedence even when its target is null. See step 2 of + // `PACKAGE_IMPORTS_EXPORTS_RESOLVE` in: + // https://nodejs.org/api/esm.html#resolution-algorithm-specification + if (exportsLikeMapAfterConditions.has(subpath)) { + target = exportsLikeMapAfterConditions.get(subpath); + } else { // Gather keys which are subpath patterns in descending order of specificity // For ordering, see `PATTERN_KEY_COMPARE` in: // https://nodejs.org/api/esm.html#resolution-algorithm-specification From f349f5440eefae64bd1cbdff8f7debc7eaf5c383 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:49:58 +0100 Subject: [PATCH 39/45] metro-symbolicate: Don't throw on stack frames with no source position (#1891) (cherry picked from commit d5cf3a3793407292c70c567e3c45b531e2e14bd0) --- .../metro-symbolicate/src/Symbolication.js | 25 +++++++++++++++---- .../__fixtures__/testfile.crashreporter.stack | 6 +++++ .../__snapshots__/symbolicate-test.js.snap | 10 ++++++++ .../src/__tests__/symbolicate-test.js | 5 ++++ 4 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 packages/metro-symbolicate/src/__tests__/__fixtures__/testfile.crashreporter.stack diff --git a/packages/metro-symbolicate/src/Symbolication.js b/packages/metro-symbolicate/src/Symbolication.js index 8e6ecd71cc..ec0b551ebd 100644 --- a/packages/metro-symbolicate/src/Symbolication.js +++ b/packages/metro-symbolicate/src/Symbolication.js @@ -163,7 +163,7 @@ class SymbolicationContext { // IOS: foo.js:57:foo, Android: bar.js:75:bar symbolicate(stackTrace: string): string { return stackTrace.replace( - /(?:([^@: \n(]+)(@|:))?(?:(?:([^@: \n(]+):)?(\d+):(\d+)|\[native code\])/g, + /(?:([^@: \n(]+)(@|:))?(?:(?:([^@: \n(]+):)?(\d+):(\d+)|\[native code\](?::\d+:\d+)?)/g, (match, func, delimiter, fileName, line, column) => { if (delimiter === ':' && func && !fileName) { fileName = func; @@ -692,10 +692,25 @@ class SingleMapSymbolicationContext extends SymbolicationContext (some message with no frame) +1 ??? 0x0 throws6 + 1 (thrower.min.js:1:161) +2 ??? 0x0 o + 1 (thrower.min.js:1:464) +3 ??? 0x0 ([native code]:0:0) +4 ??? 0x0 ([native code]:0:0) diff --git a/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap b/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap index e2b6639165..d2135b3829 100644 --- a/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap +++ b/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap @@ -216,6 +216,16 @@ Array [ ] `; +exports[`symbolicating a crash reporter stack trace with 0:0 native frames 1`] = ` +"Non-fatal Exception: JavaScriptError +0 ??? 0x0 (some message with no frame) +1 ??? 0x0 throws6 + 1 (thrower.js:18:null) +2 ??? 0x0 o + 1 (thrower.js:30:arguments) +3 ??? 0x0 (null:null:null) +4 ??? 0x0 (null:null:null) +" +`; + exports[`symbolicating a profiler map 1`] = ` "JS_0000_xxxxxxxxxxxxxxxxxxxxxx throws0::thrower.js:48:11 JS_0001_xxxxxxxxxxxxxxxxxxxxxx throws6::thrower.js:35:38 diff --git a/packages/metro-symbolicate/src/__tests__/symbolicate-test.js b/packages/metro-symbolicate/src/__tests__/symbolicate-test.js index 6512b4e731..4cce261147 100644 --- a/packages/metro-symbolicate/src/__tests__/symbolicate-test.js +++ b/packages/metro-symbolicate/src/__tests__/symbolicate-test.js @@ -215,6 +215,11 @@ test('symbolicating a stack trace in Node format', async () => execute([TESTFILE_MAP], read('testfile.node.stack')), ).resolves.toMatchSnapshot()); +test('symbolicating a crash reporter stack trace with 0:0 native frames', async () => + await expect( + execute([TESTFILE_MAP], read('testfile.crashreporter.stack')), + ).resolves.toMatchSnapshot()); + test('symbolicating a single entry', async () => await expect(execute([TESTFILE_MAP, '1', '161'])).resolves.toEqual( 'thrower.js:18:null\n', From 70c53621e4ebcd698ce4785c74779a5d0c107ae6 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:38:39 +0100 Subject: [PATCH 40/45] Release workers and watchers after failed Bundler initialisation (#1963) Adapted for 0.84.x: Kept the `Promise` return annotation added in 0.84.6. (cherry picked from commit 875159144072c4bf46cc58e47c5d240cf876de6d) --- packages/metro/src/Bundler.js | 16 ++++-- .../metro/src/DeltaBundler/Transformer.js | 5 +- .../__tests__/Transformer-test.js | 22 ++++++++ packages/metro/src/__tests__/Bundler-test.js | 39 +++++++++++++ .../metro/src/node-haste/DependencyGraph.js | 3 +- .../__tests__/DependencyGraph-test.js | 56 +++++++++++++++++++ packages/metro/types/Bundler.d.ts | 4 +- 7 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 packages/metro/src/node-haste/__tests__/DependencyGraph-test.js diff --git a/packages/metro/src/Bundler.js b/packages/metro/src/Bundler.js index eff3efe312..e00cec0ebd 100644 --- a/packages/metro/src/Bundler.js +++ b/packages/metro/src/Bundler.js @@ -16,6 +16,7 @@ import type {ConfigT} from 'metro-config'; import Transformer from './DeltaBundler/Transformer'; import DependencyGraph from './node-haste/DependencyGraph'; +import nullthrows from 'nullthrows'; export type BundlerOptions = Readonly<{ hasReducedPerformance?: boolean, @@ -25,7 +26,7 @@ export type BundlerOptions = Readonly<{ export default class Bundler { _depGraph: DependencyGraph; _initializedPromise: Promise; - _transformer: Transformer; + _transformer: ?Transformer; constructor(config: ConfigT, options?: BundlerOptions) { this._depGraph = new DependencyGraph(config, options); @@ -59,10 +60,15 @@ export default class Bundler { } async end(): Promise { - await this.ready(); + // Initialization errors are surfaced by ready() and the reporter, and + // shouldn't prevent teardown of whatever was started. + await this._initializedPromise.catch(() => {}); - await this._transformer.end(); - await this._depGraph.end(); + try { + await this._transformer?.end(); + } finally { + await this._depGraph.end(); + } } async getDependencyGraph(): Promise { @@ -81,7 +87,7 @@ export default class Bundler { // TODO: Remove this ugly hack! await this.ready(); - return this._transformer.transformFile( + return nullthrows(this._transformer).transformFile( filePath, transformOptions, fileBuffer, diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index d3c92a0dfd..e2c28353ed 100644 --- a/packages/metro/src/DeltaBundler/Transformer.js +++ b/packages/metro/src/DeltaBundler/Transformer.js @@ -61,8 +61,6 @@ export default class Transformer { transformerConfig, }; - this._workerFarm = new WorkerFarm(config, transformerOptions); - const globalCacheKey = this._cache.isDisabled ? '' : getTransformCacheKey({ @@ -74,6 +72,9 @@ export default class Transformer { const baseHashBuffer = stableHash([globalCacheKey]); this._baseHash = baseHashBuffer.toString('binary'); debug('Base hash: %s', baseHashBuffer.toString('hex')); + + // Start workers last - nothing can end them if the constructor throws. + this._workerFarm = new WorkerFarm(config, transformerOptions); } async transformFile( diff --git a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js index 1a59581ac8..3dc351001b 100644 --- a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js @@ -214,4 +214,26 @@ describe('Transformer', function () { expect(require('../getTransformCacheKey')).not.toBeCalled(); }); + + test('does not start workers if the transformer cache key throws', () => { + const error = new Error("Cannot find module 'babel-preset-expo'"); + require('../getTransformCacheKey').mockImplementationOnce(() => { + throw error; + }); + const WorkerFarm = require('../WorkerFarm').default; + WorkerFarm.mockClear(); + + expect( + () => + new Transformer( + { + ...commonOptions, + cacheStores: [{get: jest.fn(), set: jest.fn()}], + watchFolders, + }, + {getOrComputeSha1}, + ), + ).toThrow(error); + expect(WorkerFarm).not.toBeCalled(); + }); }); diff --git a/packages/metro/src/__tests__/Bundler-test.js b/packages/metro/src/__tests__/Bundler-test.js index 8515b01c5f..14c32f8990 100644 --- a/packages/metro/src/__tests__/Bundler-test.js +++ b/packages/metro/src/__tests__/Bundler-test.js @@ -29,13 +29,16 @@ const MockDependencyGraph: ClassMock = DependencyGraph; describe('Bundler', () => { let config: ConfigT; let reporter; + let depGraphEnd; beforeEach(() => { reporter = {update: jest.fn()}; config = {...getDefaultConfig.getDefaultValues('/'), reporter}; + depGraphEnd = jest.fn().mockResolvedValue(); MockDependencyGraph.mockImplementation(() => ({ ready: jest.fn().mockResolvedValue(), + end: depGraphEnd, })); jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -70,6 +73,42 @@ describe('Bundler', () => { }, ); + test('ends the dependency graph when Transformer initialization fails', async () => { + MockTransformer.mockImplementation(() => { + throw new Error('Transformer initialization failed'); + }); + + const bundler = new Bundler(config); + + await expect(bundler.end()).resolves.toBeUndefined(); + expect(depGraphEnd).toHaveBeenCalledTimes(1); + }); + + test('ends the Transformer and dependency graph after initialization', async () => { + const transformerEnd = jest.fn().mockResolvedValue(); + MockTransformer.mockImplementation(() => ({end: transformerEnd})); + + const bundler = new Bundler(config); + await bundler.ready(); + await bundler.end(); + + expect(transformerEnd).toHaveBeenCalledTimes(1); + expect(depGraphEnd).toHaveBeenCalledTimes(1); + }); + + test('ends the dependency graph when ending the Transformer fails', async () => { + const error = new Error('Farm is ended'); + MockTransformer.mockImplementation(() => ({ + end: jest.fn().mockRejectedValue(error), + })); + + const bundler = new Bundler(config); + await bundler.ready(); + + await expect(bundler.end()).rejects.toBe(error); + expect(depGraphEnd).toHaveBeenCalledTimes(1); + }); + test('does not emit an unhandled rejection before ready is called', async () => { jest.useRealTimers(); diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index dce9510767..7e584984e7 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -285,7 +285,8 @@ export default class DependencyGraph extends EventEmitter { } async end(): Promise { - await this.ready(); + // A failed build is surfaced by ready(), and shouldn't prevent teardown. + await this._initializedPromise.catch(() => {}); await this._haste.end(); } diff --git a/packages/metro/src/node-haste/__tests__/DependencyGraph-test.js b/packages/metro/src/node-haste/__tests__/DependencyGraph-test.js new file mode 100644 index 0000000000..15f950dec0 --- /dev/null +++ b/packages/metro/src/node-haste/__tests__/DependencyGraph-test.js @@ -0,0 +1,56 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {ConfigT} from 'metro-config'; + +import DependencyGraph from '../DependencyGraph'; +import createFileMap from '../DependencyGraph/createFileMap'; +import {getDefaultConfig} from 'metro-config'; + +jest.mock('../DependencyGraph/createFileMap'); + +type FunctionMock = JestMockFn, unknown>; + +// $FlowFixMe[incompatible-type] Jest automocks the default export +const mockCreateFileMap: FunctionMock = createFileMap; + +describe('DependencyGraph', () => { + let config: ConfigT; + let fileMapEnd; + + beforeEach(() => { + config = { + ...getDefaultConfig.getDefaultValues('/'), + reporter: {update: jest.fn()}, + }; + fileMapEnd = jest.fn().mockResolvedValue(); + }); + + test('ends the file map when the build fails', async () => { + const error = new Error('Crawl failed'); + mockCreateFileMap.mockReturnValue({ + fileMap: { + build: jest.fn().mockRejectedValue(error), + end: fileMapEnd, + on: jest.fn(), + setMaxListeners: jest.fn(), + }, + hasteMap: {}, + dependencyPlugin: null, + }); + + const depGraph = new DependencyGraph(config); + + await expect(depGraph.ready()).rejects.toBe(error); + await expect(depGraph.end()).resolves.toBeUndefined(); + expect(fileMapEnd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/metro/types/Bundler.d.ts b/packages/metro/types/Bundler.d.ts index 86d8279403..7c4fd5a151 100644 --- a/packages/metro/types/Bundler.d.ts +++ b/packages/metro/types/Bundler.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<3d5664309abdece0f247fcd0c53c3aaf>> + * @generated SignedSource<<0bcc56baaa3cab86e03f6ac13b6ecb9a>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/Bundler.js @@ -30,7 +30,7 @@ export type BundlerOptions = Readonly<{ declare class Bundler { _depGraph: DependencyGraph; _initializedPromise: Promise; - _transformer: Transformer; + _transformer: null | undefined | Transformer; constructor(config: ConfigT, options?: BundlerOptions); getWatcher(): EventEmitter; end(): Promise; From 21417b54859a647d68a24407abc87b2b3ac15d05 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:17:53 +0100 Subject: [PATCH 41/45] Consumer: Don't decode every source map segment twice (#1978) `MappingsConsumer` decoded the VLQ of every mapping twice - once through its cache and once more with the result discarded - since the consumer was added in ccd508c4a. This removes the second call. `composeSourceMaps` with a bundle map and its Hermes map is 13.6% faster (95% CI 13.3-13.8%; 2,088ms -> 1,801ms on our benchmark app*), with byte-identical output. Composition memory is within 0.2% (-1MB*). | | Time (median) | vs previous | vs `main` | Composition memory* | vs previous | vs `main` | Peak RSS | |---|---|---|---|---|---|---|---| | `main` | 2,088ms (2,082 to 2,094) | | | 1,070MB (1,070 to 1,070) | | | 1,664MB | | This diff | 1,801ms (1,794 to 1,806) | -13.6% (-13.8 to -13.3) | -13.6% (-13.8 to -13.3) | 1,069MB (1,069 to 1,069) | -0.1% (-0.2 to -0.1) | -0.1% (-0.2 to -0.1) | 1,663MB | ## Changelog ``` - **[Performance]**: Don't decode source map segments twice in `metro-source-map`'s `Consumer` ``` ## Test plan ``` yarn jest packages/metro-source-map packages/metro-symbolicate yarn flow check ``` \* Benchmark: Mattermost Mobile 2.45.0 (React Native 0.83.9, Metro 0.83.7), iOS release bundle, unminified: 7,086 sources, 52.6MB bundle, 82.4MB flat source map. Composed with its `hermesc -O -output-source-map` map (hermes-compiler 0.14.1; 10.7MB, 1.84M segments). Timings are `composeSourceMaps` alone, excluding JSON parsing, over 105 rounds on Node 22.13.1 on an M5 Pro; each round runs `main`, every diff in this stack and a memory baseline, in a shuffled order. Figures are medians with 95% bootstrap confidence intervals - for changes, of the per-round paired ratio. Composition memory is peak RSS less that of the same process parsing both maps without composing them (594MB). (cherry picked from commit 5e083376d499824e6fadcfbcd6fd9e28c7fd3ad6) --- packages/metro-source-map/src/Consumer/MappingsConsumer.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/metro-source-map/src/Consumer/MappingsConsumer.js b/packages/metro-source-map/src/Consumer/MappingsConsumer.js index bae03c4234..54777ef14a 100644 --- a/packages/metro-source-map/src/Consumer/MappingsConsumer.js +++ b/packages/metro-source-map/src/Consumer/MappingsConsumer.js @@ -140,7 +140,6 @@ export default class MappingsConsumer originalColumnDelta, nameIndexDelta, ] = decodedVlqValues; - decodeVlq(mappingRaw); invariant(generatedColumnDelta != null, 'Invalid generated column delta'); generatedColumn = add(generatedColumn, generatedColumnDelta); const mapping: {...Mapping, ...} = { From 50f4b97f43e59cd7353c49eb0912ec7488f46005 Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Tue, 29 Sep 2026 02:26:14 -0500 Subject: [PATCH 42/45] fix: skip errors that occurred on virtual lines (#1226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * skip errors that occurred on virtual lines * Update ModuleResolution.js * Add regression test for resolution errors past the end of the source This fix has had no test since the PR was opened, which is what the review asked for in 2024. Adds one at the integration level, in `build-errors-test.js`, so it exercises the real transform and resolution path rather than constructing the error directly. `injectImportPastEndOfFileTransformer.js` wraps `@react-native/metro-babel-transformer` and, for one fixture, appends `import './does-not-exist';` parsed with `startLine` one line past the end of the source — which is what a plugin that generates and appends code does. The dependency Metro collects then reports a location with no counterpart in the file on disk, so building the code frame for the resolution error walks off the end of `lines`. Without the guard, the build rejects with `TypeError: Cannot read properties of undefined (reading 'length')` in place of the resolution error. With it, the error survives and the code frame degrades to the lines that do exist. The assertion is deliberately not a snapshot: the resolver's list of candidate paths varies between versions, and what matters here is only that the resolution error survives. Changelog: [Internal] --------- Co-authored-by: Rob Hogan <2590098+robhogan@users.noreply.github.com> (cherry picked from commit 5d6eaeab1bd35359090f88f668770b2b6601572e) --- .../__tests__/build-errors-test.js | 34 ++++++++++ .../build-errors/transform-injected-import.js | 13 ++++ .../injectImportPastEndOfFileTransformer.js | 67 +++++++++++++++++++ .../DependencyGraph/ModuleResolution.js | 5 ++ 4 files changed, 119 insertions(+) create mode 100644 packages/metro/src/integration_tests/basic_bundle/build-errors/transform-injected-import.js create mode 100644 packages/metro/src/integration_tests/injectImportPastEndOfFileTransformer.js diff --git a/packages/metro/src/integration_tests/__tests__/build-errors-test.js b/packages/metro/src/integration_tests/__tests__/build-errors-test.js index a94a3326d4..533bc205c7 100644 --- a/packages/metro/src/integration_tests/__tests__/build-errors-test.js +++ b/packages/metro/src/integration_tests/__tests__/build-errors-test.js @@ -113,6 +113,40 @@ describe('formatting edge cases', () => { await expect(buildPromise).rejects.toMatchSnapshot(); }); + test('reports resolution errors on locations past the end of the source', async () => { + // A Babel plugin appends an unresolvable import whose source location is + // past the end of the file on disk. The resolution error must survive: + // before, building the code frame threw a TypeError that replaced it. + const config = await Metro.loadConfig({ + config: require.resolve('../metro.config.js'), + }); + + const buildPromise = Metro.runBuild( + { + ...config, + transformer: { + ...config.transformer, + babelTransformerPath: require.resolve( + '../injectImportPastEndOfFileTransformer', + ), + }, + }, + {entry: 'build-errors/transform-injected-import.js'}, + ); + + // Deliberately not a snapshot: the resolver's list of candidate paths + // varies between versions, and what matters here is only that the + // resolution error survives with a code frame of the lines that do exist. + const error = await buildPromise.then( + () => null, + (e: Error) => e, + ); + expect(error?.message).toMatch( + /^Unable to resolve module \.\/does-not-exist/, + ); + expect(error?.message).toContain('13 | global.x = 1;'); + }); + test('reports resolution errors with embedded comment after the specifier', async () => { const config = await Metro.loadConfig({ config: require.resolve('../metro.config.js'), diff --git a/packages/metro/src/integration_tests/basic_bundle/build-errors/transform-injected-import.js b/packages/metro/src/integration_tests/basic_bundle/build-errors/transform-injected-import.js new file mode 100644 index 0000000000..0df51fd906 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/build-errors/transform-injected-import.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +// The unresolvable import in this module is added by a Babel plugin, not +// written here - see injectImportPastEndOfFileTransformer.js. +global.x = 1; diff --git a/packages/metro/src/integration_tests/injectImportPastEndOfFileTransformer.js b/packages/metro/src/integration_tests/injectImportPastEndOfFileTransformer.js new file mode 100644 index 0000000000..e75b85391a --- /dev/null +++ b/packages/metro/src/integration_tests/injectImportPastEndOfFileTransformer.js @@ -0,0 +1,67 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +'use strict'; + +import type {PluginObj} from '@babel/core'; +import type { + BabelTransformer, + BabelTransformerArgs, +} from 'metro-babel-transformer'; + +const {parse} = require('@babel/parser'); +const baseTransformer = require('@react-native/metro-babel-transformer'); + +const TARGET_FILE = 'transform-injected-import.js'; +const INJECTED_SPECIFIER = './does-not-exist'; + +/** + * Returns a Babel plugin that appends `import './does-not-exist';` to the + * module, parsed at `startLine` - which is what a plugin that generates and + * appends code does, so that the generated code's locations do not overlap the + * author's. + * + * Given a `startLine` past the end of the file, the dependency Metro collects + * reports a source location that has no counterpart in the file on disk, which + * is what the resolution error's code frame has to tolerate. + */ +function injectImportAt(startLine: number): () => PluginObj<> { + return () => ({ + name: 'metro-test-inject-import-past-end-of-file', + visitor: { + Program(path) { + const generated = parse(`import '${INJECTED_SPECIFIER}';`, { + sourceType: 'module', + startLine, + }); + path.node.body.push(...generated.program.body); + }, + }, + }); +} + +function transform(args: BabelTransformerArgs) { + if (!args.filename.endsWith(TARGET_FILE)) { + return baseTransformer.transform(args); + } + // One line past the last line of the source, so the injected import's + // location does not exist in the file on disk. + const startLine = args.src.split('\n').length + 1; + return baseTransformer.transform({ + ...args, + plugins: [...(args.plugins ?? []), injectImportAt(startLine)], + }); +} + +module.exports = { + transform, + getCacheKey: baseTransformer.getCacheKey, +} as BabelTransformer; diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index dbe99c0714..b61b9cad58 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -365,6 +365,11 @@ function refineDependencyLocation( // Note that module names may not always be found in the source code verbatim, // whether because of escaping or because of exotic dependency APIs. for (let line = loc.end.line - 1; line >= loc.start.line - 1; line--) { + // The error occurred in code that was added by a transform, then it + // won't be present in the original source code. + if (lines[line] == null) { + continue; + } const maxColumn = line === loc.end.line ? loc.end.column + 2 : lines[line].length; const minColumn = line === loc.start.line ? loc.start.column - 1 : 0; From 3c975b024bb1ee55e7559f164f84c89e1ab87866 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:56:03 +0100 Subject: [PATCH 43/45] Fix backslashes in async bundle paths on Windows (#1938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: With `includeAsyncPaths`, Metro writes a server-relative URL for each async dependency into the `paths` argument of `__d(...)`, which the client uses to fetch the split bundle. `getDefaultAsyncDependencyPath` builds that URL with `path.relative` and `path.join`, and never converts separators, so on Windows a dependency at `C:\root\src\bar.js` under server root `C:\root` produces: ``` /src\bar.bundle?modulesOnly=true&runModule=false ``` This is a URL, and Metro's own parsing of bundle URLs treats the pathname as posix (`split('/')` in `parseBundleOptionsFromBundleRequestUrl`), so it should be `/src/bar.bundle?...` on every platform. This passes the joined path through `normalizePathSeparatorsToPosix`, already used in the same file for module debug names. No change on posix. Changelog: [Fix] Fix backslashes in async bundle paths on Windows (`includeAsyncPaths`) Test Plan: Removes `js-test.js` from the Windows skip list. Its three async `paths` tests already assert forward-slash URLs from Windows-style inputs. Before this change, on Windows CI: ``` ✕ includes the paths of async dependencies when requested - "paths":{"1":"/../bar.bundle?param1=true¶m2=1234&modulesOnly=true&runModule=false"} + "paths":{"1":"/..\\bar.bundle?param1=true¶m2=1234&modulesOnly=true&runModule=false"} ✕ async bundle paths override modulesOnly and runModule ✕ passes a paths object (not the id array) for async dependencies ``` After, `yarn jest packages/metro/src/DeltaBundler` passes, and Windows CI on this PR is green for `js-test.js`. Adapted for 0.84.x: Hand-ported, since 0.84.x builds async bundle paths inline rather than in `getDefaultAsyncDependencyPath`. (cherry picked from commit d52ee79771ce14ac6b1e9901edb3653e548ca497) --- .../metro/src/DeltaBundler/Serializers/helpers/js.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/metro/src/DeltaBundler/Serializers/helpers/js.js b/packages/metro/src/DeltaBundler/Serializers/helpers/js.js index 75cde3fc4e..1d7aee4636 100644 --- a/packages/metro/src/DeltaBundler/Serializers/helpers/js.js +++ b/packages/metro/src/DeltaBundler/Serializers/helpers/js.js @@ -80,11 +80,13 @@ export function getModuleParams( ); paths[id] = '/' + - path.join( - // TODO: This is not the proper Metro URL encoding of a file path - path.dirname(bundlePath), - // Strip the file extension - path.basename(bundlePath, path.extname(bundlePath)), + normalizePathSeparatorsToPosix( + path.join( + // TODO: This is not the proper Metro URL encoding of a file path + path.dirname(bundlePath), + // Strip the file extension + path.basename(bundlePath, path.extname(bundlePath)), + ), ) + '.bundle?' + searchParams.toString(); From 8e975d6c8ef9afdecda5fa9c355fbc089d67ec20 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:12:48 +0100 Subject: [PATCH 44/45] Use indexed watch folder paths for external assets (#1892) Adapted for 0.84.x: Tests use 0.84.x conventions (no `node:` mocks). (cherry picked from commit 5793c17017825d1d59191d080114365e1da569ae) --- packages/metro-transform-worker/package.json | 3 +- .../src/__tests__/index-test.js | 21 ++++++ packages/metro-transform-worker/src/index.js | 8 ++ .../src/utils/assetTransformer.js | 3 +- .../metro-transform-worker/types/index.d.ts | 3 +- .../types/utils/assetTransformer.d.ts | 3 +- packages/metro/src/Assets.js | 74 ++++++++++++++++--- .../Serializers/__tests__/getAssets-test.js | 23 +++++- .../src/DeltaBundler/Serializers/getAssets.js | 9 ++- .../metro/src/DeltaBundler/Transformer.js | 14 ++++ .../metro/src/DeltaBundler/Worker.flow.js | 5 ++ packages/metro/src/DeltaBundler/WorkerFarm.js | 2 + .../__tests__/Transformer-test.js | 32 ++++++++ .../DeltaBundler/__tests__/WorkerFarm-test.js | 26 +++++++ packages/metro/src/Server.js | 8 +- .../metro/src/Server/__tests__/Server-test.js | 71 +++++++++++++++++- packages/metro/src/__tests__/Assets-test.js | 29 +++++++- packages/metro/types/Assets.d.ts | 20 ++++- .../DeltaBundler/Serializers/getAssets.d.ts | 3 +- packages/metro/types/DeltaBundler/Worker.d.ts | 3 +- .../metro/types/DeltaBundler/WorkerFarm.d.ts | 3 +- 21 files changed, 337 insertions(+), 26 deletions(-) diff --git a/packages/metro-transform-worker/package.json b/packages/metro-transform-worker/package.json index 44c06bac2f..d2f727f604 100644 --- a/packages/metro-transform-worker/package.json +++ b/packages/metro-transform-worker/package.json @@ -35,7 +35,8 @@ }, "devDependencies": { "@react-native/metro-babel-transformer": "0.78.0", - "metro-memory-fs": "*" + "metro-memory-fs": "*", + "metro-resolver": "*" }, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" diff --git a/packages/metro-transform-worker/src/__tests__/index-test.js b/packages/metro-transform-worker/src/__tests__/index-test.js index 81aba4c9f0..7f98e5a6ba 100644 --- a/packages/metro-transform-worker/src/__tests__/index-test.js +++ b/packages/metro-transform-worker/src/__tests__/index-test.js @@ -136,6 +136,27 @@ test('transforms a simple module', async () => { expect(result.dependencies).toEqual([]); }); +test('uses the indexed watch folder path for asset URLs', async () => { + fs.mkdirSync('/root/external', {recursive: true}); + fs.writeFileSync('/root/external/test.mp4', 'asset data'); + + const result = await Transformer.transform( + baseConfig, + '/root', + 'external/test.mp4', + Buffer.from('asset data'), + { + ...baseTransformOptions, + type: 'asset', + }, + '[metro-watchFolders]/1/test.mp4', + ); + + expect(result.output[0].data.code).toContain( + '"httpServerLocation": "/assets/[metro-watchFolders]/1"', + ); +}); + test('transforms a module with dependencies', async () => { const contents = [ '"use strict";', diff --git a/packages/metro-transform-worker/src/index.js b/packages/metro-transform-worker/src/index.js index acff7eb377..4076554fd2 100644 --- a/packages/metro-transform-worker/src/index.js +++ b/packages/metro-transform-worker/src/index.js @@ -160,6 +160,7 @@ type JSONFile = { }; type TransformationContext = Readonly<{ + assetUrlPath: ?string, config: JsTransformerConfig, projectRoot: AbsolutePath, options: JsTransformOptions, @@ -521,6 +522,7 @@ async function transformAsset( getBabelTransformArgs(file, context), assetRegistryPath, assetPlugins, + context.assetUrlPath ?? undefined, ); const jsFile = { @@ -658,8 +660,14 @@ export const transform = async ( projectRelativePath: string, data: Buffer, options: JsTransformOptions, + // The asset's path relative to the asset server root (`publicPath`), as + // clients will request it. Computed by Metro for assets only, and passed + // separately from the options so that it is not spread into + // BabelTransformerArgs. + assetUrlPath?: string, ): Promise => { const context: TransformationContext = { + assetUrlPath, config, options, projectRoot, diff --git a/packages/metro-transform-worker/src/utils/assetTransformer.js b/packages/metro-transform-worker/src/utils/assetTransformer.js index 008adc90da..a314d59ad2 100644 --- a/packages/metro-transform-worker/src/utils/assetTransformer.js +++ b/packages/metro-transform-worker/src/utils/assetTransformer.js @@ -20,6 +20,7 @@ export async function transform( {filename, options, src}: BabelTransformerArgs, assetRegistryPath: string, assetDataPlugins: ReadonlyArray, + urlPath?: string, ): Promise<{ast: File, ...}> { options = options || { platform: '', @@ -32,7 +33,7 @@ export async function transform( const data = await getAssetData( absolutePath, - filename, + urlPath ?? filename, assetDataPlugins, options.platform, options.publicPath, diff --git a/packages/metro-transform-worker/types/index.d.ts b/packages/metro-transform-worker/types/index.d.ts index 0ce6451fa2..8497a909a5 100644 --- a/packages/metro-transform-worker/types/index.d.ts +++ b/packages/metro-transform-worker/types/index.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<5fe0f33f61a5a4a94ab429ad35b5e04a>> + * @generated SignedSource<<86e39ddf853e99cb0e4817de3f817a2c>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-transform-worker/src/index.js @@ -105,6 +105,7 @@ export declare const transform: ( projectRelativePath: string, data: Buffer, options: JsTransformOptions, + assetUrlPath?: string, ) => Promise; export declare type transform = typeof transform; export declare const getCacheKey: ( diff --git a/packages/metro-transform-worker/types/utils/assetTransformer.d.ts b/packages/metro-transform-worker/types/utils/assetTransformer.d.ts index 6f6e5fd4b0..e9ab8bf642 100644 --- a/packages/metro-transform-worker/types/utils/assetTransformer.d.ts +++ b/packages/metro-transform-worker/types/utils/assetTransformer.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-transform-worker/src/utils/assetTransformer.js @@ -22,4 +22,5 @@ export declare function transform( $$PARAM_0$$: BabelTransformerArgs, assetRegistryPath: string, assetDataPlugins: ReadonlyArray, + urlPath?: string, ): Promise<{ast: File}>; diff --git a/packages/metro/src/Assets.js b/packages/metro/src/Assets.js index 11427e9c39..a53932b978 100644 --- a/packages/metro/src/Assets.js +++ b/packages/metro/src/Assets.js @@ -201,20 +201,26 @@ async function getAbsoluteAssetInfo( export async function getAssetData( assetPath: string, - localPath: string, + urlPath: string, assetDataPlugins: ReadonlyArray, platform: ?string, publicPath: string, ): Promise { - // If the path of the asset is outside of the projectRoot, we don't want to - // use `path.join` since this will generate an incorrect URL path. In that - // case we just concatenate the publicPath with the relative path. - let assetUrlPath = localPath.startsWith('..') - ? publicPath.replace(/\/$/, '') + '/' + path.dirname(localPath) - : path.join(publicPath, path.dirname(localPath)); + // urlPath is the asset's path relative to publicPath. Only its directory + // reaches the client, which composes the file name from the scale and + // platform it needs. + // + // Metro itself never passes a path starting with `..` here, since assets + // outside projectRoot are addressed by watch folder index. Custom + // transformers that call this with a project-relative path still can, and + // `path.join` would collapse the `..` against publicPath, so concatenate + // instead to preserve it. + let httpServerLocation = urlPath.startsWith('..') + ? publicPath.replace(/\/$/, '') + '/' + path.dirname(urlPath) + : path.join(publicPath, path.dirname(urlPath)); // On Windows, change backslashes to slashes to get proper URL path from file path. - assetUrlPath = normalizePathSeparatorsToPosix(assetUrlPath); + httpServerLocation = normalizePathSeparatorsToPosix(httpServerLocation); const {assetInfo, firstFileContent} = await getAbsoluteAssetInfo( assetPath, @@ -228,7 +234,7 @@ export async function getAssetData( const assetData = { __packager_asset: true, fileSystemLocation: path.dirname(assetPath), - httpServerLocation: assetUrlPath, + httpServerLocation, width: dimensions ? dimensions.width / scale : undefined, height: dimensions ? dimensions.height / scale : undefined, scales: assetInfo.scales, @@ -240,6 +246,56 @@ export async function getAssetData( return await applyAssetDataPlugins(assetDataPlugins, assetData); } +/** + * Returns the path used to identify an asset in its development server URL, + * relative to publicPath. Assets outside projectRoot use an indexed watch + * folder prefix so that the URL unambiguously identifies their configured + * root. + * + * Roots are resolved as Server does when it decodes these paths, so that the + * index here and the index it reads back refer to the same directory. The + * first root to contain the asset wins, in index order, so that appending a + * watch folder never changes the URL of an asset under an existing one. + */ +export function getAssetUrlPath( + assetPath: string, + projectRoot: string, + watchFolders: ReadonlyArray, +): string { + const projectRelativePath = path.relative( + path.resolve(projectRoot), + assetPath, + ); + if (isPathInsideRoot(projectRelativePath)) { + return normalizePathSeparatorsToPosix(projectRelativePath); + } + + for (let i = 0; i < watchFolders.length; i++) { + const watchFolderRelativePath = path.relative( + path.resolve(watchFolders[i]), + assetPath, + ); + if (isPathInsideRoot(watchFolderRelativePath)) { + return normalizePathSeparatorsToPosix( + path.join('[metro-watchFolders]', String(i), watchFolderRelativePath), + ); + } + } + + throw new Error( + `Asset '${assetPath}' is not within projectRoot '${projectRoot}' or any ` + + `watch folder (${watchFolders.join(', ')})`, + ); +} + +function isPathInsideRoot(relativePath: string): boolean { + return ( + relativePath !== '..' && + !relativePath.startsWith('..' + path.sep) && + !path.isAbsolute(relativePath) + ); +} + async function applyAssetDataPlugins( assetDataPlugins: ReadonlyArray, assetData: AssetData, diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js index c13c148b66..08682ce876 100644 --- a/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js @@ -10,7 +10,7 @@ jest.mock('../../../Assets'); -import {getAssetData} from '../../../Assets'; +import {getAssetData, getAssetUrlPath} from '../../../Assets'; import getAssets from '../getAssets'; beforeEach(() => { @@ -18,6 +18,9 @@ beforeEach(() => { path, localPath, })); + getAssetUrlPath.mockImplementation( + jest.requireActual('../../../Assets').getAssetUrlPath, + ); }); test('should return the bundle assets', async () => { @@ -82,16 +85,32 @@ test('should return the bundle assets', async () => { ], }, ], + [ + '/external/6.png', + { + path: '/external/6.png', + output: [ + { + type: 'js/module/asset', + data: {code: '//', lineCount: 1, map: [], functionMap: null}, + }, + ], + }, + ], ]); expect( await getAssets(dependencies, { projectRoot: '/tmp', - watchFolders: ['/tmp'], + watchFolders: ['/tmp', '/external'], processModuleFilter: () => true, }), ).toEqual([ {path: '/tmp/3.png', localPath: '3.png'}, {path: '/tmp/5.mov', localPath: '5.mov'}, + { + path: '/external/6.png', + localPath: '[metro-watchFolders]/1/6.png', + }, ]); }); diff --git a/packages/metro/src/DeltaBundler/Serializers/getAssets.js b/packages/metro/src/DeltaBundler/Serializers/getAssets.js index 2c42caef1c..8d877cbfac 100644 --- a/packages/metro/src/DeltaBundler/Serializers/getAssets.js +++ b/packages/metro/src/DeltaBundler/Serializers/getAssets.js @@ -12,7 +12,7 @@ import type {AssetData} from '../../Assets'; import type {Module, ReadOnlyDependencies} from '../types'; -import {getAssetData} from '../../Assets'; +import {getAssetData, getAssetUrlPath} from '../../Assets'; import {getJsOutput, isJsModule} from './helpers/js'; import path from 'path'; @@ -22,6 +22,7 @@ type Options = { platform: ?string, projectRoot: string, publicPath: string, + watchFolders: ReadonlyArray, }; export default async function getAssets( @@ -41,7 +42,11 @@ export default async function getAssets( promises.push( getAssetData( module.path, - path.relative(options.projectRoot, module.path), + getAssetUrlPath( + module.path, + options.projectRoot, + options.watchFolders, + ), options.assetPlugins, options.platform, options.publicPath, diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index e2c28353ed..93845b2239 100644 --- a/packages/metro/src/DeltaBundler/Transformer.js +++ b/packages/metro/src/DeltaBundler/Transformer.js @@ -13,6 +13,7 @@ import type {TransformResult, TransformResultWithSource} from '../DeltaBundler'; import type {TransformerConfig, TransformOptions} from './Worker'; import type {ConfigT} from 'metro-config'; +import {getAssetUrlPath} from '../Assets'; import {normalizePathSeparatorsToPosix} from '../lib/pathUtils'; import getTransformCacheKey from './getTransformCacheKey'; import WorkerFarm from './WorkerFarm'; @@ -113,6 +114,17 @@ export default class Transformer { this._config.projectRoot, filePath, ); + // Assets are the only modules whose output depends on watchFolders, via + // the URL path baked into them, so that dependency enters the cache key + // per asset here and not in the base hash. + const assetUrlPath = + type === 'asset' + ? getAssetUrlPath( + filePath, + this._config.projectRoot, + this._config.watchFolders, + ) + : null; const partialKey = stableHash([ // This is the hash related to the global Bundler config. @@ -122,6 +134,7 @@ export default class Transformer { // addition to content hash because transformers receive path as an // input, and may apply e.g. extension-based logic. normalizePathSeparatorsToPosix(projectRelativePath), + assetUrlPath, customTransformOptions, dev, experimentalImportSupport, @@ -173,6 +186,7 @@ export default class Transformer { projectRelativePath, transformerOptions, content, + assetUrlPath ?? undefined, ); // Only re-compute the full key if the SHA-1 changed. This is because diff --git a/packages/metro/src/DeltaBundler/Worker.flow.js b/packages/metro/src/DeltaBundler/Worker.flow.js index 27e2776cfc..b00be2cc60 100644 --- a/packages/metro/src/DeltaBundler/Worker.flow.js +++ b/packages/metro/src/DeltaBundler/Worker.flow.js @@ -30,6 +30,7 @@ type TransformerInterface = { string, Buffer, JsTransformOptions, + ?string, ): Promise>, }; @@ -72,6 +73,7 @@ export const transform = ( projectRoot: string, transformerConfig: TransformerConfig, fileBuffer?: Buffer, + assetUrlPath?: string, ): Promise => { let data; @@ -87,6 +89,7 @@ export const transform = ( transformOptions, projectRoot, transformerConfig, + assetUrlPath, ); }; @@ -100,6 +103,7 @@ async function transformFile( transformOptions: JsTransformOptions, projectRoot: string, transformerConfig: TransformerConfig, + assetUrlPath?: string, ): Promise { // eslint-disable-next-line no-useless-call const Transformer: TransformerInterface = require.call( @@ -123,6 +127,7 @@ async function transformFile( projectRelativePath, data, transformOptions, + assetUrlPath, ); // The babel cache caches scopes and pathes for already traversed AST nodes. diff --git a/packages/metro/src/DeltaBundler/WorkerFarm.js b/packages/metro/src/DeltaBundler/WorkerFarm.js index c0502139ab..5e211b9c15 100644 --- a/packages/metro/src/DeltaBundler/WorkerFarm.js +++ b/packages/metro/src/DeltaBundler/WorkerFarm.js @@ -76,6 +76,7 @@ export default class WorkerFarm { filename: string, options: TransformOptions, fileBuffer?: Buffer, + assetUrlPath?: string, ): Promise { try { const data = await this._worker.transform( @@ -84,6 +85,7 @@ export default class WorkerFarm { this._config.projectRoot, this._transformerConfig, fileBuffer, + assetUrlPath, ); Logger.log(data.transformFileStartLogEntry); diff --git a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js index 3dc351001b..8fdae5e35f 100644 --- a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js @@ -23,6 +23,7 @@ const Transformer = require('../Transformer').default; const fs = require('fs'); const {getDefaultValues} = require('metro-config').getDefaultConfig; const {mergeConfig} = require('metro-config/private/loadConfig'); +const path = require('node:path'); describe('Transformer', function () { let watchFolders; @@ -215,6 +216,37 @@ describe('Transformer', function () { expect(require('../getTransformCacheKey')).not.toBeCalled(); }); + test('passes an indexed watch folder URL path to asset transforms', async () => { + const workerTransform = + require('../WorkerFarm').default.prototype.transform; + workerTransform.mockClear(); + workerTransform.mockReturnValue({ + sha1: 'abcdefabcdefabcdefabcdefabcdefabcdefabcd', + result: {}, + }); + fs.mkdirSync('/external', {recursive: true}); + + const transformerInstance = new Transformer( + { + ...commonOptions, + cacheStores: [], + watchFolders: ['/root', '/external'], + }, + {getOrComputeSha1}, + ); + + await transformerInstance.transformFile('/external/imgs/a.png', { + type: 'asset', + }); + + expect(workerTransform).toHaveBeenCalledWith( + path.join('..', 'external', 'imgs', 'a.png'), + {type: 'asset'}, + undefined, + '[metro-watchFolders]/1/imgs/a.png', + ); + }); + test('does not start workers if the transformer cache key throws', () => { const error = new Error("Cannot find module 'babel-preset-expo'"); require('../getTransformCacheKey').mockImplementationOnce(() => { diff --git a/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js b/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js index c35c84d576..2511dd4161 100644 --- a/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js @@ -76,6 +76,30 @@ describe('Worker Farm', function () { config.projectRoot, transformerConfig, undefined, + undefined, + ); + }); + + test('passes the asset URL path through to the worker', async () => { + const transformerConfig = { + transformerPath: config.transformerPath, + transformerConfig: config.transformer, + }; + + await new WorkerFarm(config, transformerConfig).transform( + fileName, + {type: 'asset'}, + undefined, + '[metro-watchFolders]/1/imgs/a.png', + ); + + expect(api.transform).toBeCalledWith( + fileName, + {type: 'asset'}, + config.projectRoot, + transformerConfig, + undefined, + '[metro-watchFolders]/1/imgs/a.png', ); }); @@ -97,6 +121,7 @@ describe('Worker Farm', function () { '/foo', transformerConfig, undefined, + undefined, ); await farm.kill(); @@ -113,6 +138,7 @@ describe('Worker Farm', function () { '/bar', transformerConfig, undefined, + undefined, ); }); diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index ad89cb16dd..671e50855a 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -443,6 +443,7 @@ export default class Server { platform, projectRoot: this._config.projectRoot, publicPath: this._config.transformer.publicPath, + watchFolders: this._config.watchFolders, }); } @@ -563,9 +564,12 @@ export default class Server { try { const depGraph = await this._bundler.getBundler().getDependencyGraph(); + const resolvedAssetPath = this._resolveWatchFolderPrefix( + './' + assetPath, + ); const data = await getAsset( - assetPath, - this._config.projectRoot, + resolvedAssetPath?.filePath ?? assetPath, + resolvedAssetPath?.rootDir ?? this._config.projectRoot, this._config.watchFolders, urlObj.searchParams.get('platform'), this._config.resolver.assetExts, diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index eb9d754f43..c67340ef29 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -30,6 +30,9 @@ import MockResponse from 'mock-res'; const { getDefaultConfig: {getDefaultValues}, } = require('metro-config'); +const { + posixToSystemPath: p, +} = require('metro-resolver/private/__tests__/utils'); const path = require('path'); jest @@ -852,6 +855,42 @@ describe('processRequest', () => { ); }); + test('should resolve an indexed watch folder asset path', async () => { + getAsset.mockResolvedValue(Promise.resolve('i am image')); + + const response = await makeRequest( + '/assets/[metro-watchFolders]/0/imgs/a.png?platform=ios', + ); + expect(response._getString()).toBe('i am image'); + + expect(getAsset).toBeCalledWith( + p('./imgs/a.png'), + p('/root'), + [p('/root')], + 'ios', + expect.any(Array), + expect.any(Function), + ); + }); + + test('should resolve a URL-encoded indexed watch folder asset path', async () => { + getAsset.mockResolvedValue(Promise.resolve('i am image')); + + const response = await makeRequest( + '/assets/%5Bmetro-watchFolders%5D/0/imgs/a.png?platform=ios', + ); + expect(response._getString()).toBe('i am image'); + + expect(getAsset).toBeCalledWith( + p('./imgs/a.png'), + p('/root'), + [p('/root')], + 'ios', + expect.any(Array), + expect.any(Function), + ); + }); + test('should serve range request', async () => { const mockData = 'i am image'; getAsset.mockResolvedValue(mockData); @@ -1492,7 +1531,10 @@ describe('processRequest', () => { expect(getAssetsSerializer).toBeCalledWith( expect.anything(), - expect.objectContaining({projectRoot: '/root'}), + expect.objectContaining({ + projectRoot: '/root', + watchFolders: ['/root'], + }), ); }); }); @@ -1578,5 +1620,32 @@ describe('processRequest', () => { '/project/mybundle', ); }); + + test.each([ + [p('/project/imgs/a.png')], + [p('/project/nested/deep/b.png')], + [p('/external/packages/imgs/c.png')], + [p('/external/packages/d.png')], + ])( + 'asset URL path for %s round-trips back to the same file', + absolutePath => { + const {getAssetUrlPath} = require('../../Assets'); + const urlPath = getAssetUrlPath(absolutePath, p('/project'), [ + p('/project'), + p('/external/packages'), + ]); + + // Mirrors how _processSingleAssetRequest resolves an incoming URL. + const resolved = watchFolderServer._resolveWatchFolderPrefix( + './' + urlPath, + ); + expect( + path.resolve( + resolved?.rootDir ?? p('/project'), + resolved?.filePath ?? urlPath, + ), + ).toBe(absolutePath); + }, + ); }); }); diff --git a/packages/metro/src/__tests__/Assets-test.js b/packages/metro/src/__tests__/Assets-test.js index 93f8a2c591..f071b2d652 100644 --- a/packages/metro/src/__tests__/Assets-test.js +++ b/packages/metro/src/__tests__/Assets-test.js @@ -20,7 +20,12 @@ jest.mock('../lib/imageSize', () => ({ jest.useRealTimers(); -const {getAsset, getAssetData, getAssetSize} = require('../Assets'); +const { + getAsset, + getAssetData, + getAssetSize, + getAssetUrlPath, +} = require('../Assets'); const getImageDimensions = require('../lib/imageSize').getImageDimensions; const crypto = require('crypto'); const fs = require('fs'); @@ -29,6 +34,28 @@ const path = require('path'); const mockImageWidth = 300; const mockImageHeight = 200; +describe('getAssetUrlPath', () => { + test('uses a project-relative path for assets within projectRoot', () => { + expect( + getAssetUrlPath('/root/imgs/a.png', '/root', ['/root', '/external']), + ).toBe('imgs/a.png'); + }); + + test('uses an indexed path for assets within a watch folder', () => { + expect( + getAssetUrlPath('/external/imgs/a.png', '/root', ['/root', '/external']), + ).toBe('[metro-watchFolders]/1/imgs/a.png'); + }); + + test('throws for an asset outside every configured root', () => { + expect(() => + getAssetUrlPath('/other/imgs/a.png', '/root', ['/root', '/external']), + ).toThrow( + "Asset '/other/imgs/a.png' is not within projectRoot '/root' or any watch folder (/root, /external)", + ); + }); +}); + describe('getAssetSize', () => { test('returns null for non-image assets', () => { expect(getAssetSize('mp4', Buffer.from('video'), '/root/video.mp4')).toBe( diff --git a/packages/metro/types/Assets.d.ts b/packages/metro/types/Assets.d.ts index eb77e92a9d..eb97805907 100644 --- a/packages/metro/types/Assets.d.ts +++ b/packages/metro/types/Assets.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<66a96f3882bcba8f8bd890828453052a>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/Assets.js @@ -57,11 +57,27 @@ export type AssetDataPlugin = ( ) => AssetData | Promise; export declare function getAssetData( assetPath: string, - localPath: string, + urlPath: string, assetDataPlugins: ReadonlyArray, platform: null | undefined | string, publicPath: string, ): Promise; +/** + * Returns the path used to identify an asset in its development server URL, + * relative to publicPath. Assets outside projectRoot use an indexed watch + * folder prefix so that the URL unambiguously identifies their configured + * root. + * + * Roots are resolved as Server does when it decodes these paths, so that the + * index here and the index it reads back refer to the same directory. The + * first root to contain the asset wins, in index order, so that appending a + * watch folder never changes the URL of an asset under an existing one. + */ +export declare function getAssetUrlPath( + assetPath: string, + projectRoot: string, + watchFolders: ReadonlyArray, +): string; /** * Returns all the associated files (for different resolutions) of an asset. **/ diff --git a/packages/metro/types/DeltaBundler/Serializers/getAssets.d.ts b/packages/metro/types/DeltaBundler/Serializers/getAssets.d.ts index c2ccffec0f..e9aa6fbe1b 100644 --- a/packages/metro/types/DeltaBundler/Serializers/getAssets.d.ts +++ b/packages/metro/types/DeltaBundler/Serializers/getAssets.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<0a49d828c4a80d52ccab4d4766b84c86>> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/DeltaBundler/Serializers/getAssets.js @@ -24,6 +24,7 @@ type Options = { platform: null | undefined | string; projectRoot: string; publicPath: string; + watchFolders: ReadonlyArray; }; declare function getAssets( dependencies: ReadOnlyDependencies, diff --git a/packages/metro/types/DeltaBundler/Worker.d.ts b/packages/metro/types/DeltaBundler/Worker.d.ts index ec48d676ff..f21429a8bb 100644 --- a/packages/metro/types/DeltaBundler/Worker.d.ts +++ b/packages/metro/types/DeltaBundler/Worker.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<36640ae81894592fbdc160fac081bdbf>> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/DeltaBundler/Worker.flow.js @@ -39,6 +39,7 @@ export declare const transform: ( projectRoot: string, transformerConfig: TransformerConfig, fileBuffer?: Buffer, + assetUrlPath?: string, ) => Promise; export declare type transform = typeof transform; export type Worker = {readonly transform: typeof transform}; diff --git a/packages/metro/types/DeltaBundler/WorkerFarm.d.ts b/packages/metro/types/DeltaBundler/WorkerFarm.d.ts index ec857eb608..45a47125b3 100644 --- a/packages/metro/types/DeltaBundler/WorkerFarm.d.ts +++ b/packages/metro/types/DeltaBundler/WorkerFarm.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<5839102968c6c59d8eba72a262122453>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/DeltaBundler/WorkerFarm.js @@ -45,6 +45,7 @@ declare class WorkerFarm { filename: string, options: TransformOptions, fileBuffer?: Buffer, + assetUrlPath?: string, ): Promise; _makeFarm( absoluteWorkerPath: string, From 2d7aa4e72ba20a0efa8428e1c44a117269648da5 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:52:47 +0100 Subject: [PATCH 45/45] [0.84] Publish 0.84.7 Co-Authored-By: Claude Opus 5.5 --- packages/buck-worker-tool/package.json | 2 +- packages/metro-babel-register/package.json | 2 +- packages/metro-babel-transformer/package.json | 4 +-- packages/metro-cache-key/package.json | 2 +- packages/metro-cache/package.json | 4 +-- packages/metro-config/package.json | 12 ++++---- packages/metro-core/package.json | 4 +-- packages/metro-file-map/package.json | 2 +- packages/metro-minify-terser/package.json | 2 +- packages/metro-resolver/package.json | 4 +-- packages/metro-runtime/package.json | 2 +- packages/metro-source-map/package.json | 6 ++-- packages/metro-symbolicate/package.json | 4 +-- packages/metro-transform-plugins/package.json | 4 +-- packages/metro-transform-worker/package.json | 18 ++++++------ packages/metro/package.json | 28 +++++++++---------- packages/ob1/package.json | 2 +- 17 files changed, 51 insertions(+), 51 deletions(-) diff --git a/packages/buck-worker-tool/package.json b/packages/buck-worker-tool/package.json index 6718bf2ee3..48a932bce9 100644 --- a/packages/buck-worker-tool/package.json +++ b/packages/buck-worker-tool/package.json @@ -1,6 +1,6 @@ { "name": "buck-worker-tool", - "version": "0.84.6", + "version": "0.84.7", "description": "Implementation of the Buck worker protocol for Node.js.", "license": "MIT", "repository": { diff --git a/packages/metro-babel-register/package.json b/packages/metro-babel-register/package.json index 0da642023f..bf8583555e 100644 --- a/packages/metro-babel-register/package.json +++ b/packages/metro-babel-register/package.json @@ -1,6 +1,6 @@ { "name": "metro-babel-register", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 babel/register configuration for Metro.", "main": "src/babel-register.js", "exports": { diff --git a/packages/metro-babel-transformer/package.json b/packages/metro-babel-transformer/package.json index 2847747421..e5bfd92028 100644 --- a/packages/metro-babel-transformer/package.json +++ b/packages/metro-babel-transformer/package.json @@ -1,6 +1,6 @@ { "name": "metro-babel-transformer", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Base Babel transformer for Metro.", "main": "src/index.js", "exports": { @@ -26,7 +26,7 @@ "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", - "metro-cache-key": "0.84.6", + "metro-cache-key": "0.84.7", "nullthrows": "^1.1.1" }, "engines": { diff --git a/packages/metro-cache-key/package.json b/packages/metro-cache-key/package.json index d1fde9993c..27269bbac6 100644 --- a/packages/metro-cache-key/package.json +++ b/packages/metro-cache-key/package.json @@ -1,6 +1,6 @@ { "name": "metro-cache-key", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Cache key utility.", "main": "src/index.js", "exports": { diff --git a/packages/metro-cache/package.json b/packages/metro-cache/package.json index 15f32d2ddd..f5abe67634 100644 --- a/packages/metro-cache/package.json +++ b/packages/metro-cache/package.json @@ -1,6 +1,6 @@ { "name": "metro-cache", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Cache layers for Metro.", "main": "src/index.js", "exports": { @@ -21,7 +21,7 @@ "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", - "metro-core": "0.84.6" + "metro-core": "0.84.7" }, "devDependencies": { "memfs": "^4.38.2" diff --git a/packages/metro-config/package.json b/packages/metro-config/package.json index 0adaf79df8..c2b6efe80a 100644 --- a/packages/metro-config/package.json +++ b/packages/metro-config/package.json @@ -1,6 +1,6 @@ { "name": "metro-config", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Config parser for Metro.", "main": "src/index.js", "exports": { @@ -22,15 +22,15 @@ "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", - "metro": "0.84.6", - "metro-cache": "0.84.6", - "metro-core": "0.84.6", - "metro-runtime": "0.84.6", + "metro": "0.84.7", + "metro-cache": "0.84.7", + "metro-core": "0.84.7", + "metro-runtime": "0.84.7", "yaml": "^2.6.1" }, "devDependencies": { "@types/connect": "^3.4.35", - "metro-babel-register": "0.84.6", + "metro-babel-register": "0.84.7", "pretty-format": "^29.7.0" }, "engines": { diff --git a/packages/metro-core/package.json b/packages/metro-core/package.json index 51754b7582..d37b4ca7bc 100644 --- a/packages/metro-core/package.json +++ b/packages/metro-core/package.json @@ -1,6 +1,6 @@ { "name": "metro-core", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Metro's core package.", "main": "src/index.js", "exports": { @@ -20,7 +20,7 @@ "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", - "metro-resolver": "0.84.6" + "metro-resolver": "0.84.7" }, "license": "MIT", "engines": { diff --git a/packages/metro-file-map/package.json b/packages/metro-file-map/package.json index 0a5f7ac799..4894ad4cdd 100644 --- a/packages/metro-file-map/package.json +++ b/packages/metro-file-map/package.json @@ -1,6 +1,6 @@ { "name": "metro-file-map", - "version": "0.84.6", + "version": "0.84.7", "description": "[Experimental] - 🚇 File crawling, watching and mapping for Metro", "main": "src/index.js", "exports": { diff --git a/packages/metro-minify-terser/package.json b/packages/metro-minify-terser/package.json index ed99fbbded..63387d36d3 100644 --- a/packages/metro-minify-terser/package.json +++ b/packages/metro-minify-terser/package.json @@ -1,6 +1,6 @@ { "name": "metro-minify-terser", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Minifier for Metro based on Terser.", "main": "src/index.js", "exports": { diff --git a/packages/metro-resolver/package.json b/packages/metro-resolver/package.json index a8bd7fa5dc..a62fa64ed5 100644 --- a/packages/metro-resolver/package.json +++ b/packages/metro-resolver/package.json @@ -1,6 +1,6 @@ { "name": "metro-resolver", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Implementation of Metro's resolution logic.", "main": "src/index.js", "exports": { @@ -22,7 +22,7 @@ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" }, "devDependencies": { - "metro": "0.84.6" + "metro": "0.84.7" }, "dependencies": { "flow-enums-runtime": "^0.0.6" diff --git a/packages/metro-runtime/package.json b/packages/metro-runtime/package.json index 45b7a06536..ee0fcef8c0 100644 --- a/packages/metro-runtime/package.json +++ b/packages/metro-runtime/package.json @@ -1,6 +1,6 @@ { "name": "metro-runtime", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Module required for evaluating Metro bundles.", "exports": { "./package.json": "./package.json", diff --git a/packages/metro-source-map/package.json b/packages/metro-source-map/package.json index 8faed421d0..60e547b545 100644 --- a/packages/metro-source-map/package.json +++ b/packages/metro-source-map/package.json @@ -1,6 +1,6 @@ { "name": "metro-source-map", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Source map generator for Metro.", "main": "src/source-map.js", "exports": { @@ -22,9 +22,9 @@ "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-symbolicate": "0.84.6", + "metro-symbolicate": "0.84.7", "nullthrows": "^1.1.1", - "ob1": "0.84.6", + "ob1": "0.84.7", "source-map": "^0.5.6", "vlq": "^1.0.0" }, diff --git a/packages/metro-symbolicate/package.json b/packages/metro-symbolicate/package.json index 10783abb18..936df36876 100644 --- a/packages/metro-symbolicate/package.json +++ b/packages/metro-symbolicate/package.json @@ -1,6 +1,6 @@ { "name": "metro-symbolicate", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 A tool to find the source location from JS bundles and stack traces.", "license": "MIT", "main": "./src/index.js", @@ -25,7 +25,7 @@ "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-source-map": "0.84.6", + "metro-source-map": "0.84.7", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" diff --git a/packages/metro-transform-plugins/package.json b/packages/metro-transform-plugins/package.json index 008f3e7ccb..7648be5fa5 100644 --- a/packages/metro-transform-plugins/package.json +++ b/packages/metro-transform-plugins/package.json @@ -1,6 +1,6 @@ { "name": "metro-transform-plugins", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Transform plugins for Metro.", "main": "src/index.js", "exports": { @@ -33,7 +33,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/types": "^7.29.0", "babel-plugin-tester": "^6.0.1", - "metro": "0.84.6" + "metro": "0.84.7" }, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" diff --git a/packages/metro-transform-worker/package.json b/packages/metro-transform-worker/package.json index d2f727f604..df2a2532c7 100644 --- a/packages/metro-transform-worker/package.json +++ b/packages/metro-transform-worker/package.json @@ -1,6 +1,6 @@ { "name": "metro-transform-worker", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Transform worker for Metro.", "main": "src/index.js", "exports": { @@ -24,19 +24,19 @@ "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", - "metro": "0.84.6", - "metro-babel-transformer": "0.84.6", - "metro-cache": "0.84.6", - "metro-cache-key": "0.84.6", - "metro-minify-terser": "0.84.6", - "metro-source-map": "0.84.6", - "metro-transform-plugins": "0.84.6", + "metro": "0.84.7", + "metro-babel-transformer": "0.84.7", + "metro-cache": "0.84.7", + "metro-cache-key": "0.84.7", + "metro-minify-terser": "0.84.7", + "metro-source-map": "0.84.7", + "metro-transform-plugins": "0.84.7", "nullthrows": "^1.1.1" }, "devDependencies": { "@react-native/metro-babel-transformer": "0.78.0", "metro-memory-fs": "*", - "metro-resolver": "*" + "metro-resolver": "0.84.7" }, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" diff --git a/packages/metro/package.json b/packages/metro/package.json index 242ed2de56..35af8d95db 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -1,6 +1,6 @@ { "name": "metro", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 The JavaScript bundler for React Native.", "main": "src/index.js", "bin": "src/cli.js", @@ -37,18 +37,18 @@ "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", - "metro-babel-transformer": "0.84.6", - "metro-cache": "0.84.6", - "metro-cache-key": "0.84.6", - "metro-config": "0.84.6", - "metro-core": "0.84.6", - "metro-file-map": "0.84.6", - "metro-resolver": "0.84.6", - "metro-runtime": "0.84.6", - "metro-source-map": "0.84.6", - "metro-symbolicate": "0.84.6", - "metro-transform-plugins": "0.84.6", - "metro-transform-worker": "0.84.6", + "metro-babel-transformer": "0.84.7", + "metro-cache": "0.84.7", + "metro-cache-key": "0.84.7", + "metro-config": "0.84.7", + "metro-core": "0.84.7", + "metro-file-map": "0.84.7", + "metro-resolver": "0.84.7", + "metro-runtime": "0.84.7", + "metro-source-map": "0.84.7", + "metro-symbolicate": "0.84.7", + "metro-transform-plugins": "0.84.7", + "metro-transform-worker": "0.84.7", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", @@ -69,7 +69,7 @@ "dedent": "^0.7.0", "jest-snapshot": "^29.7.0", "jest-snapshot-serializer-raw": "^1.2.0", - "metro-babel-register": "0.84.6", + "metro-babel-register": "0.84.7", "metro-memory-fs": "*", "mock-req": "^0.2.0", "mock-res": "^0.6.0", diff --git a/packages/ob1/package.json b/packages/ob1/package.json index 80598f4f59..9203bef842 100644 --- a/packages/ob1/package.json +++ b/packages/ob1/package.json @@ -1,6 +1,6 @@ { "name": "ob1", - "version": "0.84.6", + "version": "0.84.7", "description": "A small library for working with 0- and 1-based offsets in a type-checked way.", "main": "src/ob1.js", "exports": {