Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/metro-resolver/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,10 @@ export type ResolveAsset = (dirPath: string, assetName: string, extension: strin

export type Result<TResolution, TCandidates> = {readonly type: 'resolved'; readonly resolution: TResolution} | {readonly type: 'failed'; readonly candidates: TCandidates};

export type VirtualResolution = Readonly<{
type: 'virtualModule';
virtualPath: null | undefined | string;
source: string | Buffer;
}>;

```
1 change: 1 addition & 0 deletions packages/metro-resolver/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type {
Resolution,
ResolveAsset,
Result,
VirtualResolution,
} from './types';

import FailedToResolveNameError from './errors/FailedToResolveNameError';
Expand Down
33 changes: 29 additions & 4 deletions packages/metro-resolver/src/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,39 @@ export type AssetResolution = Readonly<{
export type FileResolution = AssetResolution | SourceFileResolution;

/**
* A JS module whose contents are provided out-of-band rather than read from a file
* on disk.
* A JS module whose contents are provided by the resolver rather than read from
* a file on disk.
*
* NOTE: Resolving to a virtual module is not yet implemented. This is a reservation.
* Metro derives the module's identity from `virtualPath` and the specifier
* that resolved to it. A change in `source` for the same specifier is a
* modification of the same module, as an edit to a file is. The transform
* cache key is content-addressed, so nothing about a virtual module can go
* stale.
*
* A resolver must produce the same `source` for the same virtual path and
* specifier wherever they are resolved from. Metro throws when two
* resolutions in one traversal disagree.
*/
export type VirtualResolution = Readonly<{
type: 'virtualModule',
...
/**
* The path the module notionally lives at. It need not exist on disk. Metro
* appends a suffix derived from `source` to form the module's path, so
* relative and package imports inside the virtual module resolve from this
* path's directory, and path-based transform configuration (Babel
* overrides, `inlineRequires` block lists, and so on) sees a path derived
* from it. A resolver that passes the importing module's own path gets one
* module per importer; a fixed path gets one module per distinct source.
*
* `null` declares a module with no base, as a `data:` URL has: identity is
* the source alone and relative imports inside it are errors. Resolving the
* dependencies of such a module needs a `ResolutionContext` with no
* `originModulePath`, which is a breaking change to the resolver contract,
* so Metro does not accept `null` yet.
*/
virtualPath: ?string,
/** The complete source of the module, as text or UTF-8 bytes. */
source: string | Buffer,
}>;

export type FileAndDirCandidates = {
Expand Down
56 changes: 47 additions & 9 deletions packages/metro/src/DeltaBundler/Graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,15 +183,39 @@ export class Graph<T = MixedOutput> {

const allModifiedPaths = new Set(paths);

// Virtual modules already in the graph whose edge now supplies different
// content. Their identity is unchanged, so they are revisited and committed
// like modified files.
const changedVirtualSources = new Map<string, VirtualSource>();

const delta = await this._buildDelta(
modifiedPathsInBaseGraph,
internalOptions,
// Traverse new or modified paths
absolutePath =>
!this.dependencies.has(absolutePath) ||
allModifiedPaths.has(absolutePath),
// Traverse new or modified paths, and virtual modules with new content.
(absolutePath, virtualSource) => {
if (
!this.dependencies.has(absolutePath) ||
allModifiedPaths.has(absolutePath)
) {
return true;
}
const current = this.#virtualSources.get(absolutePath);
if (
virtualSource?.type === 'buffer' &&
current?.type === 'buffer' &&
current.sha1 !== virtualSource.sha1
) {
changedVirtualSources.set(absolutePath, virtualSource);
return true;
}
return false;
},
);

for (const path of changedVirtualSources.keys()) {
modifiedPathsInBaseGraph.add(path);
}

// If we have errors we might need to roll back any changes - take
// snapshots of all modified modules at the base state. We'll also snapshot
// unmodified modules that become unreachable as they are released, so that
Expand Down Expand Up @@ -280,6 +304,14 @@ export class Graph<T = MixedOutput> {
throw error;
}

// The graph now holds the new content of revisited virtual modules, so
// later traversals compare against, and re-transform with, that content.
for (const [path, virtualSource] of changedVirtualSources) {
if (this.dependencies.has(path)) {
this.#virtualSources.set(path, virtualSource);
}
}

const added = new Map<string, Module<T>>();
for (const path of delta.added) {
added.set(path, nullthrows(this.dependencies.get(path)));
Expand Down Expand Up @@ -349,15 +381,21 @@ export class Graph<T = MixedOutput> {
async _buildDelta(
pathsToVisit: ReadonlySet<string>,
options: InternalOptions<T>,
moduleFilter?: (path: string) => boolean,
moduleFilter?: (path: string, virtualSource: ?VirtualSource) => boolean,
): Promise<Delta<T>> {
const subGraph = await buildSubgraph(pathsToVisit, this.#virtualSources, {
resolve: options.resolve,
shouldTraverse: (dependency: ResolvedDependency) => {
shouldTraverse: (
dependency: ResolvedDependency,
virtualSource: ?VirtualSource,
) => {
if (options.shallow || isWeakOrLazy(dependency, options)) {
return false;
}
return moduleFilter == null || moduleFilter(dependency.absolutePath);
return (
moduleFilter == null ||
moduleFilter(dependency.absolutePath, virtualSource)
);
},
transform: async (absolutePath, virtualSource) => {
options.onDependencyAdd();
Expand Down Expand Up @@ -561,8 +599,8 @@ export class Graph<T = MixedOutput> {
if (virtualSource) {
this.#virtualSources.set(path, virtualSource);
} else {
// This dependency may have existed previously as a require.context -
// clean it up.
// This dependency may have existed previously as a require.context or
// virtual module - clean it up.
this.#virtualSources.delete(path);
}
}
Expand Down
209 changes: 209 additions & 0 deletions packages/metro/src/DeltaBundler/__tests__/Graph-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import type {RequireContextParams} from '../../ModuleGraph/worker/collectDependencies';
import type {Result} from '../Graph';
import type {
BundlerResolution,
Dependency,
MixedOutput,
Module,
Expand All @@ -50,6 +51,7 @@ import type {

import {deriveAbsolutePathFromContext} from '../../lib/contextModule';
import CountingSet from '../../lib/CountingSet';
import {deriveVirtualModulePath} from '../../lib/virtualModule';
import {Graph} from '../Graph';
import {createPathNormalizer} from './test-utils';
import nullthrows from 'nullthrows';
Expand Down Expand Up @@ -316,10 +318,25 @@ class TestGraph extends Graph<> {
// Get a snapshot of the graph before the traversal.
const dependenciesBefore = new Set(this.dependencies.keys());
const modifiedPaths = new Set(files);
// The mocks don't model file contents, but they do model virtual module
// contents: a virtual module whose transform output key changes is
// expected to be reported as modified.
const virtualKeysBefore = new Map(
[...this.dependencies]
.filter(([path]) => path.includes('?virtual='))
.map(([path, module]) => [path, module.unstable_transformResultKey]),
);

// Mutate the graph and calculate a delta.
const delta = await super.traverseDependencies(paths, options);

for (const [path, keyBefore] of virtualKeysBefore) {
const module = this.dependencies.get(path);
if (module != null && module.unstable_transformResultKey !== keyBefore) {
modifiedPaths.add(path);
}
}

// Validate the delta against the current state of the graph.
const expectedDelta = computeDelta(
dependenciesBefore,
Expand Down Expand Up @@ -2946,6 +2963,198 @@ describe('only reachable errors are reported', () => {
});
});

describe('virtual modules', () => {
const p = createPathNormalizer();
// The virtual module is anchored at a path with no file of its own, so the
// transform mock can be keyed on it independently of the real modules.
const virtualPath = p('/virtual.js');
const sha1 = (source: Buffer) =>
require('node:crypto').createHash('sha1').update(source).digest('hex');
let virtualSource: Buffer;
let localOptions;

beforeEach(() => {
virtualSource = Buffer.from('v1');
// Whatever the resolver produces for `virtual:x` is served from
// `virtualSource`, so a test can change the module's content without
// changing its identity.
localOptions = {
...options,
resolve: (
from: string,
to: TransformResultDependency,
): BundlerResolution =>
to.name === 'virtual:x'
? {
type: 'virtualModule',
filePath: deriveVirtualModulePath(virtualPath, to.name),
source: virtualSource,
sha1: sha1(virtualSource),
virtualPath,
}
: options.resolve(from, to),
};
transformOverrides.set(virtualPath, async (path, context) => {
if (context?.type !== 'buffer') {
throw new Error('expected the virtual module source');
}
const {source} = context;
return {
dependencies: [],
getSource: () => source,
output: [
{
data: {code: source.toString(), lineCount: 1, map: []},
type: 'js/module',
},
],
unstable_transformResultKey: path + ' ' + context.sha1,
};
});
Actions.addDependency('/foo', virtualPath, {name: 'virtual:x'});
files.clear();
});

const modulePath = () => deriveVirtualModulePath(virtualPath, 'virtual:x');

test('a virtual module is added once and named after its virtual path and specifier', async () => {
expect(
getPaths(await graph.initialTraverseDependencies(localOptions)),
).toEqual({
added: new Set(['/bundle', '/foo', '/bar', '/baz', modulePath()]),
modified: new Set(),
deleted: new Set(),
});
expect(mockTransform).toHaveBeenCalledWith(virtualPath, {
type: 'buffer',
source: virtualSource,
sha1: sha1(virtualSource),
virtualPath,
});
});

test('a content change for the same specifier is a modification of the same module', async () => {
await graph.initialTraverseDependencies(localOptions);

virtualSource = Buffer.from('v2');
Actions.modifyFile('/foo');

expect(
getPaths(await graph.traverseDependencies([...files], localOptions)),
).toEqual({
added: new Set(),
modified: new Set(['/foo', modulePath()]),
deleted: new Set(),
});
expect(
nullthrows(graph.dependencies.get(modulePath())).getSource().toString(),
).toBe('v2');
});

test('re-resolving to the same content does not modify the virtual module', async () => {
await graph.initialTraverseDependencies(localOptions);

Actions.modifyFile('/foo');

expect(
getPaths(await graph.traverseDependencies([...files], localOptions)),
).toEqual({
added: new Set(),
modified: new Set(['/foo']),
deleted: new Set(),
});
});

test('after a content change, a later edit to the importer does not revisit the virtual module', async () => {
await graph.initialTraverseDependencies(localOptions);
virtualSource = Buffer.from('v2');
Actions.modifyFile('/foo');
await graph.traverseDependencies([...files], localOptions);

mockTransform.mockClear();
Actions.modifyFile('/foo');
await graph.traverseDependencies([...files], localOptions);

expect(mockTransform).not.toHaveBeenCalledWith(
virtualPath,
expect.anything(),
);
});

test('after a content change, revisiting the virtual module directly uses the new content', async () => {
await graph.initialTraverseDependencies(localOptions);
virtualSource = Buffer.from('v2');
Actions.modifyFile('/foo');
await graph.traverseDependencies([...files], localOptions);

// As getModifiedModulesForDeletedPath does for an inverse dependency of a
// deleted file.
files.clear();
mockTransform.mockClear();
await graph.traverseDependencies([modulePath()], localOptions);

expect(mockTransform).toHaveBeenCalledWith(virtualPath, {
type: 'buffer',
source: Buffer.from('v2'),
sha1: sha1(Buffer.from('v2')),
virtualPath,
});
});

test('a content conflict on an optional dependency is still an error', async () => {
Actions.addDependency('/bar', virtualPath, {
name: 'virtual:x',
data: {isOptional: true},
});
files.clear();
const conflictingOptions = {
...localOptions,
resolve: (
from: string,
to: TransformResultDependency,
): BundlerResolution =>
to.name === 'virtual:x'
? {
type: 'virtualModule',
filePath: modulePath(),
source: Buffer.from(from),
sha1: sha1(Buffer.from(from)),
virtualPath,
}
: options.resolve(from, to),
};

await expect(
graph.initialTraverseDependencies(conflictingOptions),
).rejects.toThrow(/was resolved with different contents/);
});

test('two importers resolving the same virtual module to different content is an error', async () => {
Actions.addDependency('/bar', virtualPath, {name: 'virtual:x'});
files.clear();
const conflictingOptions = {
...localOptions,
resolve: (
from: string,
to: TransformResultDependency,
): BundlerResolution =>
to.name === 'virtual:x'
? {
type: 'virtualModule',
filePath: modulePath(),
source: Buffer.from(from),
sha1: sha1(Buffer.from(from)),
virtualPath,
}
: options.resolve(from, to),
};

await expect(
graph.initialTraverseDependencies(conflictingOptions),
).rejects.toThrow(/was resolved with different contents/);
});
});

describe('require.context', () => {
// Context modules are derived from and matched against file paths, so use
// system paths throughout.
Expand Down
Loading
Loading