Skip to content

Use a module request for asyncRequireModulePath so Metro's transform cache is shareable across checkouts - #58584

Open
janicduplessis wants to merge 1 commit into
react:mainfrom
janicduplessis:@janic/metro-config-async-require-specifier
Open

janicduplessis wants to merge 1 commit into
react:mainfrom
janicduplessis:@janic/metro-config-async-require-specifier

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary:

@react-native/metro-config sets asyncRequireModulePath with require.resolve(), an absolute path into the current checkout. Metro hashes the whole transformer config into its global transform cache key (metro-transform-worker getCacheKey), so two checkouts of the same project (git worktrees, CI workspaces, a renamed directory) never share a transform cache entry. Nothing fails; the cache is just cold for every checkout.

The value has to be a module request, which is what Metro's own default is and what @expo/metro-config does with expo/internal/async-require-module ("must be a module request, rather than an absolute path to keep the cache clean"). Metro inlines it into every module that uses import() and resolves it from that module, so it also has to resolve from anywhere in the project.

Metro's bare metro-runtime/src/modules/asyncRequire does not: I confirmed with pnpm node-linker=isolated that metro-runtime is not in the project's node_modules (it is not a direct dependency), so import() in app code fails with Unable to resolve module metro-runtime/src/modules/asyncRequire from index.js, while the current absolute path works there.

So this follows the precedent already in this repo: react-native/asset-registry is an untyped secondary entry point that exists only to be referenced from Metro's transformer.assetRegistryPath, for the same reason (a module request Metro can inline and resolve from anywhere). react-native/async-require is its twin for transformer.asyncRequireModulePath: src/async-require.js re-exports metro-runtime/src/modules/asyncRequire, with a matching exports entry and the no-deep-imports allowlist update, exactly like asset-registry. react-native is every app's direct dependency and already depends on metro-runtime, so the request resolves from app code and from packages in any layout, hoisted or not. This is also what Expo does with expo/internal/async-require-module.

Packaging: src is in the package's files, and npm pack --dry-run lists src/async-require.js next to src/asset-registry.js and src/setup-env.js.

Changelog:

[GENERAL] [FIXED] - Make Metro's transform cache shareable across checkouts by pointing asyncRequireModulePath at a new react-native/async-require entry point instead of an absolute path

Test Plan:

yarn flow-check: no errors. yarn lint on the changed files and yarn test packages/eslint-plugin-react-native pass.

Cache key. The transformer-config segment of Metro's global cache key, computed with metro-transform-worker's getCacheKey for two checkouts:

$ node -e "
const {getCacheKey}=require('metro-transform-worker');
const base={babelTransformerPath:require.resolve('@react-native/metro-babel-transformer'),minifierPath:'metro-minify-terser',assetRegistryPath:'react-native/asset-registry'};
const k=(p)=>getCacheKey({...base,asyncRequireModulePath:p},{projectRoot:process.cwd()}).split('\$')[1];
console.log('A abs :', k('/Users/x/app-a/node_modules/metro-runtime/src/modules/asyncRequire.js'));
console.log('B abs :', k('/Users/x/app-b/node_modules/metro-runtime/src/modules/asyncRequire.js'));
console.log('A new :', k('react-native/async-require'));
console.log('B new :', k('react-native/async-require'));
"
A abs : 4045a4cb2cabe49b5e47e841967c2306
B abs : b3b399e7c2f3f4659bcd815c0e0377b5
A new : 6d2bf7d5742ea95bee89f4e6e1e40613
B new : 6d2bf7d5742ea95bee89f4e6e1e40613

Resolution. Metro.runBuild of an entry with import('./src/lazy'), using getDefaultConfig from @react-native/metro-config@0.86.3 with this change applied to the installed packages:

install current (require.resolve) bare metro-runtime/... react-native/async-require (this PR)
npm (hoisted) ok ok ok
pnpm node-linker=isolated ok Unable to resolve module metro-runtime/src/modules/asyncRequire from index.js ok

End to end. A fresh @react-native-community/cli init app on 0.86.3 with App.tsx rendering React.lazy(() => import('./src-lazy')), this change applied to the installed packages, built and launched on an iOS 26.5 simulator. The lazily imported component renders and the app logs have no errors.

Cache sharing across checkouts: same app as a second git worktree with node_modules copied over, $TMPDIR/metro-cache wiped first, plain react-native start in each, DEBUG=Metro:Transformer for the base hash, first bundle request timed with curl (dev, iOS):

checkout config base hash first bundle
A this PR e63a95bf… 2.38 s (cold)
B, new path this PR e63a95bf… 0.15 s
B, new path current require.resolve b6349718… 2.44 s (every transform recomputed)

The template app is ~600 modules, so the absolute numbers are small; the point is that B only shares A's transforms with this change.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Sep 18, 2026
@facebook-github-tools facebook-github-tools Bot added the Contributor A React Native contributor. label Sep 18, 2026
@janicduplessis
janicduplessis force-pushed the @janic/metro-config-async-require-specifier branch 4 times, most recently from 20e2708 to 4da2b4d Compare September 18, 2026 03:25
@janicduplessis
janicduplessis marked this pull request as ready for review September 18, 2026 03:29
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Sep 18, 2026
@javache

javache commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

react-native/asset-registry is a different use-case though, it points at a module that's actually internal to RN.

What would it take to allow metro-runtime/async-require as the package-relative specifier here?

@javache
javache requested a review from rubennorte September 18, 2026 11:25
…cache is shareable across checkouts

@react-native/metro-config set asyncRequireModulePath with require.resolve(),
which embeds the absolute path of the checkout in Metro's global transform
cache key. Two checkouts of the same project (git worktrees, CI workspaces)
never shared a transform cache entry. Add a react-native/async-require
secondary entry point, like react-native/asset-registry, and point the config
at it: a module request that resolves from any module in the project, hoisted
or not, because react-native is the app's direct dependency and depends on
metro-runtime itself.
@janicduplessis
janicduplessis force-pushed the @janic/metro-config-async-require-specifier branch from 4da2b4d to dbc06cf Compare September 18, 2026 14:43
@janicduplessis

Copy link
Copy Markdown
Contributor Author

@javache The other option that works is using

+      extraNodeModules: {
+        'metro-runtime': path.dirname(
+          require.resolve('metro-runtime/package.json'),
+        ),
+      },

in the default metro config, but I thought adding the file seemed like a better idea since we do it for other configs (although as you said it is to access internal RN module instead of other package).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants