From 93328400da18cfde7bc604a4e6cd772c7f4b2152 Mon Sep 17 00:00:00 2001 From: Rob Hogan Date: Thu, 8 Oct 2026 13:54:47 +0100 Subject: [PATCH] Resolver: Add metro:inline specifiers for importer-anchored inline modules Adds `metro:inline;base64,` (or `metro:inline,`) under the existing `metro:` scheme resolver. The payload is the module's source, and the resolver answers with a `VirtualResolution` anchored at the importing file, so relative and package imports inside the module resolve as they would from the importer and the same payload in two files is two modules. That is what a transform needs when it emits a module derived from the file it is transforming. `react-native-worklets` bundle mode does this today by writing files into `node_modules` mid-transform and patching `getOrComputeSha1` to return a random SHA-1 for them, which defeats the transform cache for every worklet. This is deliberately not spelled `data:`. A `data:` URL is importer-independent and has no base URL, and Node and browsers treat the same URL as one module wherever it is imported. Inline modules are the opposite on both counts. `keepRequireNames` output abbreviates an inline specifier to its header, so a dev bundle carries each payload once as the module rather than again in every `require` call. Changelog: [Feature] Resolver: `metro:inline;base64,` resolves to a virtual module owned by the importer --- .../ModuleGraph/worker/collectDependencies.js | 26 +++++++- .../__tests__/inline-module-test.js | 66 +++++++++++++++++++ .../basic_bundle/inline-module/index.js | 35 ++++++++++ .../basic_bundle/inline-module/sibling.js | 11 ++++ .../inline-module/subdir/counter.js | 15 +++++ .../lib/__tests__/resolveInlineModule-test.js | 66 +++++++++++++++++++ packages/metro/src/lib/metroSchemeResolver.js | 16 ++++- packages/metro/src/lib/resolveInlineModule.js | 64 ++++++++++++++++++ 8 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 packages/metro/src/integration_tests/__tests__/inline-module-test.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/inline-module/index.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/inline-module/sibling.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/inline-module/subdir/counter.js create mode 100644 packages/metro/src/lib/__tests__/resolveInlineModule-test.js create mode 100644 packages/metro/src/lib/resolveInlineModule.js diff --git a/packages/metro/src/ModuleGraph/worker/collectDependencies.js b/packages/metro/src/ModuleGraph/worker/collectDependencies.js index 9bd5be1cec..570cf1bd51 100644 --- a/packages/metro/src/ModuleGraph/worker/collectDependencies.js +++ b/packages/metro/src/ModuleGraph/worker/collectDependencies.js @@ -812,7 +812,9 @@ const DefaultDependencyTransformer: DependencyTransformer = { >; // Always add the debug name argument last if (state.keepRequireNames) { - path.node.arguments.push(types.stringLiteral(dependency.name)); + path.node.arguments.push( + types.stringLiteral(getDebugName(dependency.name)), + ); } }, @@ -906,7 +908,7 @@ function createModuleIDExpression( } function createModuleNameLiteral(dependency: InternalDependency) { - return types.stringLiteral(dependency.name); + return types.stringLiteral(getDebugName(dependency.name)); } /** @@ -929,6 +931,26 @@ function createModuleNameLiteral(dependency: InternalDependency) { * * This method should be utilized by `registerDependency`. */ +const INLINE_MODULE_PREFIX = 'metro:inline'; + +/** + * The name shown beside a dependency in development output. An inline module + * specifier carries a whole module in its payload, so only its header is kept. + */ +function getDebugName(name: string): string { + if ( + name.length > INLINE_MODULE_PREFIX.length && + name.slice(0, INLINE_MODULE_PREFIX.length).toLowerCase() === + INLINE_MODULE_PREFIX + ) { + const commaIndex = name.indexOf(','); + if (commaIndex !== -1) { + return name.slice(0, commaIndex + 1) + '...'; + } + } + return name; +} + function getKeyForDependency(qualifier: ImportQualifier): string { const {asyncType, contextParams, isESMImport, name} = qualifier; diff --git a/packages/metro/src/integration_tests/__tests__/inline-module-test.js b/packages/metro/src/integration_tests/__tests__/inline-module-test.js new file mode 100644 index 0000000000..6acae7f0bd --- /dev/null +++ b/packages/metro/src/integration_tests/__tests__/inline-module-test.js @@ -0,0 +1,66 @@ +/** + * 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 + */ + +'use strict'; + +const Metro = require('../../..'); +const execBundle = require('../execBundle'); + +jest.setTimeout(30 * 1000); + +async function build({dev}: {dev: boolean}) { + const config = await Metro.loadConfig({ + config: require.resolve('../metro.config.js'), + }); + return Metro.runBuild(config, { + entry: 'inline-module/index.js', + dev, + minify: !dev, + }); +} + +test('an inline module resolves to a virtual module anchored at its importer', async () => { + const {code} = await build({dev: true}); + expect(execBundle(code)).toEqual({ + // The inline module's relative import resolved to the shared counter. + fromInline: 11, + // Same source, different importer: a separate module instance, sharing the + // counter it imports. + fromSibling: 102, + // The weak id refers to the same module instance as the static require. + fromWeakId: 1003, + weakIdIsOwnModuleId: true, + }); +}); + +test('the virtual module is named after its importer and a hash of its source', async () => { + const {code} = await build({dev: true}); + const names = [ + ...code.matchAll(/"([^"]*inline-module\/[^"]*\?virtual=[0-9a-f]+)"/g), + ] + .map(match => match[1]) + .sort(); + expect(names).toEqual([ + expect.stringMatching(/^inline-module\/index\.js\?virtual=[0-9a-f]{40}$/), + expect.stringMatching(/^inline-module\/sibling\.js\?virtual=[0-9a-f]{40}$/), + ]); +}); + +test('development output abbreviates inline module specifiers in require names', async () => { + const {code} = await build({dev: true}); + expect(code).toContain('"metro:inline;base64,..."'); + expect(code).not.toContain('"metro:inline;base64,aW1wb3J0'); +}); + +test('builds in production', async () => { + const {code} = await build({dev: false}); + expect(execBundle(code)).toMatchObject({fromInline: 11, fromSibling: 102}); +}); diff --git a/packages/metro/src/integration_tests/basic_bundle/inline-module/index.js b/packages/metro/src/integration_tests/basic_bundle/inline-module/index.js new file mode 100644 index 0000000000..2dd97ee5aa --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/inline-module/index.js @@ -0,0 +1,35 @@ +/** + * 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 + * @oncall react_native + */ + +declare var require: { + (id: string | number): any, + resolveWeak: (id: string) => string | number, +}; + +// The same source from a different origin is a different module with its own +// module scope. +const {default: bumpFromSibling} = require('./sibling'); +// The inline module imports a sibling relatively: it resolves from this file's +// directory, because the virtual module is anchored here. +const inline = require('metro:inline;base64,aW1wb3J0IHtpbmNyZW1lbnR9IGZyb20gJy4vc3ViZGlyL2NvdW50ZXInOwpleHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBidW1wKG4pIHsKICByZXR1cm4gaW5jcmVtZW50KCkgKyBuOwp9CmV4cG9ydCBjb25zdCBvd25Nb2R1bGVJZCA9IG1vZHVsZS5pZDsK'); + +const weakId = require.resolveWeak( + 'metro:inline;base64,aW1wb3J0IHtpbmNyZW1lbnR9IGZyb20gJy4vc3ViZGlyL2NvdW50ZXInOwpleHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBidW1wKG4pIHsKICByZXR1cm4gaW5jcmVtZW50KCkgKyBuOwp9CmV4cG9ydCBjb25zdCBvd25Nb2R1bGVJZCA9IG1vZHVsZS5pZDsK', +); + +const dynamicRequire = require; + +module.exports = { + fromInline: inline.default(10), + fromSibling: bumpFromSibling(100), + // Requiring by the weak id reaches the same instance as the static require. + fromWeakId: dynamicRequire(weakId).default(1000), + weakIdIsOwnModuleId: weakId === inline.ownModuleId, +}; diff --git a/packages/metro/src/integration_tests/basic_bundle/inline-module/sibling.js b/packages/metro/src/integration_tests/basic_bundle/inline-module/sibling.js new file mode 100644 index 0000000000..3f98f782bf --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/inline-module/sibling.js @@ -0,0 +1,11 @@ +/** + * 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 + * @oncall react_native + */ + +module.exports = require('metro:inline;base64,aW1wb3J0IHtpbmNyZW1lbnR9IGZyb20gJy4vc3ViZGlyL2NvdW50ZXInOwpleHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBidW1wKG4pIHsKICByZXR1cm4gaW5jcmVtZW50KCkgKyBuOwp9CmV4cG9ydCBjb25zdCBvd25Nb2R1bGVJZCA9IG1vZHVsZS5pZDsK'); diff --git a/packages/metro/src/integration_tests/basic_bundle/inline-module/subdir/counter.js b/packages/metro/src/integration_tests/basic_bundle/inline-module/subdir/counter.js new file mode 100644 index 0000000000..f8b8ef0f9b --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/inline-module/subdir/counter.js @@ -0,0 +1,15 @@ +/** + * 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 + * @oncall react_native + */ + +let count = 0; + +export function increment(): number { + return ++count; +} diff --git a/packages/metro/src/lib/__tests__/resolveInlineModule-test.js b/packages/metro/src/lib/__tests__/resolveInlineModule-test.js new file mode 100644 index 0000000000..14e518c0c4 --- /dev/null +++ b/packages/metro/src/lib/__tests__/resolveInlineModule-test.js @@ -0,0 +1,66 @@ +/** + * 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 + */ + +'use strict'; + +import metroSchemeResolver from '../metroSchemeResolver'; +import {createResolutionContext} from 'metro-resolver/private/__tests__/utils'; + +const ORIGIN = '/root/project/foo.js'; + +function resolve(specifier: string) { + return metroSchemeResolver( + { + ...createResolutionContext({}), + originModulePath: ORIGIN, + resolveRequest: () => { + throw new Error('inline module resolution must not delegate'); + }, + }, + specifier, + null, + ); +} + +test('decodes a base64 payload into a virtual module at the importing module path', () => { + const source = 'export default 1;'; + expect( + resolve('metro:inline;base64,' + Buffer.from(source).toString('base64')), + ).toEqual({ + type: 'virtualModule', + virtualPath: ORIGIN, + source, + }); +}); + +test('decodes a percent-encoded payload', () => { + expect(resolve('metro:inline,export%20default%20%22a%2Cb%22%3B')).toEqual({ + type: 'virtualModule', + virtualPath: ORIGIN, + source: 'export default "a,b";', + }); +}); + +test('ignores the case of the parameters', () => { + expect(resolve('metro:inline;BASE64,MQ==')).toMatchObject({source: '1'}); +}); + +test('rejects an unknown parameter', () => { + expect(() => resolve('metro:inline;gzip,hello')).toThrow( + "Unsupported parameter 'gzip'", + ); +}); + +test('rejects a specifier with no payload separator', () => { + expect(() => resolve('metro:inline')).toThrow( + 'Malformed inline module specifier', + ); +}); diff --git a/packages/metro/src/lib/metroSchemeResolver.js b/packages/metro/src/lib/metroSchemeResolver.js index 590256bd5e..253e7cb2fb 100644 --- a/packages/metro/src/lib/metroSchemeResolver.js +++ b/packages/metro/src/lib/metroSchemeResolver.js @@ -11,6 +11,10 @@ import type {CustomResolver} from 'metro-resolver'; +import { + INLINE_MODULE_SPECIFIER, + resolveInlineModule, +} from './resolveInlineModule'; import * as path from 'node:path'; const BABEL_RUNTIME_SPECIFIER = 'babel-runtime'; @@ -35,12 +39,20 @@ function getBabelRuntimePackageJsonPath(): string { } /** - * Resolver used for Metro's own `metro:` URI scheme, currently handling only - * metro:babel-runtime and subpaths. + * Resolver used for Metro's own `metro:` URI scheme, handling + * metro:babel-runtime (and subpaths) and metro:inline modules. */ export default ((context, specifier, platform) => { const {protocol, pathname} = new URL(specifier); + if ( + pathname === INLINE_MODULE_SPECIFIER || + pathname.startsWith(INLINE_MODULE_SPECIFIER + ';') || + pathname.startsWith(INLINE_MODULE_SPECIFIER + ',') + ) { + return resolveInlineModule(context, pathname); + } + // Maps `metro:babel-runtime` (and subpaths, e.g. // `metro:babel-runtime/helpers/interopRequireDefault`) to metro-runtime's // `@babel/runtime` dependency, so injected Babel helpers resolve diff --git a/packages/metro/src/lib/resolveInlineModule.js b/packages/metro/src/lib/resolveInlineModule.js new file mode 100644 index 0000000000..6c8fd33106 --- /dev/null +++ b/packages/metro/src/lib/resolveInlineModule.js @@ -0,0 +1,64 @@ +/** + * 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 {ResolutionContext, VirtualResolution} from 'metro-resolver'; + +export const INLINE_MODULE_SPECIFIER = 'inline'; + +/** + * Resolves the `inline` pathname of Metro's own URI scheme, so that a transform + * may emit a module inline in its importer: + * + * require('metro:inline;base64,ZXhwb3J0IGRlZmF1bHQgMQ==') + * + * The payload is the module's source, encoded as in a `data:` URL. Unlike a + * `data:` URL, an inline module belongs to its importer: the same payload in + * two modules is two modules, and imports inside it resolve exactly as they + * would from the importer. That is what makes it usable for modules a + * transform derives from the importing file, which is not what `data:` + * promises, so it is deliberately not spelled `data:`. + * + * The result is a pure function of (origin module, specifier). Nothing on disk + * can invalidate it. + */ +export function resolveInlineModule( + context: ResolutionContext, + pathname: string, +): VirtualResolution { + const commaIndex = pathname.indexOf(','); + if (commaIndex === -1) { + throw new Error(`Malformed inline module specifier: metro:${pathname}`); + } + const parameters = pathname + .slice(INLINE_MODULE_SPECIFIER.length, commaIndex) + .split(';') + .map(part => part.trim().toLowerCase()) + .filter(part => part !== ''); + const payload = pathname.slice(commaIndex + 1); + + for (const parameter of parameters) { + if (parameter !== 'base64') { + throw new Error( + `Unsupported parameter '${parameter}' in inline module specifier: metro:${pathname}`, + ); + } + } + + const source = parameters.includes('base64') + ? Buffer.from(payload, 'base64').toString('utf8') + : decodeURIComponent(payload); + + return { + type: 'virtualModule', + virtualPath: context.originModulePath, + source, + }; +}