From d37ca65411f14a1e11a5ba48c3713d1b22c78b52 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Fri, 27 May 2022 20:08:59 -0500 Subject: [PATCH 01/38] feat: add `resolveContext` method for matching files --- packages/metro-file-map/src/HasteFS.js | 33 +++++++++++- .../src/__tests__/index-test.js | 53 +++++++++++++++++++ .../metro/src/node-haste/DependencyGraph.js | 13 +++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/metro-file-map/src/HasteFS.js b/packages/metro-file-map/src/HasteFS.js index 8f743a8920..8a6ab2cd95 100644 --- a/packages/metro-file-map/src/HasteFS.js +++ b/packages/metro-file-map/src/HasteFS.js @@ -9,7 +9,7 @@ */ import type {FileData, Path} from './flow-types'; - +import {sep} from 'path'; import H from './constants'; import * as fastPath from './lib/fast_path'; // $FlowFixMe[untyped-import] - jest-util @@ -84,6 +84,37 @@ export default class HasteFS { return files; } + /** Given a search context, return a list of file paths matching the query. */ + matchFilesWithContext( + root: Path, + context: { + /* Should search for files recursively. */ + recursive: boolean, + /* Filter files against a pattern. */ + filter: RegExp, + }, + ): Array { + const files = []; + for (const file of this.getAbsoluteFileIterator()) { + const filePath = fastPath.relative(root, file); + + // Ignore everything outside of the provided `root`. + if (filePath.startsWith('..')) { + continue; + } + + // Prevent searching in child directories during a non-recursive search. + if (!context.recursive && filePath.includes(sep)) { + continue; + } + + if (context.filter.test(filePath)) { + files.push(file); + } + } + return files; + } + matchFilesWithGlob(globs: $ReadOnlyArray, root: ?Path): Set { const files = new Set(); const matcher = globsToMatcher(globs); diff --git a/packages/metro-file-map/src/__tests__/index-test.js b/packages/metro-file-map/src/__tests__/index-test.js index 122308c568..e182c8f9d4 100644 --- a/packages/metro-file-map/src/__tests__/index-test.js +++ b/packages/metro-file-map/src/__tests__/index-test.js @@ -298,6 +298,59 @@ describe('HasteMap', () => { expect(hasteMap1.getCacheFilePath()).not.toBe(hasteMap2.getCacheFilePath()); }); + describe('matchFilesWithContext', () => { + it('matches all files', async () => { + const {hasteFS} = await new HasteMap(defaultConfig).build(); + expect( + hasteFS.matchFilesWithContext(path.resolve('/'), { + filter: /.*/, + recursive: true, + }), + ).toEqual([ + path.resolve('/project/fruits/Banana.js'), + path.resolve('/project/fruits/Pear.js'), + path.resolve('/project/fruits/Strawberry.js'), + path.resolve('/project/fruits/__mocks__/Pear.js'), + path.resolve('/project/vegetables/Melon.js'), + ]); + }); + it('matches files in a deep folder', async () => { + const {hasteFS} = await new HasteMap(defaultConfig).build(); + expect( + hasteFS.matchFilesWithContext('/project/fruits/__mocks__', { + filter: /.*/, + recursive: true, + }), + ).toEqual([path.resolve('/project/fruits/__mocks__/Pear.js')]); + }); + it('matches files shallow', async () => { + const {hasteFS} = await new HasteMap(defaultConfig).build(); + expect( + hasteFS.matchFilesWithContext(path.resolve('/project/fruits'), { + filter: /.*/, + recursive: false, + }), + ).toEqual([ + path.resolve('/project/fruits/Banana.js'), + path.resolve('/project/fruits/Pear.js'), + path.resolve('/project/fruits/Strawberry.js'), + ]); + }); + it('matches files with a more specific regex', async () => { + const {hasteFS} = await new HasteMap(defaultConfig).build(); + expect( + hasteFS.matchFilesWithContext(path.resolve('/project'), { + filter: /[BP]/, + recursive: true, + }), + ).toEqual([ + path.resolve('/project/fruits/Banana.js'), + path.resolve('/project/fruits/Pear.js'), + path.resolve('/project/fruits/__mocks__/Pear.js'), + ]); + }); + }); + it('matches files against a pattern', async () => { const {hasteFS} = await new HasteMap(defaultConfig).build(); expect( diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index 1d4885786c..bb69f087e8 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -223,6 +223,19 @@ class DependencyGraph extends EventEmitter { this._haste.end(); } + /** Given a search context, return a list of file paths matching the query. */ + resolveContext( + from: string, + context: { + /* Should search for files recursively. Optional, default `true` when `require.context` is used */ + recursive: boolean, + /* Filename filter pattern for use in `require.context`. Optional, default `/^\.\/.*$/` (any file) when `require.context` is used */ + filter: RegExp, + }, + ): string[] { + return this._hasteFS.matchFilesWithContext(from, context); + } + resolveDependency( from: string, to: string, From faf5df781caddb38daae355b0eba1df59b814ea3 Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Sat, 28 May 2022 15:54:14 -0500 Subject: [PATCH 02/38] Apply suggestions from code review Co-authored-by: Moti Zilberman --- packages/metro-file-map/src/HasteFS.js | 6 +++--- packages/metro/src/node-haste/DependencyGraph.js | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/metro-file-map/src/HasteFS.js b/packages/metro-file-map/src/HasteFS.js index 8a6ab2cd95..caeed7264c 100644 --- a/packages/metro-file-map/src/HasteFS.js +++ b/packages/metro-file-map/src/HasteFS.js @@ -87,12 +87,12 @@ export default class HasteFS { /** Given a search context, return a list of file paths matching the query. */ matchFilesWithContext( root: Path, - context: { + context: $ReadOnly<{ /* Should search for files recursively. */ recursive: boolean, - /* Filter files against a pattern. */ + /* Filter relative paths against a pattern. */ filter: RegExp, - }, + }>, ): Array { const files = []; for (const file of this.getAbsoluteFileIterator()) { diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index bb69f087e8..7d2f26c08b 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -224,14 +224,14 @@ class DependencyGraph extends EventEmitter { } /** Given a search context, return a list of file paths matching the query. */ - resolveContext( + matchFilesWithContext( from: string, - context: { - /* Should search for files recursively. Optional, default `true` when `require.context` is used */ + context: $ReadOnly<{ + /* Should search for files recursively. */ recursive: boolean, - /* Filename filter pattern for use in `require.context`. Optional, default `/^\.\/.*$/` (any file) when `require.context` is used */ + /* Filter relative paths against a pattern. */ filter: RegExp, - }, + }>, ): string[] { return this._hasteFS.matchFilesWithContext(from, context); } From 87bf0af1ae8562b550ea72bcafdbb27ae7a4f5e0 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Fri, 27 May 2022 20:13:00 -0500 Subject: [PATCH 03/38] Update HasteFS.js --- packages/metro-file-map/src/HasteFS.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/metro-file-map/src/HasteFS.js b/packages/metro-file-map/src/HasteFS.js index caeed7264c..5cdd92704b 100644 --- a/packages/metro-file-map/src/HasteFS.js +++ b/packages/metro-file-map/src/HasteFS.js @@ -8,8 +8,9 @@ * @flow strict-local */ -import type {FileData, Path} from './flow-types'; import {sep} from 'path'; + +import type {FileData, Path} from './flow-types'; import H from './constants'; import * as fastPath from './lib/fast_path'; // $FlowFixMe[untyped-import] - jest-util From 0d59e7fca94c9bd65ea223e4f6874b4006da30e0 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Wed, 22 Jun 2022 19:07:08 +0200 Subject: [PATCH 04/38] Update HasteFS.js --- packages/metro-file-map/src/HasteFS.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/metro-file-map/src/HasteFS.js b/packages/metro-file-map/src/HasteFS.js index 5cdd92704b..9fe4746024 100644 --- a/packages/metro-file-map/src/HasteFS.js +++ b/packages/metro-file-map/src/HasteFS.js @@ -8,7 +8,7 @@ * @flow strict-local */ -import {sep} from 'path'; +import * as path from 'path'; import type {FileData, Path} from './flow-types'; import H from './constants'; @@ -96,11 +96,14 @@ export default class HasteFS { }>, ): Array { const files = []; + const prefix = '.' + path.sep; for (const file of this.getAbsoluteFileIterator()) { const filePath = fastPath.relative(root, file); + const isRelative = + filePath && !filePath.startsWith('..') && !path.isAbsolute(filePath); // Ignore everything outside of the provided `root`. - if (filePath.startsWith('..')) { + if (!isRelative) { continue; } @@ -109,10 +112,18 @@ export default class HasteFS { continue; } - if (context.filter.test(filePath)) { + if ( + context.filter.test( + // NOTE(EvanBacon): Ensure files start with `./` for matching purposes + // this ensures packages work across Metro and Webpack (ex: Storybook for React DOM / React Native). + // `a/b.js` -> `./a/b.js` + prefix + filePath, + ) + ) { files.push(file); } } + return files; } From 5852acb23eaf72a5d3b772725e2e1a2405e13290 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Wed, 22 Jun 2022 19:51:41 +0200 Subject: [PATCH 05/38] Add require context changes --- packages/metro/src/Bundler.js | 8 +- .../metro/src/DeltaBundler/DeltaCalculator.js | 52 ++++++- .../metro/src/DeltaBundler/Transformer.js | 29 +++- packages/metro/src/DeltaBundler/Worker.js | 25 +++- packages/metro/src/DeltaBundler/WorkerFarm.js | 2 + packages/metro/src/HmrServer.js | 2 + packages/metro/src/IncrementalBundler.js | 7 + packages/metro/src/lib/contextModule.js | 85 +++++++++++ .../metro/src/lib/contextModuleTemplates.js | 138 ++++++++++++++++++ packages/metro/src/lib/getGraphId.js | 4 + packages/metro/src/lib/transformHelpers.js | 102 ++++++++++++- .../metro/src/node-haste/DependencyGraph.js | 2 +- 12 files changed, 449 insertions(+), 7 deletions(-) create mode 100644 packages/metro/src/lib/contextModule.js create mode 100644 packages/metro/src/lib/contextModuleTemplates.js diff --git a/packages/metro/src/Bundler.js b/packages/metro/src/Bundler.js index 5ba939c03d..50ebcf6471 100644 --- a/packages/metro/src/Bundler.js +++ b/packages/metro/src/Bundler.js @@ -68,12 +68,18 @@ class Bundler { async transformFile( filePath: string, transformOptions: TransformOptions, + /** Optionally provide the file contents, this can be used to provide virtual contents for a file. */ + fileBuffer?: Buffer, ): Promise> { // We need to be sure that the DependencyGraph has been initialized. // TODO: Remove this ugly hack! await this._depGraph.ready(); - return this._transformer.transformFile(filePath, transformOptions); + return this._transformer.transformFile( + filePath, + transformOptions, + fileBuffer, + ); } // Waits for the bundler to become ready. diff --git a/packages/metro/src/DeltaBundler/DeltaCalculator.js b/packages/metro/src/DeltaBundler/DeltaCalculator.js index fae69ac1dd..94cc28c442 100644 --- a/packages/metro/src/DeltaBundler/DeltaCalculator.js +++ b/packages/metro/src/DeltaBundler/DeltaCalculator.js @@ -10,8 +10,14 @@ 'use strict'; +import * as path from 'path'; import type {DeltaResult, Graph, Options} from './types.flow'; +import { + removeContextQueryParam, + fileMatchesContext, +} from '../lib/contextModule'; + const { createGraph, initialTraverseDependencies, @@ -33,6 +39,7 @@ class DeltaCalculator extends EventEmitter { _currentBuildPromise: ?Promise>; _deletedFiles: Set = new Set(); _modifiedFiles: Set = new Set(); + _addedFiles: Set = new Set(); _graph: Graph; @@ -72,6 +79,7 @@ class DeltaCalculator extends EventEmitter { }); this._modifiedFiles = new Set(); this._deletedFiles = new Set(); + this._addedFiles = new Set(); } /** @@ -99,6 +107,8 @@ class DeltaCalculator extends EventEmitter { this._modifiedFiles = new Set(); const deletedFiles = this._deletedFiles; this._deletedFiles = new Set(); + const addedFiles = this._addedFiles; + this._addedFiles = new Set(); // Concurrent requests should reuse the same bundling process. To do so, // this method stores the promise as an instance variable, and then it's @@ -106,6 +116,7 @@ class DeltaCalculator extends EventEmitter { this._currentBuildPromise = this._getChangedDependencies( modifiedFiles, deletedFiles, + addedFiles, ); let result; @@ -121,6 +132,7 @@ class DeltaCalculator extends EventEmitter { // which is not correct. modifiedFiles.forEach((file: string) => this._modifiedFiles.add(file)); deletedFiles.forEach((file: string) => this._deletedFiles.add(file)); + addedFiles.forEach((file: string) => this._addedFiles.add(file)); // If after an error the number of modules has changed, we could be in // a weird state. As a safe net we clean the dependency modules to force @@ -178,9 +190,17 @@ class DeltaCalculator extends EventEmitter { if (type === 'delete') { this._deletedFiles.add(filePath); this._modifiedFiles.delete(filePath); - } else { + this._addedFiles.delete(filePath); + } else if (type === 'add') { + this._addedFiles.add(filePath); + this._deletedFiles.delete(filePath); + this._modifiedFiles.delete(filePath); + } else { this._modifiedFiles.add(filePath); + + this._deletedFiles.delete(filePath); + this._addedFiles.delete(filePath); } // Notify users that there is a change in some of the bundle files. This @@ -191,6 +211,7 @@ class DeltaCalculator extends EventEmitter { async _getChangedDependencies( modifiedFiles: Set, deletedFiles: Set, + addedFiles: Set, ): Promise> { if (!this._graph.dependencies.size) { const {added} = await initialTraverseDependencies( @@ -227,6 +248,35 @@ class DeltaCalculator extends EventEmitter { (filePath: string) => this._graph.dependencies.has(filePath), ); + // NOTE(EvanBacon): This check adds extra complexity so we feature gate it + // to enable users to opt out. + if (this._options.unstable_allowRequireContext) { + const checkModifiedContextDependencies = (filePath: string) => { + this._graph.dependencies.forEach(value => { + if ( + value.contextParams && + !modifiedDependencies.includes(value.path) && + fileMatchesContext( + removeContextQueryParam(value.path), + filePath, + value.contextParams, + ) + ) { + modifiedDependencies.push(value.path); + } + }); + }; + + // Check if any added or removed files are matched in a context module. + Array.from(addedFiles).forEach(filePath => + checkModifiedContextDependencies(filePath), + ); + + Array.from(deletedFiles).forEach(filePath => + checkModifiedContextDependencies(filePath), + ); + } + // No changes happened. Return empty delta. if (modifiedDependencies.length === 0) { return { diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index cea560befd..970f59994a 100644 --- a/packages/metro/src/DeltaBundler/Transformer.js +++ b/packages/metro/src/DeltaBundler/Transformer.js @@ -12,6 +12,8 @@ import type {TransformResult, TransformResultWithSource} from '../DeltaBundler'; import type {TransformerConfig, TransformOptions} from './Worker'; +import {toRequireContext} from '../lib/contextModule'; +import crypto from 'crypto'; import type {ConfigT} from 'metro-config/src/configTypes.flow'; const getTransformCacheKey = require('./getTransformCacheKey'); @@ -66,6 +68,7 @@ class Transformer { async transformFile( filePath: string, transformerOptions: TransformOptions, + fileBuffer?: Buffer, ): Promise> { const cache = this._cache; @@ -119,7 +122,13 @@ class Transformer { unstable_transformProfile, ]); - const sha1 = this._getSha1(filePath); + let sha1: string; + if (fileBuffer) { + sha1 = crypto.createHash('sha1').update(fileBuffer).digest('hex'); + } else { + sha1 = this._getSha1(filePath); + } + let fullKey = Buffer.concat([partialKey, Buffer.from(sha1, 'hex')]); const result = await cache.get(fullKey); @@ -127,7 +136,20 @@ class Transformer { // the transformer to computed the corresponding result. const data = result ? {result, sha1} - : await this._workerFarm.transform(localPath, transformerOptions); + : await this._workerFarm.transform( + localPath, + transformerOptions, + fileBuffer, + ); + + data.result.dependencies.forEach(dependency => { + if (dependency.data.contextParams) { + // Convert JSON regular expression into RegExp. + dependency.data.contextParams = toRequireContext( + dependency.data.contextParams, + ); + } + }); // Only re-compute the full key if the SHA-1 changed. This is because // references are used by the cache implementation in a weak map to keep @@ -141,6 +163,9 @@ class Transformer { return { ...data.result, getSource(): Buffer { + if (fileBuffer) { + return fileBuffer; + } return fs.readFileSync(filePath); }, }; diff --git a/packages/metro/src/DeltaBundler/Worker.js b/packages/metro/src/DeltaBundler/Worker.js index a8d0451406..633fe38deb 100644 --- a/packages/metro/src/DeltaBundler/Worker.js +++ b/packages/metro/src/DeltaBundler/Worker.js @@ -56,6 +56,30 @@ async function transform( transformOptions: JsTransformOptions, projectRoot: string, transformerConfig: TransformerConfig, + fileBuffer?: Buffer, +): Promise { + let data; + + if (fileBuffer && fileBuffer.type === 'Buffer') { + data = Buffer.from(fileBuffer.data); + } else { + data = fs.readFileSync(path.resolve(projectRoot, filename)); + } + return transformFile( + filename, + data, + transformOptions, + projectRoot, + transformerConfig, + ); +} + +async function transformFile( + filename: string, + data: Buffer, + transformOptions: JsTransformOptions, + projectRoot: string, + transformerConfig: TransformerConfig, ): Promise { // eslint-disable-next-line no-useless-call const Transformer = (require.call( @@ -71,7 +95,6 @@ async function transform( start_timestamp: process.hrtime(), }; - const data = fs.readFileSync(path.resolve(projectRoot, filename)); const sha1 = crypto.createHash('sha1').update(data).digest('hex'); const result = await Transformer.transform( diff --git a/packages/metro/src/DeltaBundler/WorkerFarm.js b/packages/metro/src/DeltaBundler/WorkerFarm.js index bd813a3738..55b60edbb1 100644 --- a/packages/metro/src/DeltaBundler/WorkerFarm.js +++ b/packages/metro/src/DeltaBundler/WorkerFarm.js @@ -78,6 +78,7 @@ class WorkerFarm { async transform( filename: string, options: TransformOptions, + fileBuffer?: Buffer, ): Promise { try { const data = await this._worker.transform( @@ -85,6 +86,7 @@ class WorkerFarm { options, this._config.projectRoot, this._transformerConfig, + fileBuffer, ); Logger.log(data.transformFileStartLogEntry); diff --git a/packages/metro/src/HmrServer.js b/packages/metro/src/HmrServer.js index ef28830d42..fd3c80ee40 100644 --- a/packages/metro/src/HmrServer.js +++ b/packages/metro/src/HmrServer.js @@ -126,6 +126,8 @@ class HmrServer { shallow: graphOptions.shallow, experimentalImportBundleSupport: this._config.transformer.experimentalImportBundleSupport, + unstable_allowRequireContext: + this._config.transformer.unstable_allowRequireContext, }); const revPromise = this._bundler.getRevisionByGraphId(graphId); if (!revPromise) { diff --git a/packages/metro/src/IncrementalBundler.js b/packages/metro/src/IncrementalBundler.js index dcfd5646ac..d42c9d6cb6 100644 --- a/packages/metro/src/IncrementalBundler.js +++ b/packages/metro/src/IncrementalBundler.js @@ -112,6 +112,13 @@ class IncrementalBundler { this._bundler, transformOptions.platform, ), + transformContext: await transformHelpers.getTransformContextFn( + absoluteEntryFiles, + this._bundler, + this._deltaBundler, + this._config, + transformOptions, + ), transform: await transformHelpers.getTransformFn( absoluteEntryFiles, this._bundler, diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js new file mode 100644 index 0000000000..0a51ff4682 --- /dev/null +++ b/packages/metro/src/lib/contextModule.js @@ -0,0 +1,85 @@ +import crypto from 'crypto'; +import path from 'path'; +import type { + RequireContextParams, +} from '../ModuleGraph/worker/collectDependencies'; +import type { + RequireContext, +} from '../DeltaBundler/types.flow'; + + +/** Convert a JSON context into an object. */ +export function toRequireContext(context: RequireContextParams): string { + return { + ...context, + filter: new RegExp(context.filter.pattern, context.filter.flags) + } +} + +/** Get an ID for a context module. */ +export function getContextModuleId(modulePath: string, context: RequireContext): string { + // Similar to other `require.context` implementations. + return [ + modulePath, + context.mode, + context.recursive ? 'recursive' : '', + context.filter.toString(), + ] + .filter(Boolean) + .join(' '); +} + +function toHash(value: string): string { + // Use `hex` to ensure filepath safety. + return crypto.createHash('sha1').update(value).digest('hex'); +} + +/** Given a virtualized path, strip the virtual component and return a path that could be real. */ +export function removeContextQueryParam(virtualFilePath: string): string { + const [filepath] = virtualFilePath.split('?ctx='); + return filepath; +} + +/** Given a path and a require context, return a virtual file path that ensures uniqueness between paths with different contexts. */ +export function appendContextQueryParam(filePath: string, context: RequireContext): string { + // Drop the trailing slash, require.context should always be matched against a folder + // and we want to normalize the folder name as much as possible to prevent duplicates. + // This also makes the files show up in the correct location when debugging in Chrome. + filePath = filePath.endsWith('/') ? filePath.slice(0, -1) : filePath; + return filePath + '?ctx=' + toHash(getContextModuleId(filePath, context)); +} + +/** Match a file against a require context. */ +export function fileMatchesContext( + inputPath: string, + testPath: string, + context: $ReadOnly<{ + /* Should search for files recursively. */ + recursive: boolean, + /* Filter relative paths against a pattern. */ + filter: RegExp, + }>, +) { + // NOTE(EvanBacon): Ensure this logic is synchronized with the similar + // functionality in `metro-file-map/src/HasteFS.js` (`matchFilesWithContext()`) + + const filePath = path.relative(inputPath, testPath); + + if ( + // Ignore everything outside of the provided `root`. + !(filePath && !filePath.startsWith('..') && !path.isAbsolute(filePath)) || + // Prevent searching in child directories during a non-recursive search. + (!context.recursive && filePath.includes(path.sep)) || + // Test against the filter. + !context.filter.test( + // NOTE(EvanBacon): Ensure files start with `./` for matching purposes + // this ensures packages work across Metro and Webpack (ex: Storybook for React DOM / React Native). + // `a/b.js` -> `./a/b.js` + '.' + path.sep + filePath, + ) + ) { + return false; + } + + return true; + } \ No newline at end of file diff --git a/packages/metro/src/lib/contextModuleTemplates.js b/packages/metro/src/lib/contextModuleTemplates.js new file mode 100644 index 0000000000..a1398e5d8d --- /dev/null +++ b/packages/metro/src/lib/contextModuleTemplates.js @@ -0,0 +1,138 @@ +import * as path from 'path'; +import type { + ContextMode, +} from '../ModuleGraph/worker/collectDependencies'; + +function createFileMap( + modulePath: string, + files: string[], + processModule: (moduleId: string) => string, +) { + let mapString = ''; + + files.map(file => { + let filePath = path.relative(modulePath, file); + + // NOTE(EvanBacon): I'd prefer we prevent the ability for a module to require itself (`require.context('./')`) + // but Webpack allows this, keeping it here provides better parity between bundlers. + + // Ensure relative file paths start with `./` so they match the + // patterns (filters) used to include them. + if (!filePath.startsWith('.')) { + filePath = `.${path.sep}` + filePath; + } + const key = JSON.stringify(filePath); + // NOTE(EvanBacon): Webpack uses `require.resolve` in order to load modules on demand, + // Metro doesn't have this functionality so it will use getters instead. Modules need to + // be loaded on demand because if we imported directly then users would get errors from importing + // a file without exports as soon as they create a new file and the context module is updated. + + // NOTE: The values are set to `enumerable` so the `context.keys()` method works as expected. + mapString += `${key}: { enumerable: true, get() { return ${processModule( + file, + )}; } },`; + }); + return `Object.defineProperties({}, {${mapString}})`; +} + +function getEmptyContextModuleTemplate(modulePath: string, id: string): string { +return ` +function metroEmptyContext(request) { + let e = new Error("No modules for context '" + ${JSON.stringify(id)} + "'"); + e.code = 'MODULE_NOT_FOUND'; + throw e; +} + +// Return the keys that can be resolved. +metroEmptyContext.keys = () => ([]); + +// Return the module identifier for a user request. +metroEmptyContext.resolve = function metroContextResolve(request) { + throw new Error('Unimplemented Metro module context functionality'); +} + +// Readable identifier for the context module. +metroEmptyContext.id = ${JSON.stringify(id)}; + +module.exports = metroEmptyContext;`; +} + +function getLoadableContextModuleTemplate( + modulePath: string, + files: string[], + id: string, + importSyntax: string, + getContextTemplate: string, +): string { + return `// All of the requested modules are loaded behind enumerable getters. +const map = ${createFileMap( + modulePath, + files, + moduleId => `${importSyntax}(${JSON.stringify(moduleId)})`, +)}; + +function metroContext(request) { + ${getContextTemplate} +} + +// Return the keys that can be resolved. +metroContext.keys = function metroContextKeys() { + return Object.keys(map); +}; + +// Return the module identifier for a user request. +metroContext.resolve = function metroContextResolve(request) { + throw new Error('Unimplemented Metro module context functionality'); +} + +// Readable identifier for the context module. +metroContext.id = ${JSON.stringify(id)}; + +module.exports = metroContext;`; +} + +export function getContextModuleTemplate( + mode: ContextMode, + modulePath: string, + files: string[], + id: string, +): string { + if (!files.length) { + return getEmptyContextModuleTemplate(modulePath, id); + } + switch (mode) { + case 'eager': + return getLoadableContextModuleTemplate( + modulePath, + files, + id, + // NOTE(EvanBacon): It's unclear if we should use `import` or `require` here so sticking + // with the more stable option (`require`) for now. + 'require', + [ + ' // Here Promise.resolve().then() is used instead of new Promise() to prevent', + ' // uncaught exception popping up in devtools', + ' return Promise.resolve().then(() => map[request]);', + ].join('\n'), + ); + case 'sync': + return getLoadableContextModuleTemplate( + modulePath, + files, + id, + 'require', + ' return map[request];', + ); + case 'lazy': + case 'lazy-once': + return getLoadableContextModuleTemplate( + modulePath, + files, + id, + 'import', + ' return map[request];', + ); + default: + throw new Error(`Metro context mode "${mode}" is unimplemented`); + } +} diff --git a/packages/metro/src/lib/getGraphId.js b/packages/metro/src/lib/getGraphId.js index 7c312365a0..61910b1e58 100644 --- a/packages/metro/src/lib/getGraphId.js +++ b/packages/metro/src/lib/getGraphId.js @@ -22,9 +22,11 @@ function getGraphId( { shallow, experimentalImportBundleSupport, + unstable_allowRequireContext, }: { +shallow: boolean, +experimentalImportBundleSupport: boolean, + +unstable_allowRequireContext: boolean, ... }, ): GraphId { @@ -41,10 +43,12 @@ function getGraphId( hot: options.hot, minify: options.minify, unstable_disableES6Transforms: options.unstable_disableES6Transforms, + requireContext: options.requireContext, platform: options.platform != null ? options.platform : null, runtimeBytecodeVersion: options.runtimeBytecodeVersion, type: options.type, experimentalImportBundleSupport, + unstable_allowRequireContext, shallow, unstable_transformProfile: options.unstable_transformProfile || 'default', diff --git a/packages/metro/src/lib/transformHelpers.js b/packages/metro/src/lib/transformHelpers.js index 4455abfb92..1ba2ccd9fe 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -12,11 +12,19 @@ import type Bundler from '../Bundler'; import type DeltaBundler, {TransformFn} from '../DeltaBundler'; -import type {TransformInputOptions} from '../DeltaBundler/types.flow'; +import type { + TransformContextFn, + TransformInputOptions, + RequireContext, +} from '../DeltaBundler/types.flow'; +import type {ContextMode} from '../ModuleGraph/worker/collectDependencies'; import type {TransformOptions} from '../DeltaBundler/Worker'; import type {ConfigT} from 'metro-config/src/configTypes.flow'; import type {Type} from 'metro-transform-worker'; +import {getContextModuleTemplate} from './contextModuleTemplates'; +import {getContextModuleId, fileMatchesContext} from './contextModule'; + const path = require('path'); type InlineRequiresRaw = {+blockList: {[string]: true, ...}, ...} | boolean; @@ -60,6 +68,16 @@ async function calcTransformerOptions( const getDependencies = async (path: string) => { const dependencies = await deltaBundler.getDependencies([path], { resolve: await getResolveDependencyFn(bundler, options.platform), + transformContext: await getTransformContextFn( + [path], + bundler, + deltaBundler, + config, + { + ...options, + minify: false, + }, + ), transform: await getTransformFn([path], bundler, deltaBundler, config, { ...options, minify: false, @@ -68,6 +86,8 @@ async function calcTransformerOptions( onProgress: null, experimentalImportBundleSupport: config.transformer.experimentalImportBundleSupport, + unstable_allowRequireContext: + config.transformer.unstable_allowRequireContext, shallow: false, }); @@ -103,6 +123,85 @@ function removeInlineRequiresBlockListFromOptions( return inlineRequires; } +/** Generate the default method for transforming a `require.context` module. */ +async function getTransformContextFn( + entryFiles: $ReadOnlyArray, + bundler: Bundler, + deltaBundler: DeltaBundler<>, + config: ConfigT, + options: TransformInputOptions, +): Promise> { + const {inlineRequires, ...transformOptions} = await calcTransformerOptions( + entryFiles, + bundler, + deltaBundler, + config, + options, + ); + + // Cache all of the modules for intermittent updates. + const moduleCache = {}; + + return async (modulePath: string, requireContext: RequireContext) => { + const graph = await bundler.getDependencyGraph(); + + let files = []; + if (modulePath in moduleCache && requireContext.delta) { + // Get the cached modules + files = moduleCache[modulePath]; + + // Remove files from the cache. + const deletedFiles = requireContext.delta.deletedFiles; + if (deletedFiles.size) { + files = files.filter(filePath => !deletedFiles.has(filePath)); + } + + // Add files to the cache. + const addedFiles = requireContext.delta?.addedFiles; + addedFiles?.forEach(filePath => { + if ( + !files.includes(filePath) && + fileMatchesContext(modulePath, filePath, requireContext) + ) { + files.push(filePath); + } + }); + } else { + // Search against all files, this is very expensive. + // TODO: Maybe we could let the user specify which root to check against. + files = graph.matchFilesWithContext(modulePath, { + filter: requireContext.filter, + recursive: requireContext.recursive, + }); + } + + moduleCache[modulePath] = files; + + const template = getContextModuleTemplate( + requireContext.mode, + modulePath, + files, + getContextModuleId(modulePath, requireContext), + ); + return await bundler.transformFile( + modulePath, + { + ...transformOptions, + type: getType( + transformOptions.type, + modulePath, + config.resolver.assetExts, + ), + inlineRequires: removeInlineRequiresBlockListFromOptions( + modulePath, + inlineRequires, + ), + }, + Buffer.from(template), + ); + }; +} + async function getTransformFn( entryFiles: $ReadOnlyArray, bundler: Bundler, @@ -159,5 +258,6 @@ async function getResolveDependencyFn( module.exports = { getTransformFn, + getTransformContextFn, getResolveDependencyFn, }; diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index 7d2f26c08b..c229cedbee 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -100,7 +100,7 @@ class DependencyGraph extends EventEmitter { }); } - // Waits for the dependency graph to become ready after initialisation. + // Waits for the dependency graph to become ready after initialization. // Don't read anything from the graph until this resolves. async ready(): Promise { await this._readyPromise; From 360ee1931c7c1a79e12b76997593ff2ec204778f Mon Sep 17 00:00:00 2001 From: evanbacon Date: Wed, 22 Jun 2022 20:03:09 +0200 Subject: [PATCH 06/38] Add all require.context changes --- .../metro-runtime/src/polyfills/require.js | 11 ++++ .../metro/src/DeltaBundler/DeltaCalculator.js | 1 - .../metro/src/DeltaBundler/Worker.flow.js | 25 +++++++- .../metro/src/DeltaBundler/graphOperations.js | 61 +++++++++++++++++-- packages/metro/src/DeltaBundler/types.flow.js | 26 +++++++- packages/metro/src/IncrementalBundler.js | 13 ++++ .../ModuleGraph/worker/collectDependencies.js | 2 +- packages/metro/src/Server.js | 4 ++ packages/metro/src/lib/transformHelpers.js | 41 +++---------- 9 files changed, 142 insertions(+), 42 deletions(-) diff --git a/packages/metro-runtime/src/polyfills/require.js b/packages/metro-runtime/src/polyfills/require.js index aea905d05b..9c86f7633c 100644 --- a/packages/metro-runtime/src/polyfills/require.js +++ b/packages/metro-runtime/src/polyfills/require.js @@ -278,6 +278,17 @@ function metroImportAll(moduleId: ModuleID | VerboseModuleNameForDev | number) { } metroRequire.importAll = metroImportAll; +if (__DEV__) { + // The `require.context()` syntax is never executed in the runtime because it is converted + // to `require()` in `metro/src/ModuleGraph/worker/collectDependencies.js` after collecting + // dependencies. If the feature flag is not enabled then the conversion never takes place and this error is thrown (development only). + metroRequire.context = () => { + throw new Error( + `The experimental Metro feature \`require.context\` is not enabled in your project.\nThis can be enabled by setting the \`transformer.unstable_allowRequireContext\` property to \`true\` in the project \`metro.config.js\`.`, + ); + }; +} + let inGuard = false; function guardedLoadModule( moduleId: ModuleID, diff --git a/packages/metro/src/DeltaBundler/DeltaCalculator.js b/packages/metro/src/DeltaBundler/DeltaCalculator.js index 94cc28c442..6a9cbedf33 100644 --- a/packages/metro/src/DeltaBundler/DeltaCalculator.js +++ b/packages/metro/src/DeltaBundler/DeltaCalculator.js @@ -195,7 +195,6 @@ class DeltaCalculator extends EventEmitter { this._addedFiles.add(filePath); this._deletedFiles.delete(filePath); - this._modifiedFiles.delete(filePath); } else { this._modifiedFiles.add(filePath); diff --git a/packages/metro/src/DeltaBundler/Worker.flow.js b/packages/metro/src/DeltaBundler/Worker.flow.js index a8d0451406..633fe38deb 100644 --- a/packages/metro/src/DeltaBundler/Worker.flow.js +++ b/packages/metro/src/DeltaBundler/Worker.flow.js @@ -56,6 +56,30 @@ async function transform( transformOptions: JsTransformOptions, projectRoot: string, transformerConfig: TransformerConfig, + fileBuffer?: Buffer, +): Promise { + let data; + + if (fileBuffer && fileBuffer.type === 'Buffer') { + data = Buffer.from(fileBuffer.data); + } else { + data = fs.readFileSync(path.resolve(projectRoot, filename)); + } + return transformFile( + filename, + data, + transformOptions, + projectRoot, + transformerConfig, + ); +} + +async function transformFile( + filename: string, + data: Buffer, + transformOptions: JsTransformOptions, + projectRoot: string, + transformerConfig: TransformerConfig, ): Promise { // eslint-disable-next-line no-useless-call const Transformer = (require.call( @@ -71,7 +95,6 @@ async function transform( start_timestamp: process.hrtime(), }; - const data = fs.readFileSync(path.resolve(projectRoot, filename)); const sha1 = crypto.createHash('sha1').update(data).digest('hex'); const result = await Transformer.transform( diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index 530d7d62b1..814c899749 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -37,10 +37,16 @@ import type { Module, Options, TransformResultDependency, + RequireContext, } from './types.flow'; import CountingSet from '../lib/CountingSet'; +import { + appendContextQueryParam, + removeContextQueryParam, +} from '../lib/contextModule'; +import * as path from 'path'; const invariant = require('invariant'); const nullthrows = require('nullthrows'); @@ -115,11 +121,13 @@ type InternalOptions = $ReadOnly<{ onDependencyAdded: () => mixed, resolve: Options['resolve'], transform: Options['transform'], + transformContext: Options['transformContext'], shallow: boolean, }>; function getInternalOptions({ transform, + transformContext, resolve, onProgress, experimentalImportBundleSupport, @@ -131,6 +139,7 @@ function getInternalOptions({ return { experimentalImportBundleSupport, transform, + transformContext, resolve, onDependencyAdd: () => onProgress && onProgress(numProcessed, ++total), onDependencyAdded: () => onProgress && onProgress(++numProcessed, total), @@ -144,7 +153,7 @@ function getInternalOptions({ * dependency graph. * Instead of traversing the whole graph each time, it just calculates the * difference between runs by only traversing the added/removed dependencies. - * To do so, it uses the passed passed graph dependencies and it mutates it. + * To do so, it uses the passed graph dependencies and it mutates it. * The paths parameter contains the absolute paths of the root files that the * method should traverse. Normally, these paths should be the modified files * since the last traversal. @@ -255,7 +264,13 @@ async function traverseDependenciesForSingleFile( ): Promise { options.onDependencyAdd(); - await processModule(path, graph, delta, options); + await processModule( + path, + graph, + delta, + options, + graph.dependencies.get(path)?.contextParams, + ); options.onDependencyAdded(); } @@ -265,10 +280,20 @@ async function processModule( graph: Graph, delta: Delta, options: InternalOptions, + contextParams?: RequireContext, ): Promise> { + const resolvedContextParams = + contextParams || graph.dependencies.get(path)?.contextParams; + // Transform the file via the given option. // TODO: Unbind the transform method from options - const result = await options.transform(path); + let result; + if (resolvedContextParams) { + const modulePath = removeContextQueryParam(path); + result = await options.transformContext(modulePath, resolvedContextParams); + } else { + result = await options.transform(path); + } // Get the absolute path of all sub-dependencies (some of them could have been // moved but maintain the same relative path). @@ -288,6 +313,7 @@ async function processModule( // Update the module information. const module = { ...previousModule, + contextParams: resolvedContextParams, dependencies: new Map(previousDependencies), getSource: result.getSource, output: result.output, @@ -403,7 +429,13 @@ async function addDependency( delta.earlyInverseDependencies.set(path, new CountingSet()); options.onDependencyAdd(); - module = await processModule(path, graph, delta, options); + module = await processModule( + path, + graph, + delta, + options, + dependency.data.data.contextParams, + ); options.onDependencyAdded(); graph.dependencies.set(module.path, module); @@ -469,6 +501,27 @@ function resolveDependencies( const resolve = (parentPath: string, result: TransformResultDependency) => { const relativePath = result.name; try { + // `require.context` + if (result.data.contextParams) { + let absolutePath = path.join(parentPath, '..', result.name); + + // Ensure the filepath has uniqueness applied to ensure multiple `require.context` + // statements can be used to target the same file with different properties. + absolutePath = appendContextQueryParam( + absolutePath, + result.data.contextParams, + ); + + return [ + relativePath, + { + // TODO: Verify directory exists + // absolutePath: options.resolve(parentPath, dep.name), + absolutePath, + data: result, + }, + ]; + } return [ relativePath, { diff --git a/packages/metro/src/DeltaBundler/types.flow.js b/packages/metro/src/DeltaBundler/types.flow.js index f244607e12..4e3b065d37 100644 --- a/packages/metro/src/DeltaBundler/types.flow.js +++ b/packages/metro/src/DeltaBundler/types.flow.js @@ -10,12 +10,24 @@ 'use strict'; -import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; +import type { + RequireContextParams, + ContextMode, +} from '../ModuleGraph/worker/collectDependencies'; import type {PrivateState} from './graphOperations'; import type {JsTransformOptions} from 'metro-transform-worker'; import CountingSet from '../lib/CountingSet'; +export type RequireContext = { + /* Should search for files recursively. Optional, default `true` when `require.context` is used */ + recursive: boolean, + /* Filename filter pattern for use in `require.context`. Optional, default `.*` (any file) when `require.context` is used */ + filter: RegExp, + /** Mode for resolving dynamic dependencies. Defaults to `sync` */ + mode: ContextMode, +}; + export type MixedOutput = { +data: mixed, +type: string, @@ -63,6 +75,7 @@ export type Dependency = { }; export type Module = { + +contextParams?: RequireContext, +dependencies: Map, +inverseDependencies: CountingSet, +output: $ReadOnlyArray, @@ -104,6 +117,12 @@ export type TransformResultWithSource = $ReadOnly<{ getSource: () => Buffer, }>; +/** Transformer for generating `require.context` virtual module. */ +export type TransformContextFn = ( + string, + RequireContext, +) => Promise>; + export type TransformFn = string => Promise< TransformResultWithSource, >; @@ -115,11 +134,14 @@ export type AllowOptionalDependencies = | AllowOptionalDependenciesWithOptions; export type Options = { - +resolve: (from: string, to: string) => string, + +resolve: (from: string, to: string, context?: ?RequireContext) => string, +transform: TransformFn, + /** Given a path and require context, return a virtual context module. */ + +transformContext: TransformContextFn, +transformOptions: TransformInputOptions, +onProgress: ?(numProcessed: number, total: number) => mixed, +experimentalImportBundleSupport: boolean, + +unstable_allowRequireContext: boolean, +shallow: boolean, }; diff --git a/packages/metro/src/IncrementalBundler.js b/packages/metro/src/IncrementalBundler.js index d42c9d6cb6..240e3eea64 100644 --- a/packages/metro/src/IncrementalBundler.js +++ b/packages/metro/src/IncrementalBundler.js @@ -130,6 +130,8 @@ class IncrementalBundler { onProgress: otherOptions.onProgress, experimentalImportBundleSupport: this._config.transformer.experimentalImportBundleSupport, + unstable_allowRequireContext: + this._config.transformer.unstable_allowRequireContext, shallow: otherOptions.shallow, }); @@ -160,6 +162,13 @@ class IncrementalBundler { this._bundler, transformOptions.platform, ), + transformContext: await transformHelpers.getTransformContextFn( + absoluteEntryFiles, + this._bundler, + this._deltaBundler, + this._config, + transformOptions, + ), transform: await transformHelpers.getTransformFn( absoluteEntryFiles, this._bundler, @@ -171,6 +180,8 @@ class IncrementalBundler { onProgress: otherOptions.onProgress, experimentalImportBundleSupport: this._config.transformer.experimentalImportBundleSupport, + unstable_allowRequireContext: + this._config.transformer.unstable_allowRequireContext, shallow: otherOptions.shallow, }, ); @@ -225,6 +236,8 @@ class IncrementalBundler { shallow: otherOptions.shallow, experimentalImportBundleSupport: this._config.transformer.experimentalImportBundleSupport, + unstable_allowRequireContext: + this._config.transformer.unstable_allowRequireContext, }); const revisionId = createRevisionId(); const revisionPromise = (async () => { diff --git a/packages/metro/src/ModuleGraph/worker/collectDependencies.js b/packages/metro/src/ModuleGraph/worker/collectDependencies.js index e4a7d6e3b0..a67de8d055 100644 --- a/packages/metro/src/ModuleGraph/worker/collectDependencies.js +++ b/packages/metro/src/ModuleGraph/worker/collectDependencies.js @@ -38,7 +38,7 @@ export type Dependency = $ReadOnly<{ }>; // TODO: Convert to a Flow enum -type ContextMode = 'sync' | 'eager' | 'lazy' | 'lazy-once'; +export type ContextMode = 'sync' | 'eager' | 'lazy' | 'lazy-once'; type ContextFilter = {pattern: string, flags: string}; diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 7edea0bd20..295ddf49a3 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -533,6 +533,8 @@ class Server { shallow: graphOptions.shallow, experimentalImportBundleSupport: this._config.transformer.experimentalImportBundleSupport, + unstable_allowRequireContext: + this._config.transformer.unstable_allowRequireContext, }); // For resources that support deletion, handle the DELETE method. @@ -1134,6 +1136,8 @@ class Server { shallow: graphOptions.shallow, experimentalImportBundleSupport: this._config.transformer.experimentalImportBundleSupport, + unstable_allowRequireContext: + this._config.transformer.unstable_allowRequireContext, }); let revision; const revPromise = this._bundler.getRevisionByGraphId(graphId); diff --git a/packages/metro/src/lib/transformHelpers.js b/packages/metro/src/lib/transformHelpers.js index 1ba2ccd9fe..d40d1ec328 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -139,43 +139,18 @@ async function getTransformContextFn( options, ); - // Cache all of the modules for intermittent updates. - const moduleCache = {}; - return async (modulePath: string, requireContext: RequireContext) => { const graph = await bundler.getDependencyGraph(); - let files = []; - if (modulePath in moduleCache && requireContext.delta) { - // Get the cached modules - files = moduleCache[modulePath]; - - // Remove files from the cache. - const deletedFiles = requireContext.delta.deletedFiles; - if (deletedFiles.size) { - files = files.filter(filePath => !deletedFiles.has(filePath)); - } + // TODO: Check delta changes to avoid having to look over all files each time + // this is a massive performance boost. - // Add files to the cache. - const addedFiles = requireContext.delta?.addedFiles; - addedFiles?.forEach(filePath => { - if ( - !files.includes(filePath) && - fileMatchesContext(modulePath, filePath, requireContext) - ) { - files.push(filePath); - } - }); - } else { - // Search against all files, this is very expensive. - // TODO: Maybe we could let the user specify which root to check against. - files = graph.matchFilesWithContext(modulePath, { - filter: requireContext.filter, - recursive: requireContext.recursive, - }); - } - - moduleCache[modulePath] = files; + // Search against all files, this is very expensive. + // TODO: Maybe we could let the user specify which root to check against. + const files = graph.matchFilesWithContext(modulePath, { + filter: requireContext.filter, + recursive: requireContext.recursive, + }); const template = getContextModuleTemplate( requireContext.mode, From 7b78806b38ccd0a1b5b295caef574ac99c030d6a Mon Sep 17 00:00:00 2001 From: evanbacon Date: Tue, 19 Jul 2022 13:31:32 +0200 Subject: [PATCH 07/38] pr feedback revert broken feedback Updated comment Added tests for HasteFS Update HasteFS-test.js Add contextModuleTemplates tests fixup types Update index.js Drop getTransformFn drop unused Update types.flow.js Added more tests inputPath -> from Test require.context fixup --- packages/metro-file-map/src/HasteFS.js | 8 +- .../src/__tests__/HasteFS-test.js | 52 +++++ .../src/polyfills/__tests__/require-test.js | 7 + .../metro-runtime/src/polyfills/require.js | 19 +- .../metro/src/DeltaBundler/DeltaCalculator.js | 25 +-- .../metro/src/DeltaBundler/Transformer.js | 10 - .../metro/src/DeltaBundler/Worker.flow.js | 22 ++- .../__tests__/DeltaCalculator-test.js | 1 + .../metro/src/DeltaBundler/graphOperations.js | 43 ++--- packages/metro/src/DeltaBundler/types.flow.js | 13 +- packages/metro/src/IncrementalBundler.js | 14 -- .../ModuleGraph/worker/collectDependencies.js | 5 +- .../contextModuleTemplates-test.js.snap | 121 ++++++++++++ .../src/lib/__tests__/contextModule-test.js | 54 ++++++ .../__tests__/contextModuleTemplates-test.js | 62 ++++++ .../src/lib/__tests__/getGraphId-test.js | 72 +++++-- packages/metro/src/lib/contextModule.js | 127 +++++++------ .../metro/src/lib/contextModuleTemplates.js | 178 ++++++++++-------- packages/metro/src/lib/getGraphId.js | 1 - packages/metro/src/lib/getPrependedScripts.js | 2 + packages/metro/src/lib/transformHelpers.js | 87 +++------ 21 files changed, 625 insertions(+), 298 deletions(-) create mode 100644 packages/metro-file-map/src/__tests__/HasteFS-test.js create mode 100644 packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap create mode 100644 packages/metro/src/lib/__tests__/contextModule-test.js create mode 100644 packages/metro/src/lib/__tests__/contextModuleTemplates-test.js diff --git a/packages/metro-file-map/src/HasteFS.js b/packages/metro-file-map/src/HasteFS.js index 9fe4746024..38d9d31325 100644 --- a/packages/metro-file-map/src/HasteFS.js +++ b/packages/metro-file-map/src/HasteFS.js @@ -85,7 +85,11 @@ export default class HasteFS { return files; } - /** Given a search context, return a list of file paths matching the query. */ + /** + * Given a search context, return a list of file paths matching the query. + * The query matches against normalized paths which start with `./`, + * for example: `a/b.js` -> `./a/b.js` + */ matchFilesWithContext( root: Path, context: $ReadOnly<{ @@ -108,7 +112,7 @@ export default class HasteFS { } // Prevent searching in child directories during a non-recursive search. - if (!context.recursive && filePath.includes(sep)) { + if (!context.recursive && filePath.includes(path.sep)) { continue; } diff --git a/packages/metro-file-map/src/__tests__/HasteFS-test.js b/packages/metro-file-map/src/__tests__/HasteFS-test.js new file mode 100644 index 0000000000..6d64c38d2f --- /dev/null +++ b/packages/metro-file-map/src/__tests__/HasteFS-test.js @@ -0,0 +1,52 @@ +/** + * 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 + */ + +import HasteFS from '../HasteFS'; + +describe('matchFilesWithContext', () => { + it(`matches files against context`, () => { + const hfs = new HasteFS({ + rootDir: '/', + files: new Map([]), + }); + + // $FlowFixMe: mocking files + hfs.getAbsoluteFileIterator = function () { + return ['/foo/another.js', '/bar.js']; + }; + + // Test non-recursive skipping deep paths + expect( + hfs.matchFilesWithContext('/', { + filter: new RegExp( + // Test starting with `./` since this is mandatory for parity with Webpack. + /^\.\/.*/, + ), + recursive: false, + }), + ).toEqual(['/bar.js']); + + // Test inner directory + expect( + hfs.matchFilesWithContext('/foo', { + filter: new RegExp(/.*/), + recursive: true, + }), + ).toEqual(['/foo/another.js']); + + // Test recursive + expect( + hfs.matchFilesWithContext('/', { + filter: new RegExp(/.*/), + recursive: true, + }), + ).toEqual(['/foo/another.js', '/bar.js']); + }); +}); diff --git a/packages/metro-runtime/src/polyfills/__tests__/require-test.js b/packages/metro-runtime/src/polyfills/__tests__/require-test.js index 2d77907064..610fe18c31 100644 --- a/packages/metro-runtime/src/polyfills/__tests__/require-test.js +++ b/packages/metro-runtime/src/polyfills/__tests__/require-test.js @@ -478,6 +478,13 @@ describe('require', () => { expect(fn.mock.calls.length).toBe(1); }); + it('throws when using require.context directly', () => { + createModuleSystem(moduleSystem, false, ''); + expect(() => moduleSystem.__r.context('foobar')).toThrow( + 'The experimental Metro feature `require.context` is not enabled in your project.', + ); + }); + it('throws an error when trying to require an unknown module', () => { createModuleSystem(moduleSystem, false, ''); diff --git a/packages/metro-runtime/src/polyfills/require.js b/packages/metro-runtime/src/polyfills/require.js index 47338b0d5f..a842856889 100644 --- a/packages/metro-runtime/src/polyfills/require.js +++ b/packages/metro-runtime/src/polyfills/require.js @@ -278,16 +278,19 @@ function metroImportAll(moduleId: ModuleID | VerboseModuleNameForDev | number) { } metroRequire.importAll = metroImportAll; -if (__DEV__) { - // The `require.context()` syntax is never executed in the runtime because it is converted - // to `require()` in `metro/src/ModuleGraph/worker/collectDependencies.js` after collecting - // dependencies. If the feature flag is not enabled then the conversion never takes place and this error is thrown (development only). - metroRequire.context = () => { +// The `require.context()` syntax is never executed in the runtime because it is converted +// to `require()` in `metro/src/ModuleGraph/worker/collectDependencies.js` after collecting +// dependencies. If the feature flag is not enabled then the conversion never takes place and this error is thrown (development only). +metroRequire.context = function fallbackRequireContext() { + if (__DEV__) { throw new Error( - `The experimental Metro feature \`require.context\` is not enabled in your project.\nThis can be enabled by setting the \`transformer.unstable_allowRequireContext\` property to \`true\` in the project \`metro.config.js\`.`, + `The experimental Metro feature \`require.context\` is not enabled in your project.\nThis can be enabled by setting the \`transformer.unstable_allowRequireContext\` property to \`true\` in your Metro configuration.`, ); - }; -} + } + throw new Error( + `The experimental Metro feature \`require.context\` is not enabled in your project.`, + ); +}; let inGuard = false; function guardedLoadModule( diff --git a/packages/metro/src/DeltaBundler/DeltaCalculator.js b/packages/metro/src/DeltaBundler/DeltaCalculator.js index b5f9bc52f6..14ad3e9805 100644 --- a/packages/metro/src/DeltaBundler/DeltaCalculator.js +++ b/packages/metro/src/DeltaBundler/DeltaCalculator.js @@ -13,10 +13,7 @@ import * as path from 'path'; import type {DeltaResult, Graph, Options} from './types.flow'; -import { - removeContextQueryParam, - fileMatchesContext, -} from '../lib/contextModule'; +import {ensureRequireContext, fileMatchesContext} from '../lib/contextModule'; const { createGraph, @@ -253,27 +250,25 @@ class DeltaCalculator extends EventEmitter { // to enable users to opt out. if (this._options.unstable_allowRequireContext) { const checkModifiedContextDependencies = (filePath: string) => { - this._graph.dependencies.forEach(value => { + this._graph.dependencies.forEach(dependency => { + const {contextParams} = dependency; if ( - value.contextParams && - !modifiedDependencies.includes(value.path) && - fileMatchesContext( - removeContextQueryParam(value.path), - filePath, - value.contextParams, - ) + contextParams && + contextParams.from != null && + !modifiedDependencies.includes(dependency.path) && + fileMatchesContext(filePath, ensureRequireContext(contextParams)) ) { - modifiedDependencies.push(value.path); + modifiedDependencies.push(dependency.path); } }); }; // Check if any added or removed files are matched in a context module. - Array.from(addedFiles).forEach(filePath => + addedFiles.forEach(filePath => checkModifiedContextDependencies(filePath), ); - Array.from(deletedFiles).forEach(filePath => + deletedFiles.forEach(filePath => checkModifiedContextDependencies(filePath), ); } diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index 970f59994a..dac35fc3a4 100644 --- a/packages/metro/src/DeltaBundler/Transformer.js +++ b/packages/metro/src/DeltaBundler/Transformer.js @@ -12,7 +12,6 @@ import type {TransformResult, TransformResultWithSource} from '../DeltaBundler'; import type {TransformerConfig, TransformOptions} from './Worker'; -import {toRequireContext} from '../lib/contextModule'; import crypto from 'crypto'; import type {ConfigT} from 'metro-config/src/configTypes.flow'; @@ -142,15 +141,6 @@ class Transformer { fileBuffer, ); - data.result.dependencies.forEach(dependency => { - if (dependency.data.contextParams) { - // Convert JSON regular expression into RegExp. - dependency.data.contextParams = toRequireContext( - dependency.data.contextParams, - ); - } - }); - // Only re-compute the full key if the SHA-1 changed. This is because // references are used by the cache implementation in a weak map to keep // track of the cache that returned the result. diff --git a/packages/metro/src/DeltaBundler/Worker.flow.js b/packages/metro/src/DeltaBundler/Worker.flow.js index 633fe38deb..9c80b7e35d 100644 --- a/packages/metro/src/DeltaBundler/Worker.flow.js +++ b/packages/metro/src/DeltaBundler/Worker.flow.js @@ -51,6 +51,23 @@ type Data = $ReadOnly<{ transformFileEndLogEntry: LogEntry, }>; +/** + * When the `Buffer` is sent over the worker thread it gets serialized into a JSON object. + * This helper method will deserialize it if needed. + * + * @returns `Buffer` representation of the JSON object. + * @returns `null` if the given object is nullish or not a serialized `Buffer` object. + */ +function asDeserializedBuffer(value: any): Buffer | null { + if (Buffer.isBuffer(value)) { + return value; + } + if (value && value.type === 'Buffer') { + return Buffer.from(value.data); + } + return null; +} + async function transform( filename: string, transformOptions: JsTransformOptions, @@ -60,8 +77,9 @@ async function transform( ): Promise { let data; - if (fileBuffer && fileBuffer.type === 'Buffer') { - data = Buffer.from(fileBuffer.data); + const fileBufferObject = asDeserializedBuffer(fileBuffer); + if (fileBufferObject) { + data = fileBufferObject; } else { data = fs.readFileSync(path.resolve(projectRoot, filename)); } diff --git a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js index 5f50570fda..5e83441f34 100644 --- a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js @@ -36,6 +36,7 @@ describe('DeltaCalculator', () => { let fileWatcher; const options = { + unstable_allowRequireContext: true, experimentalImportBundleSupport: false, onProgress: null, resolve: (from: string, to: string) => { diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index 3bc7899fb6..d06f705fef 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -30,6 +30,7 @@ 'use strict'; +import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; import type { Dependency, Graph, @@ -43,7 +44,7 @@ import type { import CountingSet from '../lib/CountingSet'; import { appendContextQueryParam, - removeContextQueryParam, + ensureRequireContext, } from '../lib/contextModule'; import * as path from 'path'; @@ -121,13 +122,11 @@ type InternalOptions = $ReadOnly<{ onDependencyAdded: () => mixed, resolve: Options['resolve'], transform: Options['transform'], - transformContext: Options['transformContext'], shallow: boolean, }>; function getInternalOptions({ transform, - transformContext, resolve, onProgress, experimentalImportBundleSupport, @@ -139,7 +138,6 @@ function getInternalOptions({ return { experimentalImportBundleSupport, transform, - transformContext, resolve, onDependencyAdd: () => onProgress && onProgress(numProcessed, ++total), onDependencyAdded: () => onProgress && onProgress(++numProcessed, total), @@ -264,13 +262,7 @@ async function traverseDependenciesForSingleFile( ): Promise { options.onDependencyAdd(); - await processModule( - path, - graph, - delta, - options, - graph.dependencies.get(path)?.contextParams, - ); + await processModule(path, graph, delta, options); options.onDependencyAdded(); } @@ -280,17 +272,20 @@ async function processModule( graph: Graph, delta: Delta, options: InternalOptions, - contextParams?: RequireContext, + // This fallback is used when a new dependency is added after the initial bundle has been created + // the invocation comes from `traverseDependenciesForSingleFile`. + contextParams: + | ?RequireContext + | RequireContextParams = graph.dependencies.get(path)?.contextParams, ): Promise> { - const resolvedContextParams = - contextParams || graph.dependencies.get(path)?.contextParams; - // Transform the file via the given option. // TODO: Unbind the transform method from options let result; - if (resolvedContextParams) { - const modulePath = removeContextQueryParam(path); - result = await options.transformContext(modulePath, resolvedContextParams); + if (contextParams != null) { + result = await options.transform( + nullthrows(contextParams.from), + ensureRequireContext(contextParams), + ); } else { result = await options.transform(path); } @@ -313,7 +308,7 @@ async function processModule( // Update the module information. const module = { ...previousModule, - contextParams: resolvedContextParams, + contextParams: contextParams, dependencies: new Map(previousDependencies), getSource: result.getSource, output: result.output, @@ -489,14 +484,14 @@ function resolveDependencies( let resolvedDep; // `require.context` - if (dep.data.contextParams) { - let absolutePath = path.join(parentPath, '..', dep.name); + const {contextParams} = dep.data; + if (contextParams) { + contextParams.from = path.join(parentPath, '..', dep.name); // Ensure the filepath has uniqueness applied to ensure multiple `require.context` // statements can be used to target the same file with different properties. - absolutePath = appendContextQueryParam( - absolutePath, - dep.data.contextParams, + const absolutePath = appendContextQueryParam( + ensureRequireContext(contextParams), ); resolvedDep = { diff --git a/packages/metro/src/DeltaBundler/types.flow.js b/packages/metro/src/DeltaBundler/types.flow.js index 405f6cc85a..0518804bc5 100644 --- a/packages/metro/src/DeltaBundler/types.flow.js +++ b/packages/metro/src/DeltaBundler/types.flow.js @@ -20,6 +20,8 @@ import type {JsTransformOptions} from 'metro-transform-worker'; import CountingSet from '../lib/CountingSet'; export type RequireContext = { + /** Absolute file path pointing to the root directory of the context. */ + from?: string, /* Should search for files recursively. Optional, default `true` when `require.context` is used */ recursive: boolean, /* Filename filter pattern for use in `require.context`. Optional, default `.*` (any file) when `require.context` is used */ @@ -120,15 +122,10 @@ export type TransformResultWithSource = $ReadOnly<{ getSource: () => Buffer, }>; -/** Transformer for generating `require.context` virtual module. */ -export type TransformContextFn = ( +export type TransformFn = ( string, - RequireContext, + ?RequireContext, ) => Promise>; - -export type TransformFn = string => Promise< - TransformResultWithSource, ->; export type AllowOptionalDependenciesWithOptions = { +exclude: Array, }; @@ -139,8 +136,6 @@ export type AllowOptionalDependencies = export type Options = { +resolve: (from: string, to: string, context?: ?RequireContext) => string, +transform: TransformFn, - /** Given a path and require context, return a virtual context module. */ - +transformContext: TransformContextFn, +transformOptions: TransformInputOptions, +onProgress: ?(numProcessed: number, total: number) => mixed, +experimentalImportBundleSupport: boolean, diff --git a/packages/metro/src/IncrementalBundler.js b/packages/metro/src/IncrementalBundler.js index 240e3eea64..0740e2fe18 100644 --- a/packages/metro/src/IncrementalBundler.js +++ b/packages/metro/src/IncrementalBundler.js @@ -112,13 +112,6 @@ class IncrementalBundler { this._bundler, transformOptions.platform, ), - transformContext: await transformHelpers.getTransformContextFn( - absoluteEntryFiles, - this._bundler, - this._deltaBundler, - this._config, - transformOptions, - ), transform: await transformHelpers.getTransformFn( absoluteEntryFiles, this._bundler, @@ -162,13 +155,6 @@ class IncrementalBundler { this._bundler, transformOptions.platform, ), - transformContext: await transformHelpers.getTransformContextFn( - absoluteEntryFiles, - this._bundler, - this._deltaBundler, - this._config, - transformOptions, - ), transform: await transformHelpers.getTransformFn( absoluteEntryFiles, this._bundler, diff --git a/packages/metro/src/ModuleGraph/worker/collectDependencies.js b/packages/metro/src/ModuleGraph/worker/collectDependencies.js index c9b007916a..056f2e9a31 100644 --- a/packages/metro/src/ModuleGraph/worker/collectDependencies.js +++ b/packages/metro/src/ModuleGraph/worker/collectDependencies.js @@ -43,14 +43,15 @@ export type ContextMode = 'sync' | 'eager' | 'lazy' | 'lazy-once'; type ContextFilter = {pattern: string, flags: string}; -export type RequireContextParams = $ReadOnly<{ +export type RequireContextParams = { + from?: string, /* Should search for files recursively. Optional, default `true` when `require.context` is used */ recursive: boolean, /* Filename filter pattern for use in `require.context`. Optional, default `.*` (any file) when `require.context` is used */ filter: $ReadOnly, /** Mode for resolving dynamic dependencies. Defaults to `sync` */ mode: ContextMode, -}>; +}; type DependencyData = $ReadOnly<{ // A locally unique key for this dependency within the current module. diff --git a/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap b/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap new file mode 100644 index 0000000000..d23cfbe7e6 --- /dev/null +++ b/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap @@ -0,0 +1,121 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getContextModuleTemplate creates a lazy template 1`] = ` +"// All of the requested modules are loaded behind enumerable getters. +const map = Object.defineProperties({}, {\\"./foo.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/foo.js\\"); } },}); + +function metroContext(request) { + return map[request]; +} + +// Return the keys that can be resolved. +metroContext.keys = function metroContextKeys() { + return Object.keys(map); +}; + +// Return the module identifier for a user request. +metroContext.resolve = function metroContextResolve(request) { + throw new Error('Unimplemented Metro module context functionality'); +} + +// Readable identifier for the context module. +metroContext.id = \\"/path/to/project/src lazy /(?:)/\\"; + +module.exports = metroContext;" +`; + +exports[`getContextModuleTemplate creates a lazy-once template 1`] = ` +"// All of the requested modules are loaded behind enumerable getters. +const map = Object.defineProperties({}, {\\"./foo.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/foo.js\\"); } },\\"./another/bar.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/another/bar.js\\"); } },}); + +function metroContext(request) { + return map[request]; +} + +// Return the keys that can be resolved. +metroContext.keys = function metroContextKeys() { + return Object.keys(map); +}; + +// Return the module identifier for a user request. +metroContext.resolve = function metroContextResolve(request) { + throw new Error('Unimplemented Metro module context functionality'); +} + +// Readable identifier for the context module. +metroContext.id = \\"/path/to/project/src lazy recursive /(?:)/\\"; + +module.exports = metroContext;" +`; + +exports[`getContextModuleTemplate creates a sync template 1`] = ` +"// All of the requested modules are loaded behind enumerable getters. +const map = Object.defineProperties({}, {\\"./foo.js\\": { enumerable: true, get() { return require(\\"/path/to/project/src/foo.js\\"); } },}); + +function metroContext(request) { + return map[request]; +} + +// Return the keys that can be resolved. +metroContext.keys = function metroContextKeys() { + return Object.keys(map); +}; + +// Return the module identifier for a user request. +metroContext.resolve = function metroContextResolve(request) { + throw new Error('Unimplemented Metro module context functionality'); +} + +// Readable identifier for the context module. +metroContext.id = \\"/path/to/project/src sync recursive /(?:)/\\"; + +module.exports = metroContext;" +`; + +exports[`getContextModuleTemplate creates an eager template 1`] = ` +"// All of the requested modules are loaded behind enumerable getters. +const map = Object.defineProperties({}, {\\"./foo.js\\": { enumerable: true, get() { return require(\\"/path/to/project/src/foo.js\\"); } },}); + +function metroContext(request) { + // Here Promise.resolve().then() is used instead of new Promise() to prevent + // uncaught exception popping up in devtools + return Promise.resolve().then(() => map[request]); +} + +// Return the keys that can be resolved. +metroContext.keys = function metroContextKeys() { + return Object.keys(map); +}; + +// Return the module identifier for a user request. +metroContext.resolve = function metroContextResolve(request) { + throw new Error('Unimplemented Metro module context functionality'); +} + +// Readable identifier for the context module. +metroContext.id = \\"/path/to/project/src eager /(?:)/\\"; + +module.exports = metroContext;" +`; + +exports[`getContextModuleTemplate creates an empty template 1`] = ` +" +function metroEmptyContext(request) { + let e = new Error(\\"No modules for context '\\" + \\"/path/to/project/src sync recursive /(?:)/\\" + \\"'\\"); + e.code = 'MODULE_NOT_FOUND'; + throw e; +} + +// Return the keys that can be resolved. +metroEmptyContext.keys = () => ([]); + +// Return the module identifier for a user request. +metroEmptyContext.resolve = function metroContextResolve(request) { + throw new Error('Unimplemented Metro module context functionality'); +} + +// Readable identifier for the context module. +metroEmptyContext.id = \\"/path/to/project/src sync recursive /(?:)/\\"; + +module.exports = metroEmptyContext;" +`; diff --git a/packages/metro/src/lib/__tests__/contextModule-test.js b/packages/metro/src/lib/__tests__/contextModule-test.js new file mode 100644 index 0000000000..341a4d7a49 --- /dev/null +++ b/packages/metro/src/lib/__tests__/contextModule-test.js @@ -0,0 +1,54 @@ +/** + * 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 + */ + +import { + fileMatchesContext, + appendContextQueryParam, + getContextModuleId, + ensureRequireContext, +} from '../contextModule'; + +describe('getContextModuleId', () => { + it(`creates a context module ID`, () => { + for (const [ctx, results] of [ + [ + {filter: /[a-zA-Z]+/, mode: 'eager', recursive: true}, + '/path/to eager recursive /[a-zA-Z]+/', + ], + [{filter: /.*/, mode: 'lazy', recursive: false}, '/path/to lazy /.*/'], + ]) + expect(getContextModuleId('/path/to', ctx)).toBe(results); + }); +}); + +describe('appendContextQueryParam', () => { + it(`appends a context query parameter to the input path`, () => { + expect( + appendContextQueryParam({ + from: '/path/to/project', + filter: /[a-zA-Z]+/, + mode: 'eager', + recursive: true, + }), + ).toBe('/path/to/project?ctx=7d330128a8fe64375c6932e9204a6a5f40087f99'); + }); +}); + +describe('fileMatchesContext', () => { + it(`matches files`, () => { + expect( + fileMatchesContext('/path/to/project/index.js', { + inputPath: '/path/to/project', + filter: /.*/, + recursive: true, + }), + ).toBe(true); + }); +}); diff --git a/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js b/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js new file mode 100644 index 0000000000..068b56bdbf --- /dev/null +++ b/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js @@ -0,0 +1,62 @@ +/** + * 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 + */ + +import {getContextModuleTemplate} from '../contextModuleTemplates'; + +describe('getContextModuleTemplate', () => { + it(`creates a sync template`, () => { + const template = getContextModuleTemplate( + 'sync', + '/path/to/project/src', + ['/path/to/project/src/foo.js'], + '/path/to/project/src sync recursive /(?:)/', + ); + expect(template).toMatch(/foo\.js/); + expect(template).toMatchSnapshot(); + }); + it(`creates an empty template`, () => { + const template = getContextModuleTemplate( + 'sync', + '/path/to/project/src', + [], + '/path/to/project/src sync recursive /(?:)/', + ); + expect(template).toMatch(/MODULE_NOT_FOUND/); + expect(template).toMatchSnapshot(); + }); + it(`creates an eager template`, () => { + const template = getContextModuleTemplate( + 'eager', + '/path/to/project/src', + ['/path/to/project/src/foo.js'], + '/path/to/project/src eager /(?:)/', + ); + expect(template).toMatchSnapshot(); + }); + it(`creates a lazy template`, () => { + const template = getContextModuleTemplate( + 'lazy', + '/path/to/project/src', + ['/path/to/project/src/foo.js'], + '/path/to/project/src lazy /(?:)/', + ); + expect(template).toMatchSnapshot(); + }); + it(`creates a lazy-once template`, () => { + const template = getContextModuleTemplate( + 'lazy-once', + '/path/to/project/src', + ['/path/to/project/src/foo.js', '/path/to/project/src/another/bar.js'], + '/path/to/project/src lazy recursive /(?:)/', + ); + + expect(template).toMatchSnapshot(); + }); +}); diff --git a/packages/metro/src/lib/__tests__/getGraphId-test.js b/packages/metro/src/lib/__tests__/getGraphId-test.js index 06d18f01a5..91a89f01cd 100644 --- a/packages/metro/src/lib/__tests__/getGraphId-test.js +++ b/packages/metro/src/lib/__tests__/getGraphId-test.js @@ -27,7 +27,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: null, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ).not.toBe( getGraphId( @@ -41,7 +45,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: null, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ); }); @@ -59,7 +67,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: null, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ).not.toBe( getGraphId( @@ -73,7 +85,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: null, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ); }); @@ -91,7 +107,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: null, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ).toBe( getGraphId( @@ -105,7 +125,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: null, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ); }); @@ -127,7 +151,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: null, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ).toBe( getGraphId( @@ -145,7 +173,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: null, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ); }); @@ -165,7 +197,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: null, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ).toBe( getGraphId( @@ -179,7 +215,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: null, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ); }); @@ -197,7 +237,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: 48, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ).not.toBe( getGraphId( @@ -211,7 +255,11 @@ describe('getGraphId', () => { runtimeBytecodeVersion: null, unstable_transformProfile: 'default', }, - {shallow: false, experimentalImportBundleSupport: false}, + { + shallow: false, + experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, + }, ), ); }); diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js index 0a51ff4682..032f179c82 100644 --- a/packages/metro/src/lib/contextModule.js +++ b/packages/metro/src/lib/contextModule.js @@ -1,32 +1,45 @@ +/** + * 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 + */ + import crypto from 'crypto'; import path from 'path'; -import type { - RequireContextParams, -} from '../ModuleGraph/worker/collectDependencies'; -import type { - RequireContext, -} from '../DeltaBundler/types.flow'; - +import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; +import type {RequireContext} from '../DeltaBundler/types.flow'; +import nullthrows from 'nullthrows'; -/** Convert a JSON context into an object. */ -export function toRequireContext(context: RequireContextParams): string { +export function ensureRequireContext( + context: RequireContextParams | RequireContext, +): RequireContext { return { ...context, - filter: new RegExp(context.filter.pattern, context.filter.flags) - } + filter: + context.filter instanceof RegExp + ? context.filter + : new RegExp(context.filter.pattern, context.filter.flags), + }; } /** Get an ID for a context module. */ -export function getContextModuleId(modulePath: string, context: RequireContext): string { - // Similar to other `require.context` implementations. - return [ - modulePath, - context.mode, - context.recursive ? 'recursive' : '', - context.filter.toString(), - ] - .filter(Boolean) - .join(' '); +export function getContextModuleId( + modulePath: string, + context: RequireContext, +): string { + // Similar to other `require.context` implementations. + return [ + modulePath, + context.mode, + context.recursive ? 'recursive' : '', + context.filter.toString(), + ] + .filter(Boolean) + .join(' '); } function toHash(value: string): string { @@ -34,52 +47,48 @@ function toHash(value: string): string { return crypto.createHash('sha1').update(value).digest('hex'); } -/** Given a virtualized path, strip the virtual component and return a path that could be real. */ -export function removeContextQueryParam(virtualFilePath: string): string { - const [filepath] = virtualFilePath.split('?ctx='); - return filepath; -} - -/** Given a path and a require context, return a virtual file path that ensures uniqueness between paths with different contexts. */ -export function appendContextQueryParam(filePath: string, context: RequireContext): string { +/** Given a fully qualified require context, return a virtual file path that ensures uniqueness between paths with different contexts. */ +export function appendContextQueryParam(context: RequireContext): string { // Drop the trailing slash, require.context should always be matched against a folder // and we want to normalize the folder name as much as possible to prevent duplicates. // This also makes the files show up in the correct location when debugging in Chrome. - filePath = filePath.endsWith('/') ? filePath.slice(0, -1) : filePath; + const from = nullthrows(context.from); + const filePath = from.endsWith(path.sep) ? from.slice(0, -1) : from; return filePath + '?ctx=' + toHash(getContextModuleId(filePath, context)); } /** Match a file against a require context. */ export function fileMatchesContext( - inputPath: string, - testPath: string, - context: $ReadOnly<{ - /* Should search for files recursively. */ - recursive: boolean, - /* Filter relative paths against a pattern. */ - filter: RegExp, - }>, -) { - // NOTE(EvanBacon): Ensure this logic is synchronized with the similar - // functionality in `metro-file-map/src/HasteFS.js` (`matchFilesWithContext()`) + testPath: string, + context: $ReadOnly<{ + from?: string, + /* Should search for files recursively. */ + recursive: boolean, + /* Filter relative paths against a pattern. */ + filter: RegExp, + ... + }>, +): boolean { + // NOTE(EvanBacon): Ensure this logic is synchronized with the similar + // functionality in `metro-file-map/src/HasteFS.js` (`matchFilesWithContext()`) - const filePath = path.relative(inputPath, testPath); + const filePath = path.relative(nullthrows(context.from), testPath); - if ( - // Ignore everything outside of the provided `root`. - !(filePath && !filePath.startsWith('..') && !path.isAbsolute(filePath)) || - // Prevent searching in child directories during a non-recursive search. - (!context.recursive && filePath.includes(path.sep)) || - // Test against the filter. - !context.filter.test( - // NOTE(EvanBacon): Ensure files start with `./` for matching purposes - // this ensures packages work across Metro and Webpack (ex: Storybook for React DOM / React Native). - // `a/b.js` -> `./a/b.js` - '.' + path.sep + filePath, - ) - ) { - return false; - } + if ( + // Ignore everything outside of the provided `root`. + !(filePath && !filePath.startsWith('..') && !path.isAbsolute(filePath)) || + // Prevent searching in child directories during a non-recursive search. + (!context.recursive && filePath.includes(path.sep)) || + // Test against the filter. + !context.filter.test( + // NOTE(EvanBacon): Ensure files start with `./` for matching purposes + // this ensures packages work across Metro and Webpack (ex: Storybook for React DOM / React Native). + // `a/b.js` -> `./a/b.js` + '.' + path.sep + filePath, + ) + ) { + return false; + } - return true; - } \ No newline at end of file + return true; +} diff --git a/packages/metro/src/lib/contextModuleTemplates.js b/packages/metro/src/lib/contextModuleTemplates.js index a1398e5d8d..75c628e957 100644 --- a/packages/metro/src/lib/contextModuleTemplates.js +++ b/packages/metro/src/lib/contextModuleTemplates.js @@ -1,42 +1,50 @@ +/** + * 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 + */ + import * as path from 'path'; -import type { - ContextMode, -} from '../ModuleGraph/worker/collectDependencies'; +import type {ContextMode} from '../ModuleGraph/worker/collectDependencies'; function createFileMap( - modulePath: string, - files: string[], - processModule: (moduleId: string) => string, -) { - let mapString = ''; - - files.map(file => { - let filePath = path.relative(modulePath, file); - - // NOTE(EvanBacon): I'd prefer we prevent the ability for a module to require itself (`require.context('./')`) - // but Webpack allows this, keeping it here provides better parity between bundlers. - - // Ensure relative file paths start with `./` so they match the - // patterns (filters) used to include them. - if (!filePath.startsWith('.')) { - filePath = `.${path.sep}` + filePath; - } - const key = JSON.stringify(filePath); - // NOTE(EvanBacon): Webpack uses `require.resolve` in order to load modules on demand, - // Metro doesn't have this functionality so it will use getters instead. Modules need to - // be loaded on demand because if we imported directly then users would get errors from importing - // a file without exports as soon as they create a new file and the context module is updated. - - // NOTE: The values are set to `enumerable` so the `context.keys()` method works as expected. - mapString += `${key}: { enumerable: true, get() { return ${processModule( - file, - )}; } },`; - }); - return `Object.defineProperties({}, {${mapString}})`; + modulePath: string, + files: string[], + processModule: (moduleId: string) => string, +): string { + let mapString = ''; + + files.map(file => { + let filePath = path.relative(modulePath, file); + + // NOTE(EvanBacon): I'd prefer we prevent the ability for a module to require itself (`require.context('./')`) + // but Webpack allows this, keeping it here provides better parity between bundlers. + + // Ensure relative file paths start with `./` so they match the + // patterns (filters) used to include them. + if (!filePath.startsWith('.')) { + filePath = `.${path.sep}` + filePath; + } + const key = JSON.stringify(filePath); + // NOTE(EvanBacon): Webpack uses `require.resolve` in order to load modules on demand, + // Metro doesn't have this functionality so it will use getters instead. Modules need to + // be loaded on demand because if we imported directly then users would get errors from importing + // a file without exports as soon as they create a new file and the context module is updated. + + // NOTE: The values are set to `enumerable` so the `context.keys()` method works as expected. + mapString += `${key}: { enumerable: true, get() { return ${processModule( + file, + )}; } },`; + }); + return `Object.defineProperties({}, {${mapString}})`; } function getEmptyContextModuleTemplate(modulePath: string, id: string): string { -return ` + return ` function metroEmptyContext(request) { let e = new Error("No modules for context '" + ${JSON.stringify(id)} + "'"); e.code = 'MODULE_NOT_FOUND'; @@ -64,12 +72,12 @@ function getLoadableContextModuleTemplate( importSyntax: string, getContextTemplate: string, ): string { - return `// All of the requested modules are loaded behind enumerable getters. + return `// All of the requested modules are loaded behind enumerable getters. const map = ${createFileMap( - modulePath, - files, - moduleId => `${importSyntax}(${JSON.stringify(moduleId)})`, -)}; + modulePath, + files, + moduleId => `${importSyntax}(${JSON.stringify(moduleId)})`, + )}; function metroContext(request) { ${getContextTemplate} @@ -91,48 +99,58 @@ metroContext.id = ${JSON.stringify(id)}; module.exports = metroContext;`; } +/** + * Generate a context module as a virtual file string. + * + * @prop {ContextMode} mode indicates how the modules should be loaded. + * @prop {string} modulePath virtual file path for the virtual module. Example: `require.context('./src')` -> `'/path/to/project/src'`. + * @prop {string[]} files list of absolute file paths that must be exported from the context module. Example: `['/path/to/project/src/index.js']`. + * @prop {string} id virtual ID representing the context module. Example: `'/path/to/project/src sync recursive /(?:)/'` + * + * @returns a string representing a context module (virtual file contents). + */ export function getContextModuleTemplate( - mode: ContextMode, - modulePath: string, - files: string[], - id: string, + mode: ContextMode, + modulePath: string, + files: string[], + id: string, ): string { - if (!files.length) { - return getEmptyContextModuleTemplate(modulePath, id); - } - switch (mode) { - case 'eager': - return getLoadableContextModuleTemplate( - modulePath, - files, - id, - // NOTE(EvanBacon): It's unclear if we should use `import` or `require` here so sticking - // with the more stable option (`require`) for now. - 'require', - [ - ' // Here Promise.resolve().then() is used instead of new Promise() to prevent', - ' // uncaught exception popping up in devtools', - ' return Promise.resolve().then(() => map[request]);', - ].join('\n'), - ); - case 'sync': - return getLoadableContextModuleTemplate( - modulePath, - files, - id, - 'require', - ' return map[request];', - ); - case 'lazy': - case 'lazy-once': - return getLoadableContextModuleTemplate( - modulePath, - files, - id, - 'import', - ' return map[request];', - ); - default: - throw new Error(`Metro context mode "${mode}" is unimplemented`); - } + if (!files.length) { + return getEmptyContextModuleTemplate(modulePath, id); + } + switch (mode) { + case 'eager': + return getLoadableContextModuleTemplate( + modulePath, + files, + id, + // NOTE(EvanBacon): It's unclear if we should use `import` or `require` here so sticking + // with the more stable option (`require`) for now. + 'require', + [ + ' // Here Promise.resolve().then() is used instead of new Promise() to prevent', + ' // uncaught exception popping up in devtools', + ' return Promise.resolve().then(() => map[request]);', + ].join('\n'), + ); + case 'sync': + return getLoadableContextModuleTemplate( + modulePath, + files, + id, + 'require', + ' return map[request];', + ); + case 'lazy': + case 'lazy-once': + return getLoadableContextModuleTemplate( + modulePath, + files, + id, + 'import', + ' return map[request];', + ); + default: + throw new Error(`Metro context mode "${mode}" is unimplemented`); + } } diff --git a/packages/metro/src/lib/getGraphId.js b/packages/metro/src/lib/getGraphId.js index 61910b1e58..4c457d234c 100644 --- a/packages/metro/src/lib/getGraphId.js +++ b/packages/metro/src/lib/getGraphId.js @@ -43,7 +43,6 @@ function getGraphId( hot: options.hot, minify: options.minify, unstable_disableES6Transforms: options.unstable_disableES6Transforms, - requireContext: options.requireContext, platform: options.platform != null ? options.platform : null, runtimeBytecodeVersion: options.runtimeBytecodeVersion, type: options.type, diff --git a/packages/metro/src/lib/getPrependedScripts.js b/packages/metro/src/lib/getPrependedScripts.js index fbe40a8660..16b61830c9 100644 --- a/packages/metro/src/lib/getPrependedScripts.js +++ b/packages/metro/src/lib/getPrependedScripts.js @@ -59,6 +59,8 @@ async function getPrependedScripts( config, transformOptions, ), + unstable_allowRequireContext: + config.transformer.unstable_allowRequireContext, transformOptions, onProgress: null, experimentalImportBundleSupport: diff --git a/packages/metro/src/lib/transformHelpers.js b/packages/metro/src/lib/transformHelpers.js index d40d1ec328..512f1af5ad 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -13,7 +13,6 @@ import type Bundler from '../Bundler'; import type DeltaBundler, {TransformFn} from '../DeltaBundler'; import type { - TransformContextFn, TransformInputOptions, RequireContext, } from '../DeltaBundler/types.flow'; @@ -23,7 +22,7 @@ import type {ConfigT} from 'metro-config/src/configTypes.flow'; import type {Type} from 'metro-transform-worker'; import {getContextModuleTemplate} from './contextModuleTemplates'; -import {getContextModuleId, fileMatchesContext} from './contextModule'; +import {getContextModuleId} from './contextModule'; const path = require('path'); @@ -68,16 +67,6 @@ async function calcTransformerOptions( const getDependencies = async (path: string) => { const dependencies = await deltaBundler.getDependencies([path], { resolve: await getResolveDependencyFn(bundler, options.platform), - transformContext: await getTransformContextFn( - [path], - bundler, - deltaBundler, - config, - { - ...options, - minify: false, - }, - ), transform: await getTransformFn([path], bundler, deltaBundler, config, { ...options, minify: false, @@ -123,14 +112,13 @@ function removeInlineRequiresBlockListFromOptions( return inlineRequires; } -/** Generate the default method for transforming a `require.context` module. */ -async function getTransformContextFn( +async function getTransformFn( entryFiles: $ReadOnlyArray, bundler: Bundler, deltaBundler: DeltaBundler<>, config: ConfigT, options: TransformInputOptions, -): Promise> { +): Promise> { const {inlineRequires, ...transformOptions} = await calcTransformerOptions( entryFiles, bundler, @@ -139,25 +127,32 @@ async function getTransformContextFn( options, ); - return async (modulePath: string, requireContext: RequireContext) => { - const graph = await bundler.getDependencyGraph(); + return async (modulePath: string, requireContext: ?RequireContext) => { + let templateBuffer: Buffer; - // TODO: Check delta changes to avoid having to look over all files each time - // this is a massive performance boost. + if (requireContext) { + const graph = await bundler.getDependencyGraph(); - // Search against all files, this is very expensive. - // TODO: Maybe we could let the user specify which root to check against. - const files = graph.matchFilesWithContext(modulePath, { - filter: requireContext.filter, - recursive: requireContext.recursive, - }); + // TODO: Check delta changes to avoid having to look over all files each time + // this is a massive performance boost. + + // Search against all files, this is very expensive. + // TODO: Maybe we could let the user specify which root to check against. + const files = graph.matchFilesWithContext(modulePath, { + filter: requireContext.filter, + recursive: requireContext.recursive, + }); + + const template = getContextModuleTemplate( + requireContext.mode, + modulePath, + files, + getContextModuleId(modulePath, requireContext), + ); + + templateBuffer = Buffer.from(template); + } - const template = getContextModuleTemplate( - requireContext.mode, - modulePath, - files, - getContextModuleId(modulePath, requireContext), - ); return await bundler.transformFile( modulePath, { @@ -172,38 +167,11 @@ async function getTransformContextFn( inlineRequires, ), }, - Buffer.from(template), + templateBuffer, ); }; } -async function getTransformFn( - entryFiles: $ReadOnlyArray, - bundler: Bundler, - deltaBundler: DeltaBundler<>, - config: ConfigT, - options: TransformInputOptions, -): Promise> { - const {inlineRequires, ...transformOptions} = await calcTransformerOptions( - entryFiles, - bundler, - deltaBundler, - config, - options, - ); - - return async (path: string) => { - return await bundler.transformFile(path, { - ...transformOptions, - type: getType(transformOptions.type, path, config.resolver.assetExts), - inlineRequires: removeInlineRequiresBlockListFromOptions( - path, - inlineRequires, - ), - }); - }; -} - function getType( type: string, filePath: string, @@ -233,6 +201,5 @@ async function getResolveDependencyFn( module.exports = { getTransformFn, - getTransformContextFn, getResolveDependencyFn, }; From 1b807f3d9c918a68084daf7efc1911bcd248b369 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Tue, 19 Jul 2022 17:05:16 +0200 Subject: [PATCH 08/38] Move buffer sha upstream --- packages/metro/src/DeltaBundler/Transformer.js | 13 +++---------- packages/metro/src/node-haste/DependencyGraph.js | 8 ++++++-- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index dac35fc3a4..9c5f1d8992 100644 --- a/packages/metro/src/DeltaBundler/Transformer.js +++ b/packages/metro/src/DeltaBundler/Transformer.js @@ -12,7 +12,6 @@ import type {TransformResult, TransformResultWithSource} from '../DeltaBundler'; import type {TransformerConfig, TransformOptions} from './Worker'; -import crypto from 'crypto'; import type {ConfigT} from 'metro-config/src/configTypes.flow'; const getTransformCacheKey = require('./getTransformCacheKey'); @@ -26,10 +25,10 @@ class Transformer { _config: ConfigT; _cache: Cache>; _baseHash: string; - _getSha1: string => string; + _getSha1: (string, ?Buffer) => string; _workerFarm: WorkerFarm; - constructor(config: ConfigT, getSha1Fn: string => string) { + constructor(config: ConfigT, getSha1Fn: (string, ?Buffer) => string) { this._config = config; this._config.watchFolders.forEach(verifyRootExists); @@ -121,13 +120,7 @@ class Transformer { unstable_transformProfile, ]); - let sha1: string; - if (fileBuffer) { - sha1 = crypto.createHash('sha1').update(fileBuffer).digest('hex'); - } else { - sha1 = this._getSha1(filePath); - } - + const sha1 = this._getSha1(filePath, fileBuffer); let fullKey = Buffer.concat([partialKey, Buffer.from(sha1, 'hex')]); const result = await cache.get(fullKey); diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index 02732a99a9..8edd3cbe59 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -12,8 +12,8 @@ import type Package from './Package'; import type {ConfigT} from 'metro-config/src/configTypes.flow'; import type MetroFileMap, {HasteFS} from 'metro-file-map'; import type Module from './Module'; - import {ModuleMap as MetroFileMapModuleMap} from 'metro-file-map'; +import crypto from 'crypto'; const createHasteMap = require('./DependencyGraph/createHasteMap'); const {ModuleResolver} = require('./DependencyGraph/ModuleResolution'); @@ -186,7 +186,11 @@ class DependencyGraph extends EventEmitter { }); } - getSha1(filename: string): string { + getSha1(filename: string, contents: ?Buffer): string { + // Shortcut for virtual modules which provide the contents with the filename. + if (contents) { + return crypto.createHash('sha1').update(contents).digest('hex'); + } // TODO If it looks like we're trying to get the sha1 from a file located // within a Zip archive, then we instead compute the sha1 for what looks // like the Zip archive itself. From 797a92c15335245ce9e93b55f176a994aee4a37e Mon Sep 17 00:00:00 2001 From: evanbacon Date: Tue, 19 Jul 2022 18:55:29 +0200 Subject: [PATCH 09/38] fix types --- .../metro/src/DeltaBundler/DeltaCalculator.js | 4 +- .../__tests__/DeltaBundler-test.js | 1 + .../__tests__/traverseDependencies-test.js | 3 ++ .../metro/src/DeltaBundler/graphOperations.js | 19 +++------- packages/metro/src/DeltaBundler/types.flow.js | 21 ++++------- .../ModuleGraph/worker/collectDependencies.js | 3 +- .../src/lib/__tests__/contextModule-test.js | 23 +++++++++--- packages/metro/src/lib/contextModule.js | 37 +++++-------------- packages/metro/src/lib/transformHelpers.js | 15 +++++--- 9 files changed, 57 insertions(+), 69 deletions(-) diff --git a/packages/metro/src/DeltaBundler/DeltaCalculator.js b/packages/metro/src/DeltaBundler/DeltaCalculator.js index 14ad3e9805..abaeddfdce 100644 --- a/packages/metro/src/DeltaBundler/DeltaCalculator.js +++ b/packages/metro/src/DeltaBundler/DeltaCalculator.js @@ -13,7 +13,7 @@ import * as path from 'path'; import type {DeltaResult, Graph, Options} from './types.flow'; -import {ensureRequireContext, fileMatchesContext} from '../lib/contextModule'; +import {fileMatchesContext} from '../lib/contextModule'; const { createGraph, @@ -256,7 +256,7 @@ class DeltaCalculator extends EventEmitter { contextParams && contextParams.from != null && !modifiedDependencies.includes(dependency.path) && - fileMatchesContext(filePath, ensureRequireContext(contextParams)) + fileMatchesContext(filePath, contextParams) ) { modifiedDependencies.push(dependency.path); } diff --git a/packages/metro/src/DeltaBundler/__tests__/DeltaBundler-test.js b/packages/metro/src/DeltaBundler/__tests__/DeltaBundler-test.js index 165a0ffeec..d64b3d4f2e 100644 --- a/packages/metro/src/DeltaBundler/__tests__/DeltaBundler-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/DeltaBundler-test.js @@ -29,6 +29,7 @@ describe('DeltaBundler', () => { }; const options = { + unstable_allowRequireContext: false, experimentalImportBundleSupport: false, onProgress: null, resolve: (from: string, to: string) => { diff --git a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js index 661793ed51..cca2db9a99 100644 --- a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js @@ -203,6 +203,7 @@ function computeInverseDependencies( +shallow: boolean, +transform: TransformFn<>, +transformOptions: TransformInputOptions, + +unstable_allowRequireContext: boolean, }, ) { const allInverseDependencies = new Map(); @@ -244,6 +245,7 @@ async function traverseDependencies( +shallow: boolean, +transform: TransformFn<>, +transformOptions: TransformInputOptions, + +unstable_allowRequireContext: boolean, }, ) { // Get a snapshot of the graph before the traversal. @@ -305,6 +307,7 @@ beforeEach(async () => { }); options = { + unstable_allowRequireContext: false, experimentalImportBundleSupport: false, onProgress: null, resolve: (from: string, to: string) => { diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index d06f705fef..d4987c2b34 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -38,14 +38,10 @@ import type { Module, Options, TransformResultDependency, - RequireContext, } from './types.flow'; import CountingSet from '../lib/CountingSet'; -import { - appendContextQueryParam, - ensureRequireContext, -} from '../lib/contextModule'; +import {appendContextQueryParam} from '../lib/contextModule'; import * as path from 'path'; const invariant = require('invariant'); @@ -274,9 +270,8 @@ async function processModule( options: InternalOptions, // This fallback is used when a new dependency is added after the initial bundle has been created // the invocation comes from `traverseDependenciesForSingleFile`. - contextParams: - | ?RequireContext - | RequireContextParams = graph.dependencies.get(path)?.contextParams, + contextParams: ?RequireContextParams = graph.dependencies.get(path) + ?.contextParams, ): Promise> { // Transform the file via the given option. // TODO: Unbind the transform method from options @@ -284,7 +279,7 @@ async function processModule( if (contextParams != null) { result = await options.transform( nullthrows(contextParams.from), - ensureRequireContext(contextParams), + contextParams, ); } else { result = await options.transform(path); @@ -308,7 +303,7 @@ async function processModule( // Update the module information. const module = { ...previousModule, - contextParams: contextParams, + contextParams: contextParams ?? undefined, dependencies: new Map(previousDependencies), getSource: result.getSource, output: result.output, @@ -490,9 +485,7 @@ function resolveDependencies( // Ensure the filepath has uniqueness applied to ensure multiple `require.context` // statements can be used to target the same file with different properties. - const absolutePath = appendContextQueryParam( - ensureRequireContext(contextParams), - ); + const absolutePath = appendContextQueryParam(contextParams); resolvedDep = { absolutePath, diff --git a/packages/metro/src/DeltaBundler/types.flow.js b/packages/metro/src/DeltaBundler/types.flow.js index 0518804bc5..63e74e34e2 100644 --- a/packages/metro/src/DeltaBundler/types.flow.js +++ b/packages/metro/src/DeltaBundler/types.flow.js @@ -19,17 +19,6 @@ import type {JsTransformOptions} from 'metro-transform-worker'; import CountingSet from '../lib/CountingSet'; -export type RequireContext = { - /** Absolute file path pointing to the root directory of the context. */ - from?: string, - /* Should search for files recursively. Optional, default `true` when `require.context` is used */ - recursive: boolean, - /* Filename filter pattern for use in `require.context`. Optional, default `.*` (any file) when `require.context` is used */ - filter: RegExp, - /** Mode for resolving dynamic dependencies. Defaults to `sync` */ - mode: ContextMode, -}; - export type MixedOutput = { +data: mixed, +type: string, @@ -80,7 +69,7 @@ export type Dependency = { }; export type Module = { - +contextParams?: RequireContext, + +contextParams?: RequireContextParams, +dependencies: Map, +inverseDependencies: CountingSet, +output: $ReadOnlyArray, @@ -124,7 +113,7 @@ export type TransformResultWithSource = $ReadOnly<{ export type TransformFn = ( string, - ?RequireContext, + ?RequireContextParams, ) => Promise>; export type AllowOptionalDependenciesWithOptions = { +exclude: Array, @@ -134,7 +123,11 @@ export type AllowOptionalDependencies = | AllowOptionalDependenciesWithOptions; export type Options = { - +resolve: (from: string, to: string, context?: ?RequireContext) => string, + +resolve: ( + from: string, + to: string, + context?: ?RequireContextParams, + ) => string, +transform: TransformFn, +transformOptions: TransformInputOptions, +onProgress: ?(numProcessed: number, total: number) => mixed, diff --git a/packages/metro/src/ModuleGraph/worker/collectDependencies.js b/packages/metro/src/ModuleGraph/worker/collectDependencies.js index 056f2e9a31..715ed086e3 100644 --- a/packages/metro/src/ModuleGraph/worker/collectDependencies.js +++ b/packages/metro/src/ModuleGraph/worker/collectDependencies.js @@ -41,9 +41,10 @@ export type Dependency = $ReadOnly<{ // TODO: Convert to a Flow enum export type ContextMode = 'sync' | 'eager' | 'lazy' | 'lazy-once'; -type ContextFilter = {pattern: string, flags: string}; +export type ContextFilter = {pattern: string, flags: string}; export type RequireContextParams = { + /** Absolute file path pointing to the root directory of the context. */ from?: string, /* Should search for files recursively. Optional, default `true` when `require.context` is used */ recursive: boolean, diff --git a/packages/metro/src/lib/__tests__/contextModule-test.js b/packages/metro/src/lib/__tests__/contextModule-test.js index 341a4d7a49..f4d86825fb 100644 --- a/packages/metro/src/lib/__tests__/contextModule-test.js +++ b/packages/metro/src/lib/__tests__/contextModule-test.js @@ -12,17 +12,27 @@ import { fileMatchesContext, appendContextQueryParam, getContextModuleId, - ensureRequireContext, } from '../contextModule'; describe('getContextModuleId', () => { it(`creates a context module ID`, () => { for (const [ctx, results] of [ [ - {filter: /[a-zA-Z]+/, mode: 'eager', recursive: true}, + { + filter: {pattern: '.*', flags: ''}, + mode: 'eager', + recursive: true, + }, '/path/to eager recursive /[a-zA-Z]+/', ], - [{filter: /.*/, mode: 'lazy', recursive: false}, '/path/to lazy /.*/'], + [ + { + filter: {pattern: '.*', flags: ''}, + mode: 'lazy', + recursive: false, + }, + '/path/to lazy /.*/', + ], ]) expect(getContextModuleId('/path/to', ctx)).toBe(results); }); @@ -33,7 +43,7 @@ describe('appendContextQueryParam', () => { expect( appendContextQueryParam({ from: '/path/to/project', - filter: /[a-zA-Z]+/, + filter: {pattern: '[a-zA-Z]+', flags: ''}, mode: 'eager', recursive: true, }), @@ -45,8 +55,9 @@ describe('fileMatchesContext', () => { it(`matches files`, () => { expect( fileMatchesContext('/path/to/project/index.js', { - inputPath: '/path/to/project', - filter: /.*/, + mode: 'lazy', + from: '/path/to/project', + filter: {pattern: '.*', flags: ''}, recursive: true, }), ).toBe(true); diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js index 032f179c82..bbef2b0385 100644 --- a/packages/metro/src/lib/contextModule.js +++ b/packages/metro/src/lib/contextModule.js @@ -10,33 +10,23 @@ import crypto from 'crypto'; import path from 'path'; -import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; -import type {RequireContext} from '../DeltaBundler/types.flow'; +import type { + ContextFilter, + RequireContextParams, +} from '../ModuleGraph/worker/collectDependencies'; import nullthrows from 'nullthrows'; -export function ensureRequireContext( - context: RequireContextParams | RequireContext, -): RequireContext { - return { - ...context, - filter: - context.filter instanceof RegExp - ? context.filter - : new RegExp(context.filter.pattern, context.filter.flags), - }; -} - /** Get an ID for a context module. */ export function getContextModuleId( modulePath: string, - context: RequireContext, + context: RequireContextParams, ): string { // Similar to other `require.context` implementations. return [ modulePath, context.mode, context.recursive ? 'recursive' : '', - context.filter.toString(), + new RegExp(context.filter.pattern, context.filter.flags).toString(), ] .filter(Boolean) .join(' '); @@ -48,7 +38,7 @@ function toHash(value: string): string { } /** Given a fully qualified require context, return a virtual file path that ensures uniqueness between paths with different contexts. */ -export function appendContextQueryParam(context: RequireContext): string { +export function appendContextQueryParam(context: RequireContextParams): string { // Drop the trailing slash, require.context should always be matched against a folder // and we want to normalize the folder name as much as possible to prevent duplicates. // This also makes the files show up in the correct location when debugging in Chrome. @@ -60,27 +50,20 @@ export function appendContextQueryParam(context: RequireContext): string { /** Match a file against a require context. */ export function fileMatchesContext( testPath: string, - context: $ReadOnly<{ - from?: string, - /* Should search for files recursively. */ - recursive: boolean, - /* Filter relative paths against a pattern. */ - filter: RegExp, - ... - }>, + context: RequireContextParams, ): boolean { // NOTE(EvanBacon): Ensure this logic is synchronized with the similar // functionality in `metro-file-map/src/HasteFS.js` (`matchFilesWithContext()`) const filePath = path.relative(nullthrows(context.from), testPath); - + const filter = new RegExp(context.filter.pattern, context.filter.flags); if ( // Ignore everything outside of the provided `root`. !(filePath && !filePath.startsWith('..') && !path.isAbsolute(filePath)) || // Prevent searching in child directories during a non-recursive search. (!context.recursive && filePath.includes(path.sep)) || // Test against the filter. - !context.filter.test( + !filter.test( // NOTE(EvanBacon): Ensure files start with `./` for matching purposes // this ensures packages work across Metro and Webpack (ex: Storybook for React DOM / React Native). // `a/b.js` -> `./a/b.js` diff --git a/packages/metro/src/lib/transformHelpers.js b/packages/metro/src/lib/transformHelpers.js index 512f1af5ad..736609f026 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -12,11 +12,11 @@ import type Bundler from '../Bundler'; import type DeltaBundler, {TransformFn} from '../DeltaBundler'; +import type {TransformInputOptions} from '../DeltaBundler/types.flow'; import type { - TransformInputOptions, - RequireContext, -} from '../DeltaBundler/types.flow'; -import type {ContextMode} from '../ModuleGraph/worker/collectDependencies'; + ContextMode, + RequireContextParams, +} from '../ModuleGraph/worker/collectDependencies'; import type {TransformOptions} from '../DeltaBundler/Worker'; import type {ConfigT} from 'metro-config/src/configTypes.flow'; import type {Type} from 'metro-transform-worker'; @@ -127,7 +127,7 @@ async function getTransformFn( options, ); - return async (modulePath: string, requireContext: ?RequireContext) => { + return async (modulePath: string, requireContext: ?RequireContextParams) => { let templateBuffer: Buffer; if (requireContext) { @@ -139,7 +139,10 @@ async function getTransformFn( // Search against all files, this is very expensive. // TODO: Maybe we could let the user specify which root to check against. const files = graph.matchFilesWithContext(modulePath, { - filter: requireContext.filter, + filter: new RegExp( + requireContext.filter.pattern, + requireContext.filter.flags, + ), recursive: requireContext.recursive, }); From 6f795ac8f8267505568b26416cfb7d4776f6eb29 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Tue, 19 Jul 2022 18:57:55 +0200 Subject: [PATCH 10/38] fix lint --- packages/metro-runtime/src/polyfills/require.js | 4 ++-- packages/metro-transform-plugins/src/addParamsToDefineCall.js | 2 +- packages/metro/src/shared/output/RamBundle/as-assets.js | 2 +- packages/metro/src/shared/output/RamBundle/as-indexed-file.js | 2 +- packages/metro/src/shared/output/bundle.flow.js | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/metro-runtime/src/polyfills/require.js b/packages/metro-runtime/src/polyfills/require.js index a842856889..57652c8b7f 100644 --- a/packages/metro-runtime/src/polyfills/require.js +++ b/packages/metro-runtime/src/polyfills/require.js @@ -284,11 +284,11 @@ metroRequire.importAll = metroImportAll; metroRequire.context = function fallbackRequireContext() { if (__DEV__) { throw new Error( - `The experimental Metro feature \`require.context\` is not enabled in your project.\nThis can be enabled by setting the \`transformer.unstable_allowRequireContext\` property to \`true\` in your Metro configuration.`, + 'The experimental Metro feature `require.context` is not enabled in your project.\nThis can be enabled by setting the `transformer.unstable_allowRequireContext` property to `true` in your Metro configuration.', ); } throw new Error( - `The experimental Metro feature \`require.context\` is not enabled in your project.`, + 'The experimental Metro feature `require.context` is not enabled in your project.', ); }; diff --git a/packages/metro-transform-plugins/src/addParamsToDefineCall.js b/packages/metro-transform-plugins/src/addParamsToDefineCall.js index b6bbc6fc71..7f6c5adfd6 100644 --- a/packages/metro-transform-plugins/src/addParamsToDefineCall.js +++ b/packages/metro-transform-plugins/src/addParamsToDefineCall.js @@ -22,7 +22,7 @@ function addParamsToDefineCall( ): string { const index = code.lastIndexOf(')'); const params = paramsToAdd.map(param => - param !== undefined ? JSON.stringify(param) : 'undefined', + param != null ? JSON.stringify(param) : 'undefined', ); return code.slice(0, index) + ',' + params.join(',') + code.slice(index); diff --git a/packages/metro/src/shared/output/RamBundle/as-assets.js b/packages/metro/src/shared/output/RamBundle/as-assets.js index c0674e1f38..e0fb90edb3 100644 --- a/packages/metro/src/shared/output/RamBundle/as-assets.js +++ b/packages/metro/src/shared/output/RamBundle/as-assets.js @@ -70,7 +70,7 @@ function saveAsAssets( moduleGroups: null, startupModules: startupModules.concat(), }); - if (sourcemapSourcesRoot !== undefined) { + if (sourcemapSourcesRoot != null) { relativizeSourceMapInline(sourceMap, sourcemapSourcesRoot); } const wroteSourceMap = writeSourceMap( diff --git a/packages/metro/src/shared/output/RamBundle/as-indexed-file.js b/packages/metro/src/shared/output/RamBundle/as-indexed-file.js index acfbd9a743..85c2d0bcad 100644 --- a/packages/metro/src/shared/output/RamBundle/as-indexed-file.js +++ b/packages/metro/src/shared/output/RamBundle/as-indexed-file.js @@ -66,7 +66,7 @@ function saveAsIndexedFile( moduleGroups, fixWrapperOffset: true, }); - if (sourcemapSourcesRoot !== undefined) { + if (sourcemapSourcesRoot != null) { relativizeSourceMapInline(sourceMap, sourcemapSourcesRoot); } diff --git a/packages/metro/src/shared/output/bundle.flow.js b/packages/metro/src/shared/output/bundle.flow.js index da3d448c5b..d482cf927f 100644 --- a/packages/metro/src/shared/output/bundle.flow.js +++ b/packages/metro/src/shared/output/bundle.flow.js @@ -67,7 +67,7 @@ async function saveBundleAndMap( if (sourcemapOutput) { let {map} = bundle; - if (sourcemapSourcesRoot !== undefined) { + if (sourcemapSourcesRoot != null) { log('start relativating source map'); map = relativateSerializedMap(map, sourcemapSourcesRoot); log('finished relativating'); From 0cd6a1f1a9931aac589c8c400576ce95cb2f3e66 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Tue, 19 Jul 2022 22:56:34 +0200 Subject: [PATCH 11/38] normalize matching file patterns --- packages/metro-file-map/src/HasteFS.js | 12 ++++++++++-- packages/metro/src/lib/contextModule.js | 10 +++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/metro-file-map/src/HasteFS.js b/packages/metro-file-map/src/HasteFS.js index 38d9d31325..cc66dbcc7b 100644 --- a/packages/metro-file-map/src/HasteFS.js +++ b/packages/metro-file-map/src/HasteFS.js @@ -100,7 +100,8 @@ export default class HasteFS { }>, ): Array { const files = []; - const prefix = '.' + path.sep; + const prefix = './'; + for (const file of this.getAbsoluteFileIterator()) { const filePath = fastPath.relative(root, file); @@ -121,7 +122,7 @@ export default class HasteFS { // NOTE(EvanBacon): Ensure files start with `./` for matching purposes // this ensures packages work across Metro and Webpack (ex: Storybook for React DOM / React Native). // `a/b.js` -> `./a/b.js` - prefix + filePath, + prefix + normalizeSlashes(filePath), ) ) { files.push(file); @@ -149,3 +150,10 @@ export default class HasteFS { return this._files.get(relativePath); } } + +function normalizeSlashes(path: string): string { + if (/^\\\\\?\\/.test(path) || /[^\u0000-\u0080]+/.test(path)) { + return path; + } + return path.replace(/\\/g, '/'); +} diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js index bbef2b0385..2a16dde0b5 100644 --- a/packages/metro/src/lib/contextModule.js +++ b/packages/metro/src/lib/contextModule.js @@ -67,7 +67,7 @@ export function fileMatchesContext( // NOTE(EvanBacon): Ensure files start with `./` for matching purposes // this ensures packages work across Metro and Webpack (ex: Storybook for React DOM / React Native). // `a/b.js` -> `./a/b.js` - '.' + path.sep + filePath, + './' + normalizeSlashes(filePath), ) ) { return false; @@ -75,3 +75,11 @@ export function fileMatchesContext( return true; } + +/** Convert back slashes (windows) to forward slashes. */ +function normalizeSlashes(path: string): string { + if (/^\\\\\?\\/.test(path) || /[^\u0000-\u0080]+/.test(path)) { + return path; + } + return path.replace(/\\/g, '/'); +} From b8336fd956a7cb8cc42592b54cc944ecceea3214 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Tue, 19 Jul 2022 22:58:48 +0200 Subject: [PATCH 12/38] fix lint --- packages/metro/src/DeltaBundler/DeltaCalculator.js | 1 - packages/metro/src/DeltaBundler/types.flow.js | 5 +---- packages/metro/src/lib/contextModule.js | 7 ++----- packages/metro/src/lib/transformHelpers.js | 5 +---- 4 files changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/metro/src/DeltaBundler/DeltaCalculator.js b/packages/metro/src/DeltaBundler/DeltaCalculator.js index abaeddfdce..055a7854d5 100644 --- a/packages/metro/src/DeltaBundler/DeltaCalculator.js +++ b/packages/metro/src/DeltaBundler/DeltaCalculator.js @@ -10,7 +10,6 @@ 'use strict'; -import * as path from 'path'; import type {DeltaResult, Graph, Options} from './types.flow'; import {fileMatchesContext} from '../lib/contextModule'; diff --git a/packages/metro/src/DeltaBundler/types.flow.js b/packages/metro/src/DeltaBundler/types.flow.js index 63e74e34e2..14ee1b8558 100644 --- a/packages/metro/src/DeltaBundler/types.flow.js +++ b/packages/metro/src/DeltaBundler/types.flow.js @@ -10,10 +10,7 @@ 'use strict'; -import type { - RequireContextParams, - ContextMode, -} from '../ModuleGraph/worker/collectDependencies'; +import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; import type {PrivateState} from './graphOperations'; import type {JsTransformOptions} from 'metro-transform-worker'; diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js index 2a16dde0b5..dec4b72001 100644 --- a/packages/metro/src/lib/contextModule.js +++ b/packages/metro/src/lib/contextModule.js @@ -10,10 +10,7 @@ import crypto from 'crypto'; import path from 'path'; -import type { - ContextFilter, - RequireContextParams, -} from '../ModuleGraph/worker/collectDependencies'; +import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; import nullthrows from 'nullthrows'; /** Get an ID for a context module. */ @@ -59,7 +56,7 @@ export function fileMatchesContext( const filter = new RegExp(context.filter.pattern, context.filter.flags); if ( // Ignore everything outside of the provided `root`. - !(filePath && !filePath.startsWith('..') && !path.isAbsolute(filePath)) || + !(filePath && !filePath.startsWith('..')) || // Prevent searching in child directories during a non-recursive search. (!context.recursive && filePath.includes(path.sep)) || // Test against the filter. diff --git a/packages/metro/src/lib/transformHelpers.js b/packages/metro/src/lib/transformHelpers.js index 736609f026..70c60a3d35 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -13,10 +13,7 @@ import type Bundler from '../Bundler'; import type DeltaBundler, {TransformFn} from '../DeltaBundler'; import type {TransformInputOptions} from '../DeltaBundler/types.flow'; -import type { - ContextMode, - RequireContextParams, -} from '../ModuleGraph/worker/collectDependencies'; +import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; import type {TransformOptions} from '../DeltaBundler/Worker'; import type {ConfigT} from 'metro-config/src/configTypes.flow'; import type {Type} from 'metro-transform-worker'; From 645443b0c9ed4442d8079265e11f3e7ed2f42bcf Mon Sep 17 00:00:00 2001 From: evanbacon Date: Tue, 19 Jul 2022 23:34:45 +0200 Subject: [PATCH 13/38] revert changes --- packages/metro-transform-plugins/src/addParamsToDefineCall.js | 2 +- packages/metro/src/Server/__tests__/Server-test.js | 2 ++ packages/metro/src/lib/__tests__/contextModule-test.js | 2 +- packages/metro/src/shared/output/RamBundle/as-assets.js | 2 +- packages/metro/src/shared/output/RamBundle/as-indexed-file.js | 2 +- packages/metro/src/shared/output/bundle.flow.js | 2 +- 6 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/metro-transform-plugins/src/addParamsToDefineCall.js b/packages/metro-transform-plugins/src/addParamsToDefineCall.js index 7f6c5adfd6..b6bbc6fc71 100644 --- a/packages/metro-transform-plugins/src/addParamsToDefineCall.js +++ b/packages/metro-transform-plugins/src/addParamsToDefineCall.js @@ -22,7 +22,7 @@ function addParamsToDefineCall( ): string { const index = code.lastIndexOf(')'); const params = paramsToAdd.map(param => - param != null ? JSON.stringify(param) : 'undefined', + param !== undefined ? JSON.stringify(param) : 'undefined', ); return code.slice(0, index) + ',' + params.join(',') + code.slice(index); diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 34d44c907f..8cd7d91af0 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -624,6 +624,7 @@ describe('processRequest', () => { type: 'module', unstable_transformProfile: 'default', }, + unstable_allowRequireContext: false, }, ); }); @@ -881,6 +882,7 @@ describe('processRequest', () => { type: 'module', unstable_transformProfile: 'default', }, + unstable_allowRequireContext: false, }, ); }); diff --git a/packages/metro/src/lib/__tests__/contextModule-test.js b/packages/metro/src/lib/__tests__/contextModule-test.js index f4d86825fb..210a3516dd 100644 --- a/packages/metro/src/lib/__tests__/contextModule-test.js +++ b/packages/metro/src/lib/__tests__/contextModule-test.js @@ -23,7 +23,7 @@ describe('getContextModuleId', () => { mode: 'eager', recursive: true, }, - '/path/to eager recursive /[a-zA-Z]+/', + '/path/to eager recursive /.*/', ], [ { diff --git a/packages/metro/src/shared/output/RamBundle/as-assets.js b/packages/metro/src/shared/output/RamBundle/as-assets.js index e0fb90edb3..c0674e1f38 100644 --- a/packages/metro/src/shared/output/RamBundle/as-assets.js +++ b/packages/metro/src/shared/output/RamBundle/as-assets.js @@ -70,7 +70,7 @@ function saveAsAssets( moduleGroups: null, startupModules: startupModules.concat(), }); - if (sourcemapSourcesRoot != null) { + if (sourcemapSourcesRoot !== undefined) { relativizeSourceMapInline(sourceMap, sourcemapSourcesRoot); } const wroteSourceMap = writeSourceMap( diff --git a/packages/metro/src/shared/output/RamBundle/as-indexed-file.js b/packages/metro/src/shared/output/RamBundle/as-indexed-file.js index 85c2d0bcad..acfbd9a743 100644 --- a/packages/metro/src/shared/output/RamBundle/as-indexed-file.js +++ b/packages/metro/src/shared/output/RamBundle/as-indexed-file.js @@ -66,7 +66,7 @@ function saveAsIndexedFile( moduleGroups, fixWrapperOffset: true, }); - if (sourcemapSourcesRoot != null) { + if (sourcemapSourcesRoot !== undefined) { relativizeSourceMapInline(sourceMap, sourcemapSourcesRoot); } diff --git a/packages/metro/src/shared/output/bundle.flow.js b/packages/metro/src/shared/output/bundle.flow.js index d482cf927f..da3d448c5b 100644 --- a/packages/metro/src/shared/output/bundle.flow.js +++ b/packages/metro/src/shared/output/bundle.flow.js @@ -67,7 +67,7 @@ async function saveBundleAndMap( if (sourcemapOutput) { let {map} = bundle; - if (sourcemapSourcesRoot != null) { + if (sourcemapSourcesRoot !== undefined) { log('start relativating source map'); map = relativateSerializedMap(map, sourcemapSourcesRoot); log('finished relativating'); From 6e258510b22fdd4ece2535fdaf40ea34269fda79 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Tue, 19 Jul 2022 23:40:13 +0200 Subject: [PATCH 14/38] fix tests --- .../__tests__/Transformer-test.js | 22 ++++++++++++++++++- .../DeltaBundler/__tests__/WorkerFarm-test.js | 3 +++ .../traverseDependencies-test.js.snap | 5 +++++ .../metro/src/Server/__tests__/Server-test.js | 1 + .../metro/src/__tests__/HmrServer-test.js | 5 +++++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js index 423c0f4a9c..e920ecf951 100644 --- a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js @@ -80,7 +80,7 @@ describe('Transformer', function () { await transformerInstance.transformFile('./foo.js', {}); // We got the SHA-1 of the file from the dependency graph. - expect(getSha1).toBeCalledWith('./foo.js'); + expect(getSha1).toBeCalledWith('./foo.js', undefined); // Only one get, with the original SHA-1. expect(get).toHaveBeenCalledTimes(1); @@ -120,4 +120,24 @@ describe('Transformer', function () { expect(require('../getTransformCacheKey')).not.toBeCalled(); }); + + it('short-circuits the transformer cache key when the cache is disabled', async () => { + const transformerInstance = new Transformer( + { + ...commonOptions, + cacheStores: [], + watchFolders, + }, + getSha1, + ); + + require('../WorkerFarm').prototype.transform.mockReturnValue({ + sha1: 'abcdefabcdefabcdefabcdefabcdefabcdefabcd', + result: {}, + }); + + await transformerInstance.transformFile('./foo.js', {}); + + expect(require('../getTransformCacheKey')).not.toBeCalled(); + }); }); diff --git a/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js b/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js index cb1db1c044..f156dc3ecb 100644 --- a/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js @@ -76,6 +76,7 @@ describe('Worker Farm', function () { transformOptions, config.projectRoot, transformerConfig, + undefined, ); }); @@ -96,6 +97,7 @@ describe('Worker Farm', function () { {}, '/foo', transformerConfig, + undefined, ); await farm.kill(); @@ -111,6 +113,7 @@ describe('Worker Farm', function () { {}, '/bar', transformerConfig, + undefined, ); }); diff --git a/packages/metro/src/DeltaBundler/__tests__/__snapshots__/traverseDependencies-test.js.snap b/packages/metro/src/DeltaBundler/__tests__/__snapshots__/traverseDependencies-test.js.snap index b16bca6ac8..ad26ca0027 100644 --- a/packages/metro/src/DeltaBundler/__tests__/__snapshots__/traverseDependencies-test.js.snap +++ b/packages/metro/src/DeltaBundler/__tests__/__snapshots__/traverseDependencies-test.js.snap @@ -4,6 +4,7 @@ exports[`should do the initial traversal correctly 1`] = ` Object { "dependencies": Map { "/bundle" => Object { + "contextParams": undefined, "dependencies": Map { "C+7Hteo/D9vJXQ3UfzxbwnXaijM=" => Object { "absolutePath": "/foo", @@ -32,6 +33,7 @@ Object { "path": "/bundle", }, "/foo" => Object { + "contextParams": undefined, "dependencies": Map { "Ys23Ag/5IOWqZCw9QGaVDdHwH00=" => Object { "absolutePath": "/bar", @@ -73,6 +75,7 @@ Object { "path": "/foo", }, "/bar" => Object { + "contextParams": undefined, "dependencies": Map {}, "getSource": [Function], "inverseDependencies": Array [ @@ -91,6 +94,7 @@ Object { "path": "/bar", }, "/baz" => Object { + "contextParams": undefined, "dependencies": Map {}, "getSource": [Function], "inverseDependencies": Array [ @@ -129,6 +133,7 @@ exports[`should not traverse past the initial module if \`shallow\` is passed 1` Object { "dependencies": Map { "/bundle" => Object { + "contextParams": undefined, "dependencies": Map { "C+7Hteo/D9vJXQ3UfzxbwnXaijM=" => Object { "absolutePath": "/foo", diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 8cd7d91af0..3f0dd21553 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -661,6 +661,7 @@ describe('processRequest', () => { type: 'module', unstable_transformProfile: 'hermes-stable', }, + unstable_allowRequireContext: false, }, ); }); diff --git a/packages/metro/src/__tests__/HmrServer-test.js b/packages/metro/src/__tests__/HmrServer-test.js index 5253a8bf3d..f10a384c95 100644 --- a/packages/metro/src/__tests__/HmrServer-test.js +++ b/packages/metro/src/__tests__/HmrServer-test.js @@ -101,6 +101,7 @@ describe('HmrServer', () => { options.serializer.experimentalSerializerHook = () => {}; options.reporter.update = jest.fn(); options.transformer.experimentalImportBundleSupport = false; + options.transformer.unstable_allowRequireContext = false; options.resolver.platforms = []; options.server.rewriteRequestUrl = function (requrl) { const rewritten = requrl.replace(/__REMOVE_THIS_WHEN_REWRITING__/g, ''); @@ -160,6 +161,7 @@ describe('HmrServer', () => { { shallow: false, experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, }, ), ); @@ -185,6 +187,7 @@ describe('HmrServer', () => { { shallow: false, experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, }, ), ); @@ -210,6 +213,7 @@ describe('HmrServer', () => { { shallow: false, experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, }, ), ); @@ -235,6 +239,7 @@ describe('HmrServer', () => { { shallow: false, experimentalImportBundleSupport: false, + unstable_allowRequireContext: false, }, )}\` was not found.`; From ec0ac71e4175315cb2be3d8d3ec3756cfac6d793 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Fri, 22 Jul 2022 18:12:38 +0200 Subject: [PATCH 15/38] move sha1 back up --- .../src/__tests__/HasteFS-test.js | 23 ++++++++++++++----- .../metro/src/DeltaBundler/Transformer.js | 14 ++++++++--- .../metro/src/node-haste/DependencyGraph.js | 7 +----- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/metro-file-map/src/__tests__/HasteFS-test.js b/packages/metro-file-map/src/__tests__/HasteFS-test.js index 6d64c38d2f..aaf98f661c 100644 --- a/packages/metro-file-map/src/__tests__/HasteFS-test.js +++ b/packages/metro-file-map/src/__tests__/HasteFS-test.js @@ -10,18 +10,29 @@ import HasteFS from '../HasteFS'; +jest.mock('../lib/fast_path', () => ({ + resolve: (a, b) => b, + relative: jest.requireActual('path').relative, +})); + describe('matchFilesWithContext', () => { it(`matches files against context`, () => { const hfs = new HasteFS({ rootDir: '/', - files: new Map([]), + files: new Map([ + [ + '/foo/another.js', + // $FlowFixMe: mocking files + {}, + ], + [ + '/bar.js', + // $FlowFixMe: mocking files + {}, + ], + ]), }); - // $FlowFixMe: mocking files - hfs.getAbsoluteFileIterator = function () { - return ['/foo/another.js', '/bar.js']; - }; - // Test non-recursive skipping deep paths expect( hfs.matchFilesWithContext('/', { diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index 9c5f1d8992..fe8e9d1c32 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/src/configTypes.flow'; +import crypto from 'crypto'; const getTransformCacheKey = require('./getTransformCacheKey'); const WorkerFarm = require('./WorkerFarm'); @@ -25,10 +26,10 @@ class Transformer { _config: ConfigT; _cache: Cache>; _baseHash: string; - _getSha1: (string, ?Buffer) => string; + _getSha1: string => string; _workerFarm: WorkerFarm; - constructor(config: ConfigT, getSha1Fn: (string, ?Buffer) => string) { + constructor(config: ConfigT, getSha1Fn: string => string) { this._config = config; this._config.watchFolders.forEach(verifyRootExists); @@ -120,7 +121,14 @@ class Transformer { unstable_transformProfile, ]); - const sha1 = this._getSha1(filePath, fileBuffer); + let sha1: string; + if (fileBuffer) { + // Shortcut for virtual modules which provide the contents with the filename. + sha1 = crypto.createHash('sha1').update(fileBuffer).digest('hex'); + } else { + sha1 = this._getSha1(filePath); + } + let fullKey = Buffer.concat([partialKey, Buffer.from(sha1, 'hex')]); const result = await cache.get(fullKey); diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index 8edd3cbe59..48ccd5a97e 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -13,7 +13,6 @@ import type {ConfigT} from 'metro-config/src/configTypes.flow'; import type MetroFileMap, {HasteFS} from 'metro-file-map'; import type Module from './Module'; import {ModuleMap as MetroFileMapModuleMap} from 'metro-file-map'; -import crypto from 'crypto'; const createHasteMap = require('./DependencyGraph/createHasteMap'); const {ModuleResolver} = require('./DependencyGraph/ModuleResolution'); @@ -186,11 +185,7 @@ class DependencyGraph extends EventEmitter { }); } - getSha1(filename: string, contents: ?Buffer): string { - // Shortcut for virtual modules which provide the contents with the filename. - if (contents) { - return crypto.createHash('sha1').update(contents).digest('hex'); - } + getSha1(filename: string): string { // TODO If it looks like we're trying to get the sha1 from a file located // within a Zip archive, then we instead compute the sha1 for what looks // like the Zip archive itself. From 2b59675b1e62c473a9e7867028ef3f5e36375d6d Mon Sep 17 00:00:00 2001 From: evanbacon Date: Fri, 22 Jul 2022 18:22:49 +0200 Subject: [PATCH 16/38] rename function --- packages/metro/src/DeltaBundler/graphOperations.js | 4 ++-- packages/metro/src/lib/__tests__/contextModule-test.js | 6 +++--- packages/metro/src/lib/contextModule.js | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index d4987c2b34..c02a226fd7 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -41,7 +41,7 @@ import type { } from './types.flow'; import CountingSet from '../lib/CountingSet'; -import {appendContextQueryParam} from '../lib/contextModule'; +import {deriveAbsolutePathFromContext} from '../lib/contextModule'; import * as path from 'path'; const invariant = require('invariant'); @@ -485,7 +485,7 @@ function resolveDependencies( // Ensure the filepath has uniqueness applied to ensure multiple `require.context` // statements can be used to target the same file with different properties. - const absolutePath = appendContextQueryParam(contextParams); + const absolutePath = deriveAbsolutePathFromContext(contextParams); resolvedDep = { absolutePath, diff --git a/packages/metro/src/lib/__tests__/contextModule-test.js b/packages/metro/src/lib/__tests__/contextModule-test.js index 210a3516dd..5f16d0df4f 100644 --- a/packages/metro/src/lib/__tests__/contextModule-test.js +++ b/packages/metro/src/lib/__tests__/contextModule-test.js @@ -10,7 +10,7 @@ import { fileMatchesContext, - appendContextQueryParam, + deriveAbsolutePathFromContext, getContextModuleId, } from '../contextModule'; @@ -38,10 +38,10 @@ describe('getContextModuleId', () => { }); }); -describe('appendContextQueryParam', () => { +describe('deriveAbsolutePathFromContext', () => { it(`appends a context query parameter to the input path`, () => { expect( - appendContextQueryParam({ + deriveAbsolutePathFromContext({ from: '/path/to/project', filter: {pattern: '[a-zA-Z]+', flags: ''}, mode: 'eager', diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js index dec4b72001..24a015441d 100644 --- a/packages/metro/src/lib/contextModule.js +++ b/packages/metro/src/lib/contextModule.js @@ -35,7 +35,9 @@ function toHash(value: string): string { } /** Given a fully qualified require context, return a virtual file path that ensures uniqueness between paths with different contexts. */ -export function appendContextQueryParam(context: RequireContextParams): string { +export function deriveAbsolutePathFromContext( + context: RequireContextParams, +): string { // Drop the trailing slash, require.context should always be matched against a folder // and we want to normalize the folder name as much as possible to prevent duplicates. // This also makes the files show up in the correct location when debugging in Chrome. From 182b4e858ad0492c444d84b1eab8daceecec748f Mon Sep 17 00:00:00 2001 From: evanbacon Date: Mon, 25 Jul 2022 14:52:23 +0200 Subject: [PATCH 17/38] Update Transformer-test.js --- packages/metro/src/DeltaBundler/__tests__/Transformer-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js index e920ecf951..8f44337d27 100644 --- a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js @@ -80,7 +80,7 @@ describe('Transformer', function () { await transformerInstance.transformFile('./foo.js', {}); // We got the SHA-1 of the file from the dependency graph. - expect(getSha1).toBeCalledWith('./foo.js', undefined); + expect(getSha1).toBeCalledWith('./foo.js'); // Only one get, with the original SHA-1. expect(get).toHaveBeenCalledTimes(1); From 2baf25d4c906891ecf073886e3a8fb6e269d2114 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Mon, 25 Jul 2022 19:01:16 +0200 Subject: [PATCH 18/38] restructure to use privateState fixup fix tests Update graphOperations.js Update DeltaCalculator-test.js --- .../metro/src/DeltaBundler/DeltaCalculator.js | 30 ++++----- .../__tests__/DeltaCalculator-test.js | 17 ++++++ .../metro/src/DeltaBundler/graphOperations.js | 61 ++++++++++++++++--- packages/metro/src/DeltaBundler/types.flow.js | 9 +-- .../ModuleGraph/worker/collectDependencies.js | 8 +-- .../src/lib/__tests__/contextModule-test.js | 7 ++- packages/metro/src/lib/contextModule.js | 26 ++++++-- packages/metro/src/lib/transformHelpers.js | 12 ++-- 8 files changed, 118 insertions(+), 52 deletions(-) diff --git a/packages/metro/src/DeltaBundler/DeltaCalculator.js b/packages/metro/src/DeltaBundler/DeltaCalculator.js index 055a7854d5..64dd9cd47f 100644 --- a/packages/metro/src/DeltaBundler/DeltaCalculator.js +++ b/packages/metro/src/DeltaBundler/DeltaCalculator.js @@ -12,7 +12,7 @@ import type {DeltaResult, Graph, Options} from './types.flow'; -import {fileMatchesContext} from '../lib/contextModule'; +import {getContextModulesMatchingFilePath} from './graphOperations'; const { createGraph, @@ -191,11 +191,9 @@ class DeltaCalculator extends EventEmitter { this._addedFiles.delete(filePath); } else if (type === 'add') { this._addedFiles.add(filePath); - this._deletedFiles.delete(filePath); } else { this._modifiedFiles.add(filePath); - this._deletedFiles.delete(filePath); this._addedFiles.delete(filePath); } @@ -248,27 +246,21 @@ class DeltaCalculator extends EventEmitter { // NOTE(EvanBacon): This check adds extra complexity so we feature gate it // to enable users to opt out. if (this._options.unstable_allowRequireContext) { - const checkModifiedContextDependencies = (filePath: string) => { - this._graph.dependencies.forEach(dependency => { - const {contextParams} = dependency; - if ( - contextParams && - contextParams.from != null && - !modifiedDependencies.includes(dependency.path) && - fileMatchesContext(filePath, contextParams) - ) { - modifiedDependencies.push(dependency.path); - } - }); - }; - // Check if any added or removed files are matched in a context module. addedFiles.forEach(filePath => - checkModifiedContextDependencies(filePath), + getContextModulesMatchingFilePath( + this._graph, + filePath, + modifiedDependencies, + ), ); deletedFiles.forEach(filePath => - checkModifiedContextDependencies(filePath), + getContextModulesMatchingFilePath( + this._graph, + filePath, + modifiedDependencies, + ), ); } diff --git a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js index 5e83441f34..c294321af4 100644 --- a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js @@ -315,6 +315,23 @@ describe('DeltaCalculator', () => { fileWatcher.emit('change', {eventsQueue: [{filePath: '/foo'}]}); }); + it('should emit an event when a file is added', async () => { + jest.useFakeTimers(); + + const onChangeFile = jest.fn(); + await deltaCalculator.getDelta({reset: false, shallow: false}); + + deltaCalculator.on('change', onChangeFile); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'add', filePath: '/foo'}], + }); + + jest.runAllTimers(); + + expect(onChangeFile).toHaveBeenCalled(); + }); + it('should not emit an event when there is a file deleted', async () => { jest.useFakeTimers(); diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index c02a226fd7..8f59d9e582 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -31,6 +31,7 @@ 'use strict'; import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; +import type {RequireContext} from '../lib/contextModule'; import type { Dependency, Graph, @@ -41,7 +42,11 @@ import type { } from './types.flow'; import CountingSet from '../lib/CountingSet'; -import {deriveAbsolutePathFromContext} from '../lib/contextModule'; +import { + deriveAbsolutePathFromContext, + fileMatchesContext, + getContextModuleId, +} from '../lib/contextModule'; import * as path from 'path'; const invariant = require('invariant'); @@ -66,6 +71,8 @@ type NodeColor = // Private state for the graph that persists between operations. export opaque type PrivateState = { + /** Resolved context parameters from `require.context`. */ + +resolvedContext: Map, +gc: { // GC state for nodes in the graph (graph.dependencies) +color: Map, @@ -82,6 +89,7 @@ function createGraph(options: GraphInputOptions): Graph { dependencies: new Map(), importBundleNames: new Set(), privateState: { + resolvedContext: new Map(), gc: { color: new Map(), possibleCycleRoots: new Set(), @@ -277,10 +285,10 @@ async function processModule( // TODO: Unbind the transform method from options let result; if (contextParams != null) { - result = await options.transform( - nullthrows(contextParams.from), - contextParams, + const resolvedContext = nullthrows( + graph.privateState.resolvedContext.get(path), ); + result = await options.transform(resolvedContext.from, resolvedContext); } else { result = await options.transform(path); } @@ -288,6 +296,7 @@ async function processModule( // Get the absolute path of all sub-dependencies (some of them could have been // moved but maintain the same relative path). const currentDependencies = resolveDependencies( + graph, path, result.dependencies, options, @@ -450,6 +459,10 @@ function removeDependency( decrementImportBundleReference(dependency, graph); } + if (dependency.data.data.contextParams) { + graph.privateState.resolvedContext.delete(dependency.absolutePath); + } + const module = graph.dependencies.get(absolutePath); if (!module) { @@ -469,7 +482,27 @@ function removeDependency( } } +/** + * Collect a list of context modules which include a given file. + */ +function getContextModulesMatchingFilePath( + graph: Graph, + filePath: string, + modifiedDependencies: string[], +): string[] { + graph.privateState.resolvedContext.forEach(context => { + if ( + !modifiedDependencies.includes(context.absolutePath) && + fileMatchesContext(filePath, context) + ) { + modifiedDependencies.push(context.absolutePath); + } + }); + return modifiedDependencies; +} + function resolveDependencies( + graph: Graph, parentPath: string, dependencies: $ReadOnlyArray, options: InternalOptions, @@ -481,11 +514,24 @@ function resolveDependencies( // `require.context` const {contextParams} = dep.data; if (contextParams) { - contextParams.from = path.join(parentPath, '..', dep.name); - // Ensure the filepath has uniqueness applied to ensure multiple `require.context` // statements can be used to target the same file with different properties. - const absolutePath = deriveAbsolutePathFromContext(contextParams); + const from = path.join(parentPath, '..', dep.name); + const absolutePath = deriveAbsolutePathFromContext(from, contextParams); + + const resolvedContext: RequireContext = { + id: getContextModuleId(from, contextParams), + from, + absolutePath, + mode: contextParams.mode, + recursive: contextParams.recursive, + filter: new RegExp( + contextParams.filter.pattern, + contextParams.filter.flags, + ), + }; + + graph.privateState.resolvedContext.set(absolutePath, resolvedContext); resolvedDep = { absolutePath, @@ -779,4 +825,5 @@ module.exports = { initialTraverseDependencies, traverseDependencies, reorderGraph, + getContextModulesMatchingFilePath, }; diff --git a/packages/metro/src/DeltaBundler/types.flow.js b/packages/metro/src/DeltaBundler/types.flow.js index 14ee1b8558..f2cd6495a2 100644 --- a/packages/metro/src/DeltaBundler/types.flow.js +++ b/packages/metro/src/DeltaBundler/types.flow.js @@ -11,6 +11,7 @@ 'use strict'; import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; +import type {RequireContext} from '../lib/contextModule'; import type {PrivateState} from './graphOperations'; import type {JsTransformOptions} from 'metro-transform-worker'; @@ -110,7 +111,7 @@ export type TransformResultWithSource = $ReadOnly<{ export type TransformFn = ( string, - ?RequireContextParams, + ?RequireContext, ) => Promise>; export type AllowOptionalDependenciesWithOptions = { +exclude: Array, @@ -120,11 +121,7 @@ export type AllowOptionalDependencies = | AllowOptionalDependenciesWithOptions; export type Options = { - +resolve: ( - from: string, - to: string, - context?: ?RequireContextParams, - ) => string, + +resolve: (from: string, to: string, context?: ?RequireContext) => string, +transform: TransformFn, +transformOptions: TransformInputOptions, +onProgress: ?(numProcessed: number, total: number) => mixed, diff --git a/packages/metro/src/ModuleGraph/worker/collectDependencies.js b/packages/metro/src/ModuleGraph/worker/collectDependencies.js index 715ed086e3..c9b007916a 100644 --- a/packages/metro/src/ModuleGraph/worker/collectDependencies.js +++ b/packages/metro/src/ModuleGraph/worker/collectDependencies.js @@ -41,18 +41,16 @@ export type Dependency = $ReadOnly<{ // TODO: Convert to a Flow enum export type ContextMode = 'sync' | 'eager' | 'lazy' | 'lazy-once'; -export type ContextFilter = {pattern: string, flags: string}; +type ContextFilter = {pattern: string, flags: string}; -export type RequireContextParams = { - /** Absolute file path pointing to the root directory of the context. */ - from?: string, +export type RequireContextParams = $ReadOnly<{ /* Should search for files recursively. Optional, default `true` when `require.context` is used */ recursive: boolean, /* Filename filter pattern for use in `require.context`. Optional, default `.*` (any file) when `require.context` is used */ filter: $ReadOnly, /** Mode for resolving dynamic dependencies. Defaults to `sync` */ mode: ContextMode, -}; +}>; type DependencyData = $ReadOnly<{ // A locally unique key for this dependency within the current module. diff --git a/packages/metro/src/lib/__tests__/contextModule-test.js b/packages/metro/src/lib/__tests__/contextModule-test.js index 5f16d0df4f..009f4dfa3d 100644 --- a/packages/metro/src/lib/__tests__/contextModule-test.js +++ b/packages/metro/src/lib/__tests__/contextModule-test.js @@ -41,8 +41,7 @@ describe('getContextModuleId', () => { describe('deriveAbsolutePathFromContext', () => { it(`appends a context query parameter to the input path`, () => { expect( - deriveAbsolutePathFromContext({ - from: '/path/to/project', + deriveAbsolutePathFromContext('/path/to/project', { filter: {pattern: '[a-zA-Z]+', flags: ''}, mode: 'eager', recursive: true, @@ -55,9 +54,11 @@ describe('fileMatchesContext', () => { it(`matches files`, () => { expect( fileMatchesContext('/path/to/project/index.js', { + absolutePath: '...', + id: '...', mode: 'lazy', from: '/path/to/project', - filter: {pattern: '.*', flags: ''}, + filter: /.*/, recursive: true, }), ).toBe(true); diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js index 24a015441d..c85cb50b48 100644 --- a/packages/metro/src/lib/contextModule.js +++ b/packages/metro/src/lib/contextModule.js @@ -10,9 +10,27 @@ import crypto from 'crypto'; import path from 'path'; -import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; +import type { + ContextMode, + RequireContextParams, +} from '../ModuleGraph/worker/collectDependencies'; import nullthrows from 'nullthrows'; +export type RequireContext = $ReadOnly<{ + /* Should search for files recursively. Optional, default `true` when `require.context` is used */ + recursive: boolean, + /* Filename filter pattern for use in `require.context`. Optional, default `.*` (any file) when `require.context` is used */ + filter: RegExp, + /** Mode for resolving dynamic dependencies. Defaults to `sync` */ + mode: ContextMode, + + from: string, + + id: string, + + absolutePath: string, +}>; + /** Get an ID for a context module. */ export function getContextModuleId( modulePath: string, @@ -36,12 +54,12 @@ function toHash(value: string): string { /** Given a fully qualified require context, return a virtual file path that ensures uniqueness between paths with different contexts. */ export function deriveAbsolutePathFromContext( + from: string, context: RequireContextParams, ): string { // Drop the trailing slash, require.context should always be matched against a folder // and we want to normalize the folder name as much as possible to prevent duplicates. // This also makes the files show up in the correct location when debugging in Chrome. - const from = nullthrows(context.from); const filePath = from.endsWith(path.sep) ? from.slice(0, -1) : from; return filePath + '?ctx=' + toHash(getContextModuleId(filePath, context)); } @@ -49,13 +67,13 @@ export function deriveAbsolutePathFromContext( /** Match a file against a require context. */ export function fileMatchesContext( testPath: string, - context: RequireContextParams, + context: RequireContext, ): boolean { // NOTE(EvanBacon): Ensure this logic is synchronized with the similar // functionality in `metro-file-map/src/HasteFS.js` (`matchFilesWithContext()`) const filePath = path.relative(nullthrows(context.from), testPath); - const filter = new RegExp(context.filter.pattern, context.filter.flags); + const filter = context.filter; if ( // Ignore everything outside of the provided `root`. !(filePath && !filePath.startsWith('..')) || diff --git a/packages/metro/src/lib/transformHelpers.js b/packages/metro/src/lib/transformHelpers.js index 70c60a3d35..4dceb91d1b 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -13,13 +13,12 @@ import type Bundler from '../Bundler'; import type DeltaBundler, {TransformFn} from '../DeltaBundler'; import type {TransformInputOptions} from '../DeltaBundler/types.flow'; -import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; import type {TransformOptions} from '../DeltaBundler/Worker'; import type {ConfigT} from 'metro-config/src/configTypes.flow'; import type {Type} from 'metro-transform-worker'; +import type {RequireContext} from './contextModule'; import {getContextModuleTemplate} from './contextModuleTemplates'; -import {getContextModuleId} from './contextModule'; const path = require('path'); @@ -124,7 +123,7 @@ async function getTransformFn( options, ); - return async (modulePath: string, requireContext: ?RequireContextParams) => { + return async (modulePath: string, requireContext: ?RequireContext) => { let templateBuffer: Buffer; if (requireContext) { @@ -136,10 +135,7 @@ async function getTransformFn( // Search against all files, this is very expensive. // TODO: Maybe we could let the user specify which root to check against. const files = graph.matchFilesWithContext(modulePath, { - filter: new RegExp( - requireContext.filter.pattern, - requireContext.filter.flags, - ), + filter: requireContext.filter, recursive: requireContext.recursive, }); @@ -147,7 +143,7 @@ async function getTransformFn( requireContext.mode, modulePath, files, - getContextModuleId(modulePath, requireContext), + requireContext.id, ); templateBuffer = Buffer.from(template); From 5f110e62f616fb907dd035126f6e72d6569c2359 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Wed, 27 Jul 2022 15:18:57 +0200 Subject: [PATCH 19/38] added tests --- .../metro/src/DeltaBundler/DeltaCalculator.js | 42 +- .../__tests__/DeltaCalculator-test.js | 30 ++ .../__tests__/DeltaCalculatorContext-test.js | 372 ++++++++++++++++++ .../metro/src/DeltaBundler/graphOperations.js | 9 +- 4 files changed, 427 insertions(+), 26 deletions(-) create mode 100644 packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js diff --git a/packages/metro/src/DeltaBundler/DeltaCalculator.js b/packages/metro/src/DeltaBundler/DeltaCalculator.js index 64dd9cd47f..dc999bd4fe 100644 --- a/packages/metro/src/DeltaBundler/DeltaCalculator.js +++ b/packages/metro/src/DeltaBundler/DeltaCalculator.js @@ -10,16 +10,15 @@ 'use strict'; -import type {DeltaResult, Graph, Options} from './types.flow'; - import {getContextModulesMatchingFilePath} from './graphOperations'; - -const { +import { createGraph, initialTraverseDependencies, reorderGraph, traverseDependencies, -} = require('./graphOperations'); +} from './graphOperations'; +import type {DeltaResult, Graph, Options} from './types.flow'; + const {EventEmitter} = require('events'); /** @@ -192,6 +191,7 @@ class DeltaCalculator extends EventEmitter { } else if (type === 'add') { this._addedFiles.add(filePath); this._deletedFiles.delete(filePath); + this._modifiedFiles.delete(filePath); } else { this._modifiedFiles.add(filePath); this._deletedFiles.delete(filePath); @@ -238,32 +238,30 @@ class DeltaCalculator extends EventEmitter { } }); - // We only want to process files that are in the bundle. - const modifiedDependencies = Array.from(modifiedFiles).filter( - (filePath: string) => this._graph.dependencies.has(filePath), - ); - // NOTE(EvanBacon): This check adds extra complexity so we feature gate it // to enable users to opt out. if (this._options.unstable_allowRequireContext) { // Check if any added or removed files are matched in a context module. - addedFiles.forEach(filePath => - getContextModulesMatchingFilePath( + // We only need to do this for added files because deleted files will contain a context + // module as an inverse dependency. + addedFiles.forEach(filePath => { + const contextModulePaths = getContextModulesMatchingFilePath( this._graph, filePath, - modifiedDependencies, - ), - ); + modifiedFiles, + ); - deletedFiles.forEach(filePath => - getContextModulesMatchingFilePath( - this._graph, - filePath, - modifiedDependencies, - ), - ); + contextModulePaths.forEach(modulePath => { + modifiedFiles.add(modulePath); + }); + }); } + // We only want to process files that are in the bundle. + const modifiedDependencies = Array.from(modifiedFiles).filter( + (filePath: string) => this._graph.dependencies.has(filePath), + ); + // No changes happened. Return empty delta. if (modifiedDependencies.length === 0) { return { diff --git a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js index c294321af4..187ff4ffa1 100644 --- a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js @@ -15,6 +15,7 @@ jest.mock('../../Bundler'); const initialTraverseDependencies = jest.fn(); const traverseDependencies = jest.fn(); const reorderGraph = jest.fn(); + jest.doMock('../graphOperations', () => ({ ...jest.requireActual('../graphOperations'), initialTraverseDependencies, @@ -209,6 +210,35 @@ describe('DeltaCalculator', () => { }); }); + it('should calculate a delta after a file addition', async () => { + await deltaCalculator.getDelta({reset: false, shallow: false}); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'add', filePath: '/foo'}], + }); + + traverseDependencies.mockResolvedValueOnce({ + added: new Map([['/foo', fooModule]]), + modified: new Map(), + deleted: new Set(), + }); + + const result = await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + expect(result).toEqual({ + added: new Map(), + modified: new Map(), + deleted: new Set(), + reset: false, + }); + + // Not called because there were no modified files. + expect(traverseDependencies).not.toBeCalled(); + }); + it('should calculate a delta after a simple modification', async () => { await deltaCalculator.getDelta({reset: false, shallow: false}); diff --git a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js new file mode 100644 index 0000000000..48684fbae9 --- /dev/null +++ b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js @@ -0,0 +1,372 @@ +/** + * 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. + * + * @emails oncall+metro_bundler + * @format + * @flow strict-local + */ + +'use strict'; + +jest.mock('../../Bundler'); +const initialTraverseDependencies = jest.fn(); +const traverseDependencies = jest.fn(); +const reorderGraph = jest.fn(); +const getContextModulesMatchingFilePath = jest.fn(); +jest.doMock('../graphOperations', () => ({ + ...jest.requireActual('../graphOperations'), + initialTraverseDependencies, + traverseDependencies, + reorderGraph, + getContextModulesMatchingFilePath, +})); + +const DeltaCalculator = require('../DeltaCalculator'); +const {EventEmitter} = require('events'); + +describe('DeltaCalculator', () => { + let entryModule; + let fooModule; + let ctxModule; + + let deltaCalculator; + let fileWatcher; + + const options = { + unstable_allowRequireContext: true, + experimentalImportBundleSupport: false, + onProgress: null, + resolve: (from: string, to: string) => { + throw new Error('Never called'); + }, + shallow: false, + transform: (modulePath: string) => { + throw new Error('Never called'); + }, + transformOptions: { + // NOTE: These options are ignored because we mock out the transformer (via traverseDependencies). + dev: false, + hot: false, + minify: false, + platform: null, + runtimeBytecodeVersion: null, + type: 'module', + unstable_transformProfile: 'default', + }, + }; + + beforeEach(async () => { + fileWatcher = new EventEmitter(); + + getContextModulesMatchingFilePath.mockReset(); + // ~/ + // ├─ bundle + // ├─ ctx/ + // │ ├─ foo + + initialTraverseDependencies.mockImplementationOnce(async (graph, opt) => { + // + // require.context('./ctx') + // + entryModule = { + dependencies: new Map([['ctx', '/ctx?ctx=xxx']]), + inverseDependencies: [], + output: { + name: 'bundle', + }, + path: '/bundle', + }; + + // Virtual context module. + ctxModule = { + dependencies: new Map([['foo', '/ctx/foo']]), + inverseDependencies: ['/bundle'], + output: { + name: 'ctx', + }, + path: '/ctx?ctx=xxx', + contextParams: { + recursive: true, + filter: { + pattern: '.*', + flags: '', + }, + mode: 'sync', + }, + }; + + fooModule = { + dependencies: new Map(), + inverseDependencies: ['/ctx?ctx=xxx'], + output: { + name: 'foo', + }, + path: '/ctx/foo', + }; + + graph.dependencies.set('/bundle', entryModule); + graph.dependencies.set('/ctx?ctx=xxx', ctxModule); + graph.dependencies.set('/foo', fooModule); + + return { + added: new Map([ + ['/bundle', entryModule], + ['/ctx?ctx=xxx', entryModule], + ['/ctx/foo', fooModule], + ]), + modified: new Map(), + deleted: new Set(), + }; + }); + + deltaCalculator = new DeltaCalculator( + new Set(['/bundle']), + fileWatcher, + options, + ); + }); + + // Entry -> ctx -> [foo, bar] + + afterEach(() => { + deltaCalculator.end(); + + traverseDependencies.mockReset(); + initialTraverseDependencies.mockReset(); + }); + + it('should include the entry file when calculating the initial bundle', async () => { + const result = await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + expect(result).toEqual({ + added: new Map([ + ['/bundle', entryModule], + [ + '/ctx?ctx=xxx', + { + dependencies: new Map([['ctx', '/ctx?ctx=xxx']]), + inverseDependencies: [], + output: { + name: 'bundle', + }, + path: '/bundle', + }, + ], + ['/ctx/foo', fooModule], + ]), + modified: new Map(), + deleted: new Set(), + reset: true, + }); + + jest.runAllTicks(); + }); + + it('should calculate a delta after removing a dependency', async () => { + // Get initial delta + await deltaCalculator.getDelta({reset: false, shallow: false}); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'delete', filePath: '/foo'}], + }); + + traverseDependencies.mockReturnValue( + Promise.resolve({ + added: new Map(), + modified: new Map([['/ctx?ctx=xxx', ctxModule]]), + deleted: new Set(['/foo']), + }), + ); + + const result = await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + expect(traverseDependencies).toBeCalledWith( + ['/ctx?ctx=xxx'], + expect.anything(), + expect.anything(), + ); + + expect(result).toEqual({ + added: new Map(), + modified: new Map([['/ctx?ctx=xxx', expect.anything()]]), + deleted: new Set(['/foo']), + reset: false, + }); + + // We rely on inverse dependencies to update a context module. + expect(getContextModulesMatchingFilePath).not.toBeCalled(); + + expect(traverseDependencies.mock.calls.length).toBe(1); + }); + + it('should calculate a delta after adding/removing dependencies', async () => { + // Emulate matching the deleted file against the first context module + getContextModulesMatchingFilePath.mockImplementationOnce( + (graph, filePath, modifiedDependencies) => { + return ['/ctx?ctx=xxx']; + }, + ); + + // Get initial delta: (_addedFiles, _deletedFiles, _modifiedFiles) -> _getChangedDependencies -> traverseDependencies + await deltaCalculator.getDelta({reset: false, shallow: false}); + + // _handleMultipleFileChanges -> _handleFileChange -> (_addedFiles, _deletedFiles, _modifiedFiles) + fileWatcher.emit('change', { + eventsQueue: [{type: 'add', filePath: '/qux'}], + }); + + const quxModule = { + dependencies: new Map(), + inverseDependencies: [], + output: {name: 'qux'}, + path: '/qux', + }; + + traverseDependencies.mockImplementation(async (path, graph, options) => { + graph.dependencies.set('/qux', quxModule); + return { + added: new Map([['/qux', quxModule]]), + modified: new Map([['/ctx?ctx=xxx', ctxModule]]), + deleted: new Set([]), + }; + }); + + const result = await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + // Test if the new module matches any of the context modules. + expect(getContextModulesMatchingFilePath).toBeCalledWith( + expect.anything(), + '/qux', + new Set(['/ctx?ctx=xxx']), + ); + + // Called with context module + expect(traverseDependencies).toBeCalledWith( + ['/ctx?ctx=xxx'], + expect.anything(), + expect.anything(), + ); + + expect(result).toEqual({ + added: new Map([['/qux', quxModule]]), + modified: new Map([['/ctx?ctx=xxx', ctxModule]]), + deleted: new Set(), + reset: false, + }); + }); + + it('should retry to build the last delta after getting an error', async () => { + await deltaCalculator.getDelta({reset: false, shallow: false}); + + fileWatcher.emit('change', {eventsQueue: [{filePath: '/ctx?ctx=xxx'}]}); + + traverseDependencies.mockReturnValue(Promise.reject(new Error())); + + await expect( + deltaCalculator.getDelta({reset: false, shallow: false}), + ).rejects.toBeInstanceOf(Error); + + // This second time it should still throw an error. + await expect( + deltaCalculator.getDelta({reset: false, shallow: false}), + ).rejects.toBeInstanceOf(Error); + }); + + it('should never try to traverse a file after deleting it', async () => { + await deltaCalculator.getDelta({reset: false, shallow: false}); + + // First modify the file + fileWatcher.emit('change', {eventsQueue: [{filePath: '/ctx?ctx=xxx'}]}); + + // Then delete that same file + fileWatcher.emit('change', { + eventsQueue: [{type: 'delete', filePath: '/ctx?ctx=xxx'}], + }); + + traverseDependencies.mockReturnValue( + Promise.resolve({ + added: new Map(), + modified: new Map([['/bundle', entryModule]]), + deleted: new Set(['/ctx?ctx=xxx']), + }), + ); + + expect( + await deltaCalculator.getDelta({reset: false, shallow: false}), + ).toEqual({ + added: new Map(), + modified: new Map([['/bundle', entryModule]]), + deleted: new Set(['/ctx?ctx=xxx']), + reset: false, + }); + + expect(traverseDependencies).toHaveBeenCalledTimes(1); + expect(traverseDependencies.mock.calls[0][0]).toEqual(['/bundle']); + }); + + it('does not traverse a file after deleting it and one of its dependencies', async () => { + await deltaCalculator.getDelta({reset: false, shallow: false}); + + // Delete a file + fileWatcher.emit('change', { + eventsQueue: [{type: 'delete', filePath: '/ctx?ctx=xxx'}], + }); + + // Delete a dependency of the deleted file + fileWatcher.emit('change', { + eventsQueue: [{type: 'delete', filePath: '/foo'}], + }); + + traverseDependencies.mockReturnValue( + Promise.resolve({ + added: new Map(), + modified: new Map([['/bundle', entryModule]]), + deleted: new Set(['/ctx?ctx=xxx']), + }), + ); + + await deltaCalculator.getDelta({reset: false, shallow: false}); + + // Only the /bundle module should have been traversed (since it's an + // inverse dependency of /ctx?ctx=xxx). + expect(traverseDependencies).toHaveBeenCalledTimes(1); + expect(traverseDependencies.mock.calls[0][0]).toEqual(['/bundle']); + }); + + it('should not do unnecessary work when adding a context module file after deleting it', async () => { + await deltaCalculator.getDelta({reset: false, shallow: false}); + + // First delete a file + fileWatcher.emit('change', { + eventsQueue: [{type: 'delete', filePath: '/ctx?ctx=xxx'}], + }); + + // Then add it again + fileWatcher.emit('change', {eventsQueue: [{filePath: '/ctx?ctx=xxx'}]}); + + traverseDependencies.mockReturnValue( + Promise.resolve({ + added: new Map(), + modified: new Map([['/ctx?ctx=xxx', entryModule]]), + deleted: new Set(), + }), + ); + + await deltaCalculator.getDelta({reset: false, shallow: false}); + + expect(traverseDependencies).toHaveBeenCalledTimes(1); + expect(traverseDependencies.mock.calls[0][0]).toEqual(['/ctx?ctx=xxx']); + }); +}); diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index 8f59d9e582..71b191aec1 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -488,17 +488,18 @@ function removeDependency( function getContextModulesMatchingFilePath( graph: Graph, filePath: string, - modifiedDependencies: string[], + modifiedFiles: Set, ): string[] { + const modulePaths: string[] = []; graph.privateState.resolvedContext.forEach(context => { if ( - !modifiedDependencies.includes(context.absolutePath) && + !modifiedFiles.has(context.absolutePath) && fileMatchesContext(filePath, context) ) { - modifiedDependencies.push(context.absolutePath); + modulePaths.push(context.absolutePath); } }); - return modifiedDependencies; + return modulePaths; } function resolveDependencies( From 063e99acc868b38c619a026afbb1f18f023e4b8e Mon Sep 17 00:00:00 2001 From: evanbacon Date: Wed, 27 Jul 2022 16:20:07 +0200 Subject: [PATCH 20/38] Update traverseDependencies-test.js --- .../__tests__/traverseDependencies-test.js | 179 +++++++++++++++++- 1 file changed, 176 insertions(+), 3 deletions(-) diff --git a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js index cca2db9a99..ff622d5db5 100644 --- a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js @@ -28,6 +28,7 @@ const { initialTraverseDependencies, reorderGraph, traverseDependencies: traverseDependenciesImpl, + getContextModulesMatchingFilePath, } = require('../graphOperations'); const {objectContaining} = expect; @@ -59,6 +60,14 @@ let moduleBaz; let mockTransform; +const getMockDependency = (path: string) => { + const deps = mockedDependencyTree.get(path); + if (!deps) { + throw new Error(`No mock dependency named: ${path}`); + } + return deps; +}; + const Actions = { modifyFile(path: string) { if (mockedDependencies.has(path)) { @@ -89,7 +98,7 @@ const Actions = { name?: string, data?: DependencyDataInput, ) { - const deps = nullthrows(mockedDependencyTree.get(path)); + const deps = getMockDependency(path); const depName = name ?? dependencyPath.replace('/', ''); const key = require('crypto') .createHash('sha1') @@ -307,11 +316,11 @@ beforeEach(async () => { }); options = { - unstable_allowRequireContext: false, + unstable_allowRequireContext: true, experimentalImportBundleSupport: false, onProgress: null, resolve: (from: string, to: string) => { - const deps = nullthrows(mockedDependencyTree.get(from)); + const deps = getMockDependency(from); const {path} = deps.filter(dep => dep.name === to)[0]; if (!mockedDependencies.has(path)) { @@ -1391,6 +1400,138 @@ describe('edge cases', () => { }); }); + it('should add a context module and pass resolved context to the transform function', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', undefined, undefined, { + contextParams: { + recursive: true, + mode: 'sync', + filter: {pattern: '.*', flags: ''}, + }, + }); + files.clear(); + + await initialTraverseDependencies(graph, options); + + expect(mockTransform).toHaveBeenCalledTimes(5); + // Ensure the resolved context is passed to the transform function + // this triggers the context module generation. + expect(mockTransform).toHaveBeenNthCalledWith(3, '/ctx', { + absolutePath: '/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a', + filter: /.*/, + from: '/ctx', + id: '/ctx sync recursive /.*/', + mode: 'sync', + recursive: true, + }); + }); + + it('should remove resolved context module when a dependency is removed', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', undefined, undefined, { + contextParams: { + recursive: true, + mode: 'sync', + filter: {pattern: '.*', flags: ''}, + }, + }); + files.clear(); + + await initialTraverseDependencies(graph, options); + + // Ensure the resolved context exists + expect(graph.privateState.resolvedContext).toEqual( + new Map([ + [ + '/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a', + expect.anything(), + ], + ]), + ); + + Actions.removeDependency('/bundle', '/ctx'); + + expect( + getPaths(await traverseDependencies([...files], graph, options)), + ).toEqual({ + added: new Set([]), + deleted: new Set(['/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a']), + modified: new Set(['/bundle']), + }); + + // Ensure the resolved context was removed + expect(graph.privateState.resolvedContext).toEqual(new Map()); + }); + + it('should modify the context module when its dependencies change', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', undefined, undefined, { + contextParams: { + recursive: true, + mode: 'sync', + filter: {pattern: '.*', flags: ''}, + }, + }); + + Actions.createFile('/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a'); + files.clear(); + + await initialTraverseDependencies(graph, options); + + Actions.createFile('/ctx/foo'); + Actions.addDependency( + '/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a', + '/ctx/foo', + ); + + expect( + getPaths(await traverseDependencies([...files], graph, options)), + ).toEqual({ + added: new Set([]), + modified: new Set(['/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a']), + deleted: new Set([]), + }); + }); + + it('should modify a single context module from different origins when the dependencies change', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', undefined, undefined, { + contextParams: { + recursive: true, + mode: 'sync', + filter: {pattern: '.*', flags: ''}, + }, + }); + + Actions.createFile('/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a'); + + Actions.addDependency('/foo', '/ctx', undefined, undefined, { + contextParams: { + recursive: true, + mode: 'sync', + filter: {pattern: '.*', flags: ''}, + }, + }); + + files.clear(); + + await initialTraverseDependencies(graph, options); + + Actions.createFile('/ctx/foo'); + Actions.addDependency( + '/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a', + '/ctx/foo', + ); + + expect( + getPaths(await traverseDependencies([...files], graph, options)), + ).toEqual({ + added: new Set([]), + modified: new Set(['/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a']), + deleted: new Set([]), + }); + }); + describe('lazy traversal of async imports', () => { let localOptions; beforeEach(() => { @@ -2336,3 +2477,35 @@ describe('parallel edges', () => { }); }); }); + +describe('getContextModulesMatchingFilePath', () => { + it(`matches a file against internally resolved context modules`, () => { + graph.privateState.resolvedContext.set('/ctx?ctx=xxx', { + absolutePath: '/ctx?ctx=xxx', + from: '/', + recursive: true, + filter: /.*/, + }); + graph.privateState.resolvedContext.set('/ctx?ctx=xxx2', { + absolutePath: '/ctx?ctx=xxx2', + from: '/', + recursive: true, + filter: /foobar/, + }); + + // This won't match + graph.privateState.resolvedContext.set('/ctx?ctx=xxx3', { + absolutePath: '/ctx?ctx=xxx3', + from: '/', + recursive: true, + filter: /no-match/, + }); + + const contextModules = getContextModulesMatchingFilePath( + graph, + '/ctx/foobar', + new Set(), + ); + expect(contextModules).toEqual(['/ctx?ctx=xxx', '/ctx?ctx=xxx2']); + }); +}); From 4b965fb20b481edf0c1aa25936be490e29cd80cd Mon Sep 17 00:00:00 2001 From: evanbacon Date: Tue, 9 Aug 2022 11:03:49 +0200 Subject: [PATCH 21/38] resolvedContexts --- .../__tests__/traverseDependencies-test.js | 10 +++++----- packages/metro/src/DeltaBundler/graphOperations.js | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js index ff622d5db5..2deae4b2d0 100644 --- a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js @@ -1440,7 +1440,7 @@ describe('edge cases', () => { await initialTraverseDependencies(graph, options); // Ensure the resolved context exists - expect(graph.privateState.resolvedContext).toEqual( + expect(graph.privateState.resolvedContexts).toEqual( new Map([ [ '/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a', @@ -1460,7 +1460,7 @@ describe('edge cases', () => { }); // Ensure the resolved context was removed - expect(graph.privateState.resolvedContext).toEqual(new Map()); + expect(graph.privateState.resolvedContexts).toEqual(new Map()); }); it('should modify the context module when its dependencies change', async () => { @@ -2480,13 +2480,13 @@ describe('parallel edges', () => { describe('getContextModulesMatchingFilePath', () => { it(`matches a file against internally resolved context modules`, () => { - graph.privateState.resolvedContext.set('/ctx?ctx=xxx', { + graph.privateState.resolvedContexts.set('/ctx?ctx=xxx', { absolutePath: '/ctx?ctx=xxx', from: '/', recursive: true, filter: /.*/, }); - graph.privateState.resolvedContext.set('/ctx?ctx=xxx2', { + graph.privateState.resolvedContexts.set('/ctx?ctx=xxx2', { absolutePath: '/ctx?ctx=xxx2', from: '/', recursive: true, @@ -2494,7 +2494,7 @@ describe('getContextModulesMatchingFilePath', () => { }); // This won't match - graph.privateState.resolvedContext.set('/ctx?ctx=xxx3', { + graph.privateState.resolvedContexts.set('/ctx?ctx=xxx3', { absolutePath: '/ctx?ctx=xxx3', from: '/', recursive: true, diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index 71b191aec1..e8692bdfc6 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -72,7 +72,7 @@ type NodeColor = // Private state for the graph that persists between operations. export opaque type PrivateState = { /** Resolved context parameters from `require.context`. */ - +resolvedContext: Map, + +resolvedContexts: Map, +gc: { // GC state for nodes in the graph (graph.dependencies) +color: Map, @@ -89,7 +89,7 @@ function createGraph(options: GraphInputOptions): Graph { dependencies: new Map(), importBundleNames: new Set(), privateState: { - resolvedContext: new Map(), + resolvedContexts: new Map(), gc: { color: new Map(), possibleCycleRoots: new Set(), @@ -286,7 +286,7 @@ async function processModule( let result; if (contextParams != null) { const resolvedContext = nullthrows( - graph.privateState.resolvedContext.get(path), + graph.privateState.resolvedContexts.get(path), ); result = await options.transform(resolvedContext.from, resolvedContext); } else { @@ -460,7 +460,7 @@ function removeDependency( } if (dependency.data.data.contextParams) { - graph.privateState.resolvedContext.delete(dependency.absolutePath); + graph.privateState.resolvedContexts.delete(dependency.absolutePath); } const module = graph.dependencies.get(absolutePath); @@ -491,7 +491,7 @@ function getContextModulesMatchingFilePath( modifiedFiles: Set, ): string[] { const modulePaths: string[] = []; - graph.privateState.resolvedContext.forEach(context => { + graph.privateState.resolvedContexts.forEach(context => { if ( !modifiedFiles.has(context.absolutePath) && fileMatchesContext(filePath, context) @@ -532,7 +532,7 @@ function resolveDependencies( ), }; - graph.privateState.resolvedContext.set(absolutePath, resolvedContext); + graph.privateState.resolvedContexts.set(absolutePath, resolvedContext); resolvedDep = { absolutePath, From 56c6a66a5d50508d6d586c55713d708cd3cac7b7 Mon Sep 17 00:00:00 2001 From: evanbacon Date: Tue, 9 Aug 2022 11:06:23 +0200 Subject: [PATCH 22/38] Update graphOperations.js --- packages/metro/src/DeltaBundler/graphOperations.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index e8692bdfc6..a9895942ee 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -459,10 +459,6 @@ function removeDependency( decrementImportBundleReference(dependency, graph); } - if (dependency.data.data.contextParams) { - graph.privateState.resolvedContexts.delete(dependency.absolutePath); - } - const module = graph.dependencies.get(absolutePath); if (!module) { @@ -488,7 +484,7 @@ function removeDependency( function getContextModulesMatchingFilePath( graph: Graph, filePath: string, - modifiedFiles: Set, + modifiedFiles: $ReadOnlySet, ): string[] { const modulePaths: string[] = []; graph.privateState.resolvedContexts.forEach(context => { @@ -682,6 +678,9 @@ function releaseModule( options: InternalOptions, ) { for (const [key, dependency] of module.dependencies) { + if (dependency.data.data.contextParams) { + graph.privateState.resolvedContexts.delete(dependency.absolutePath); + } removeDependency(module, key, dependency, graph, delta, options); } graph.privateState.gc.color.set(module.path, 'black'); From 9d469a56d94f04ca8c0fc88af53bfefc9ff2ef9a Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 9 Aug 2022 11:51:38 +0100 Subject: [PATCH 23/38] Overhaul traverseDependencies tests, some API/behaviour changes * getContextModulesMatchingFilePath --> markModifiedContextModules, taking a mutable Set * resolvedContext --> resolveContexts * transformer now consistently receives the absolute path * fixed resolvedContexts deletion bug and added a regression test * removed absolutePath from RequireContext type * Flow check succeeds * Tests no longer reach into privateState --- .../metro/src/DeltaBundler/DeltaCalculator.js | 17 +- .../__tests__/DeltaCalculatorContext-test.js | 14 +- .../__tests__/traverseDependencies-test.js | 870 +++++++++++++----- .../metro/src/DeltaBundler/graphOperations.js | 44 +- .../src/lib/__tests__/contextModule-test.js | 1 - packages/metro/src/lib/contextModule.js | 3 +- packages/metro/src/lib/transformHelpers.js | 2 +- 7 files changed, 660 insertions(+), 291 deletions(-) diff --git a/packages/metro/src/DeltaBundler/DeltaCalculator.js b/packages/metro/src/DeltaBundler/DeltaCalculator.js index dc999bd4fe..a09ae4abf9 100644 --- a/packages/metro/src/DeltaBundler/DeltaCalculator.js +++ b/packages/metro/src/DeltaBundler/DeltaCalculator.js @@ -10,7 +10,7 @@ 'use strict'; -import {getContextModulesMatchingFilePath} from './graphOperations'; +import {markModifiedContextModules} from './graphOperations'; import { createGraph, initialTraverseDependencies, @@ -242,18 +242,11 @@ class DeltaCalculator extends EventEmitter { // to enable users to opt out. if (this._options.unstable_allowRequireContext) { // Check if any added or removed files are matched in a context module. - // We only need to do this for added files because deleted files will contain a context - // module as an inverse dependency. + // We only need to do this for added files because (1) deleted files will have a context + // module as an inverse dependency, (2) modified files don't invalidate the contents + // of the context module. addedFiles.forEach(filePath => { - const contextModulePaths = getContextModulesMatchingFilePath( - this._graph, - filePath, - modifiedFiles, - ); - - contextModulePaths.forEach(modulePath => { - modifiedFiles.add(modulePath); - }); + markModifiedContextModules(this._graph, filePath, modifiedFiles); }); } diff --git a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js index 48684fbae9..0c13837711 100644 --- a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js @@ -15,13 +15,13 @@ jest.mock('../../Bundler'); const initialTraverseDependencies = jest.fn(); const traverseDependencies = jest.fn(); const reorderGraph = jest.fn(); -const getContextModulesMatchingFilePath = jest.fn(); +const markModifiedContextModules = jest.fn(); jest.doMock('../graphOperations', () => ({ ...jest.requireActual('../graphOperations'), initialTraverseDependencies, traverseDependencies, reorderGraph, - getContextModulesMatchingFilePath, + markModifiedContextModules, })); const DeltaCalculator = require('../DeltaCalculator'); @@ -61,7 +61,7 @@ describe('DeltaCalculator', () => { beforeEach(async () => { fileWatcher = new EventEmitter(); - getContextModulesMatchingFilePath.mockReset(); + markModifiedContextModules.mockReset(); // ~/ // ├─ bundle // ├─ ctx/ @@ -203,16 +203,16 @@ describe('DeltaCalculator', () => { }); // We rely on inverse dependencies to update a context module. - expect(getContextModulesMatchingFilePath).not.toBeCalled(); + expect(markModifiedContextModules).not.toBeCalled(); expect(traverseDependencies.mock.calls.length).toBe(1); }); it('should calculate a delta after adding/removing dependencies', async () => { // Emulate matching the deleted file against the first context module - getContextModulesMatchingFilePath.mockImplementationOnce( + markModifiedContextModules.mockImplementationOnce( (graph, filePath, modifiedDependencies) => { - return ['/ctx?ctx=xxx']; + modifiedDependencies.add('/ctx?ctx=xxx'); }, ); @@ -246,7 +246,7 @@ describe('DeltaCalculator', () => { }); // Test if the new module matches any of the context modules. - expect(getContextModulesMatchingFilePath).toBeCalledWith( + expect(markModifiedContextModules).toBeCalledWith( expect.anything(), '/qux', new Set(['/ctx?ctx=xxx']), diff --git a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js index ff622d5db5..2efe4580a6 100644 --- a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js @@ -28,9 +28,10 @@ const { initialTraverseDependencies, reorderGraph, traverseDependencies: traverseDependenciesImpl, - getContextModulesMatchingFilePath, + markModifiedContextModules, } = require('../graphOperations'); - +import {deriveAbsolutePathFromContext} from '../../lib/contextModule'; +import type {RequireContext} from '../../lib/contextModule'; const {objectContaining} = expect; type DependencyDataInput = $Shape; @@ -94,10 +95,32 @@ const Actions = { addDependency( path: string, dependencyPath: string, - position?: ?number, - name?: string, - data?: DependencyDataInput, + options: { + position?: ?number, + name?: string, + data?: DependencyDataInput, + } = {}, + ) { + Actions.addInferredDependency(path, dependencyPath, options); + files.add(path); + }, + + addInferredDependency( + path: string, + dependencyPath: string, + { + position, + name, + data, + }: { + position?: ?number, + name?: string, + data?: DependencyDataInput, + } = {}, ) { + if (!mockedDependencies.has(path)) { + Actions.createFile(path); + } const deps = getMockDependency(path); const depName = name ?? dependencyPath.replace('/', ''); const key = require('crypto') @@ -123,11 +146,15 @@ const Actions = { mockedDependencyTree.set(path, deps); mockedDependencies.add(dependencyPath); + }, + + removeDependency(path: string, dependencyPath: string) { + Actions.removeInferredDependency(path, dependencyPath); files.add(path); }, - removeDependency(path: string, dependencyPath: string) { + removeInferredDependency(path: string, dependencyPath: string) { const deps = nullthrows(mockedDependencyTree.get(path)); const index = deps.findIndex(({path}) => path === dependencyPath); @@ -135,8 +162,6 @@ const Actions = { deps.splice(index, 1); mockedDependencyTree.set(path, deps); } - - files.add(path); }, }; @@ -286,37 +311,46 @@ async function traverseDependencies( return delta; } +function getMatchingContextModules(graph: Graph, filePath: string) { + const contextPaths = new Set(); + markModifiedContextModules(graph, filePath, contextPaths); + return contextPaths; +} + beforeEach(async () => { mockedDependencies = new Set(); mockedDependencyTree = new Map(); - mockTransform = jest.fn().mockImplementation(async path => { - return { - dependencies: (mockedDependencyTree.get(path) || []).map(dep => ({ - name: dep.name, - data: { - asyncType: null, - locs: [], - key: dep.data.key, - ...dep.data, - }, - })), - getSource: () => Buffer.from('// source'), - output: [ - { + mockTransform = jest + .fn() + .mockImplementation(async (path: string, context: ?RequireContext) => { + return { + dependencies: (mockedDependencyTree.get(path) || []).map(dep => ({ + name: dep.name, data: { - code: '// code', - lineCount: 1, - map: [], + asyncType: null, + locs: [], + key: dep.data.key, + ...dep.data, }, - type: 'js/module', - }, - ], - }; - }); + })), + getSource: () => + Buffer.from('// source' + (context ? ' (context)' : '')), + output: [ + { + data: { + code: '// code' + (context ? ' (context)' : ''), + lineCount: 1, + map: [], + }, + type: 'js/module', + }, + ], + }; + }); options = { - unstable_allowRequireContext: true, + unstable_allowRequireContext: false, experimentalImportBundleSupport: false, onProgress: null, resolve: (from: string, to: string) => { @@ -625,7 +659,7 @@ describe('edge cases', () => { await initialTraverseDependencies(graph, options); Actions.removeDependency('/foo', '/baz'); - Actions.addDependency('/foo', '/baz.js', null, 'baz'); + Actions.addDependency('/foo', '/baz.js', {name: 'baz'}); expect( getPaths(await traverseDependencies([...files], graph, options)), @@ -851,7 +885,7 @@ describe('edge cases', () => { it('maintain the order of module dependencies', async () => { await initialTraverseDependencies(graph, options); - Actions.addDependency('/foo', '/qux', 0); + Actions.addDependency('/foo', '/qux', {position: 0}); expect( getPaths(await traverseDependencies([...files], graph, options)), @@ -1400,138 +1434,6 @@ describe('edge cases', () => { }); }); - it('should add a context module and pass resolved context to the transform function', async () => { - // Create a context module - Actions.addDependency('/bundle', '/ctx', undefined, undefined, { - contextParams: { - recursive: true, - mode: 'sync', - filter: {pattern: '.*', flags: ''}, - }, - }); - files.clear(); - - await initialTraverseDependencies(graph, options); - - expect(mockTransform).toHaveBeenCalledTimes(5); - // Ensure the resolved context is passed to the transform function - // this triggers the context module generation. - expect(mockTransform).toHaveBeenNthCalledWith(3, '/ctx', { - absolutePath: '/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a', - filter: /.*/, - from: '/ctx', - id: '/ctx sync recursive /.*/', - mode: 'sync', - recursive: true, - }); - }); - - it('should remove resolved context module when a dependency is removed', async () => { - // Create a context module - Actions.addDependency('/bundle', '/ctx', undefined, undefined, { - contextParams: { - recursive: true, - mode: 'sync', - filter: {pattern: '.*', flags: ''}, - }, - }); - files.clear(); - - await initialTraverseDependencies(graph, options); - - // Ensure the resolved context exists - expect(graph.privateState.resolvedContext).toEqual( - new Map([ - [ - '/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a', - expect.anything(), - ], - ]), - ); - - Actions.removeDependency('/bundle', '/ctx'); - - expect( - getPaths(await traverseDependencies([...files], graph, options)), - ).toEqual({ - added: new Set([]), - deleted: new Set(['/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a']), - modified: new Set(['/bundle']), - }); - - // Ensure the resolved context was removed - expect(graph.privateState.resolvedContext).toEqual(new Map()); - }); - - it('should modify the context module when its dependencies change', async () => { - // Create a context module - Actions.addDependency('/bundle', '/ctx', undefined, undefined, { - contextParams: { - recursive: true, - mode: 'sync', - filter: {pattern: '.*', flags: ''}, - }, - }); - - Actions.createFile('/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a'); - files.clear(); - - await initialTraverseDependencies(graph, options); - - Actions.createFile('/ctx/foo'); - Actions.addDependency( - '/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a', - '/ctx/foo', - ); - - expect( - getPaths(await traverseDependencies([...files], graph, options)), - ).toEqual({ - added: new Set([]), - modified: new Set(['/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a']), - deleted: new Set([]), - }); - }); - - it('should modify a single context module from different origins when the dependencies change', async () => { - // Create a context module - Actions.addDependency('/bundle', '/ctx', undefined, undefined, { - contextParams: { - recursive: true, - mode: 'sync', - filter: {pattern: '.*', flags: ''}, - }, - }); - - Actions.createFile('/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a'); - - Actions.addDependency('/foo', '/ctx', undefined, undefined, { - contextParams: { - recursive: true, - mode: 'sync', - filter: {pattern: '.*', flags: ''}, - }, - }); - - files.clear(); - - await initialTraverseDependencies(graph, options); - - Actions.createFile('/ctx/foo'); - Actions.addDependency( - '/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a', - '/ctx/foo', - ); - - expect( - getPaths(await traverseDependencies([...files], graph, options)), - ).toEqual({ - added: new Set([]), - modified: new Set(['/ctx?ctx=7855fe0b1074e361e66650cb2e83816836dc652a']), - deleted: new Set([]), - }); - }); - describe('lazy traversal of async imports', () => { let localOptions; beforeEach(() => { @@ -1543,8 +1445,10 @@ describe('edge cases', () => { it('async dependencies and their deps are omitted from the initial graph', async () => { Actions.removeDependency('/bundle', '/foo'); - Actions.addDependency('/bundle', '/foo', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bundle', '/foo', { + data: { + asyncType: 'async', + }, }); /* @@ -1570,8 +1474,10 @@ describe('edge cases', () => { it('initial async dependencies are collected in importBundleNames', async () => { Actions.removeDependency('/bundle', '/foo'); - Actions.addDependency('/bundle', '/foo', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bundle', '/foo', { + data: { + asyncType: 'async', + }, }); /* @@ -1591,8 +1497,10 @@ describe('edge cases', () => { it('adding a new async dependency updates importBundleNames', async () => { Actions.removeDependency('/bundle', '/foo'); - Actions.addDependency('/bundle', '/foo', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bundle', '/foo', { + data: { + asyncType: 'async', + }, }); /* @@ -1610,8 +1518,10 @@ describe('edge cases', () => { files.clear(); Actions.createFile('/quux'); - Actions.addDependency('/bundle', '/quux', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bundle', '/quux', { + data: { + asyncType: 'async', + }, }); /* @@ -1651,8 +1561,10 @@ describe('edge cases', () => { files.clear(); Actions.removeDependency('/bundle', '/foo'); - Actions.addDependency('/bundle', '/foo', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bundle', '/foo', { + data: { + asyncType: 'async', + }, }); /* @@ -1691,8 +1603,10 @@ describe('edge cases', () => { files.clear(); Actions.removeDependency('/bundle', '/foo'); - Actions.addDependency('/bundle', '/foo', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bundle', '/foo', { + data: { + asyncType: 'async', + }, }); /* @@ -1712,8 +1626,10 @@ describe('edge cases', () => { it('changing an async dependency to sync is an addition', async () => { Actions.removeDependency('/bundle', '/foo'); - Actions.addDependency('/bundle', '/foo', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bundle', '/foo', { + data: { + asyncType: 'async', + }, }); /* @@ -1755,8 +1671,10 @@ describe('edge cases', () => { it('changing an async dependency to sync updates importBundleNames', async () => { Actions.removeDependency('/bundle', '/foo'); - Actions.addDependency('/bundle', '/foo', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bundle', '/foo', { + data: { + asyncType: 'async', + }, }); /* @@ -1792,8 +1710,10 @@ describe('edge cases', () => { }); it('initial graph can have async+sync edges to the same module', async () => { - Actions.addDependency('/bar', '/foo', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bar', '/foo', { + data: { + asyncType: 'async', + }, }); /* @@ -1830,8 +1750,10 @@ describe('edge cases', () => { */ await initialTraverseDependencies(graph, options); - Actions.addDependency('/bar', '/foo', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bar', '/foo', { + data: { + asyncType: 'async', + }, }); /* @@ -1860,8 +1782,10 @@ describe('edge cases', () => { it('adding a sync edge brings in a module that is already the target of an async edge', async () => { Actions.removeDependency('/foo', '/bar'); - Actions.addDependency('/foo', '/bar', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/foo', '/bar', { + data: { + asyncType: 'async', + }, }); /* @@ -1905,8 +1829,10 @@ describe('edge cases', () => { it('on initial traversal, modules are not kept alive by a cycle with an async dep', async () => { Actions.removeDependency('/foo', '/bar'); - Actions.addDependency('/foo', '/bar', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/foo', '/bar', { + data: { + asyncType: 'async', + }, }); Actions.addDependency('/bar', '/foo'); Actions.removeDependency('/bundle', '/foo'); @@ -1935,8 +1861,10 @@ describe('edge cases', () => { it('on incremental traversal, modules are not kept alive by a cycle with an async dep - deleting the sync edge in a delta', async () => { Actions.removeDependency('/foo', '/bar'); - Actions.addDependency('/foo', '/bar', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/foo', '/bar', { + data: { + asyncType: 'async', + }, }); Actions.addDependency('/bar', '/foo'); @@ -2002,8 +1930,10 @@ describe('edge cases', () => { await initialTraverseDependencies(graph, localOptions); files.clear(); - Actions.addDependency('/foo', '/bar', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/foo', '/bar', { + data: { + asyncType: 'async', + }, }); /* @@ -2050,8 +1980,10 @@ describe('edge cases', () => { await initialTraverseDependencies(graph, localOptions); files.clear(); - Actions.addDependency('/foo', '/bar', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/foo', '/bar', { + data: { + asyncType: 'async', + }, }); Actions.removeDependency('/bundle', '/foo'); @@ -2106,7 +2038,7 @@ describe('edge cases', () => { await initialTraverseDependencies(graph, options); // We're adding a new reference from bundle to foo. - Actions.addDependency('/bundle', '/foo', 0, 'foo.js'); + Actions.addDependency('/bundle', '/foo', {position: 0, name: 'foo.js'}); expect( getPaths(await traverseDependencies([...files], graph, options)), @@ -2178,8 +2110,8 @@ describe('edge cases', () => { let deferredSlow; let fastResolved = false; - localMockTransform.mockImplementation(async path => { - const result = await mockTransform(path); + localMockTransform.mockImplementation(async (path, context) => { + const result = await mockTransform(path, context); if (path === slowPath && !fastResolved) { // Return a Promise that won't be resolved after fastPath. @@ -2239,14 +2171,498 @@ describe('edge cases', () => { mockTransform.mockClear(); setMockTransformOrder('/foo', '/bar'); await assertOrder(); - expect(mockTransform).toHaveBeenCalledWith('/foo'); - expect(mockTransform).toHaveBeenCalledWith('/bar'); + expect(mockTransform).toHaveBeenCalledWith('/foo', undefined); + expect(mockTransform).toHaveBeenCalledWith('/bar', undefined); mockTransform.mockClear(); setMockTransformOrder('/bar', '/foo'); await assertOrder(); - expect(mockTransform).toHaveBeenCalledWith('/bar'); - expect(mockTransform).toHaveBeenCalledWith('/foo'); + expect(mockTransform).toHaveBeenCalledWith('/bar', undefined); + expect(mockTransform).toHaveBeenCalledWith('/foo', undefined); + }); +}); + +describe('require.context', () => { + let localOptions; + beforeEach(() => { + localOptions = { + ...options, + unstable_allowRequireContext: true, + }; + }); + + const ctxParams = { + recursive: true, + mode: 'sync', + filter: {pattern: '.*', flags: ''}, + }; + + const ctxResolved = { + recursive: true, + mode: 'sync', + filter: /.*/, + from: '/ctx', + id: '/ctx sync recursive /.*/', + }; + + const ctxPath = deriveAbsolutePathFromContext('/ctx', ctxParams); + + it('a context module is created when the context exists in the initial graph', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // The transformer receives the arguments necessary to generate a context module + expect(mockTransform).toHaveBeenCalledWith(ctxPath, ctxResolved); + // Ensure the module has been created + expect(graph.dependencies.get(ctxPath)).not.toBe(undefined); + // No module at /ctx - that dependency turned into the context module + expect(graph.dependencies.get('/ctx')).toBe(undefined); + + // We can match paths against the created context + expect(getMatchingContextModules(graph, '/ctx/matched-file')).toEqual( + new Set([ctxPath]), + ); + expect(getMatchingContextModules(graph, '/no-match')).toEqual(new Set()); + }); + + it('a context module is created incrementally', async () => { + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Compute the new graph incrementally + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set([ctxPath]), + deleted: new Set([]), + modified: new Set(['/bundle']), + }); + + // The transformer receives the arguments necessary to generate a context module + expect(mockTransform).toHaveBeenCalledWith(ctxPath, ctxResolved); + + // We can match paths against the created context + expect(getMatchingContextModules(graph, '/ctx/matched-file')).toEqual( + new Set([ctxPath]), + ); + }); + + it('context exists in initial traversal and is then removed', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Remove the reference to the context module + Actions.removeDependency('/bundle', '/ctx'); + + // Compute the new graph incrementally + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set([]), + deleted: new Set([ctxPath]), + modified: new Set(['/bundle']), + }); + + // We can no longer match against this context because it has been deleted + expect(getMatchingContextModules(graph, '/ctx/matched-file')).toEqual( + new Set(), + ); + }); + + it('context + matched file exist in initial traversal and are then removed', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Create the file matched by the context + Actions.createFile('/ctx/matched-file'); + // Create a dependency between the context module and the new file, for mockTransform + Actions.addInferredDependency(ctxPath, '/ctx/matched-file'); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Ensure the context module and the matched file are in the graph + expect(graph.dependencies.get(ctxPath)).not.toBe(undefined); + expect(graph.dependencies.get('/ctx/matched-file')).not.toBe(undefined); + + // Remove the reference to the context module + Actions.removeDependency('/bundle', '/ctx'); + + // Compute the new graph incrementally + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set([]), + deleted: new Set([ctxPath, '/ctx/matched-file']), + modified: new Set(['/bundle']), + }); + + // We can no longer match against this context because it has been deleted + expect(getMatchingContextModules(graph, '/ctx/matched-file')).toEqual( + new Set(), + ); + }); + + it('remove a matched file incrementally from a context', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Create the file matched by the context + Actions.createFile('/ctx/matched-file'); + // Create a dependency between the context module and the new file, for mockTransform + Actions.addInferredDependency(ctxPath, '/ctx/matched-file'); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Ensure we recorded an inverse dependency between the matched file and the context module + expect([ + ...nullthrows(graph.dependencies.get('/ctx/matched-file')) + .inverseDependencies, + ]).toEqual([ctxPath]); + + // Delete the matched file + Actions.deleteFile('/ctx/matched-file'); + + // Propagate the deletion to the context module (normally DeltaCalculator's responsibility) + Actions.removeInferredDependency(ctxPath, '/ctx/matched-file'); + Actions.modifyFile(ctxPath); + + // Compute the new graph incrementally + mockTransform.mockClear(); + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set([]), + modified: new Set([ctxPath]), + deleted: new Set(['/ctx/matched-file']), + }); + + // Ensure the incremental traversal re-transformed the context module + expect(mockTransform).toHaveBeenCalledWith(ctxPath, ctxResolved); + }); + + it('modify a matched file incrementally', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Create the file matched by the context + Actions.createFile('/ctx/matched-file'); + // Create a dependency between the context module and the new file, for mockTransform + Actions.addInferredDependency(ctxPath, '/ctx/matched-file'); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Modify the matched file + Actions.modifyFile('/ctx/matched-file'); + + // We do not propagate the modification to the context module. (See DeltaCalculator) + + // Compute the new graph incrementally + mockTransform.mockClear(); + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set([]), + modified: new Set(['/ctx/matched-file']), + deleted: new Set([]), + }); + + // Ensure the incremental traversal did not re-transform the context module + expect(mockTransform).not.toHaveBeenCalledWith(ctxPath, ctxResolved); + }); + + it('add a matched file incrementally to a context', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Create the file matched by the context + Actions.createFile('/ctx/matched-file'); + // Create a dependency between the context module and the new file, for mockTransform + Actions.addInferredDependency(ctxPath, '/ctx/matched-file'); + // Propagate the addition to the context module (normally DeltaCalculator's responsibility) + markModifiedContextModules(graph, '/ctx/matched-file', files); + + // Compute the new graph incrementally + mockTransform.mockClear(); + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set(['/ctx/matched-file']), + modified: new Set([ctxPath]), + deleted: new Set([]), + }); + + // Ensure the incremental traversal re-transformed the context module + expect(mockTransform).toHaveBeenCalledWith(ctxPath, ctxResolved); + }); + + it('add a matched file incrementally to a context with two references', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Create another reference to the same context module + Actions.addDependency('/foo', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Create the file matched by the context + Actions.createFile('/ctx/matched-file'); + Actions.addInferredDependency(ctxPath, '/ctx/matched-file'); + // Propagate the addition to the context module (normally DeltaCalculator's responsibility) + markModifiedContextModules(graph, '/ctx/matched-file', files); + + // Compute the new graph incrementally + mockTransform.mockClear(); + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set(['/ctx/matched-file']), + modified: new Set([ctxPath]), + deleted: new Set([]), + }); + + // Ensure the incremental traversal re-transformed the context module + expect(mockTransform).toHaveBeenCalledWith(ctxPath, ctxResolved); + }); + + it('remove only one of two references to a context module', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Create another reference to the same context module + Actions.addDependency('/foo', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Remove one reference + Actions.removeDependency('/bundle', '/ctx'); + + // Compute the new graph incrementally + mockTransform.mockClear(); + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set([]), + modified: new Set(['/bundle']), + deleted: new Set([]), + }); + + // Ensure the incremental traversal did not re-transform the context module + expect(mockTransform).not.toHaveBeenCalledWith(ctxPath, ctxResolved); + + // We can still match against this context because it has not been deleted + expect(getMatchingContextModules(graph, '/ctx/matched-file')).toEqual( + new Set([ctxPath]), + ); + }); + + describe('when two distinct contexts match the same file', () => { + const narrowCtxParams = { + recursive: true, + mode: 'sync', + filter: {pattern: '\\./narrow/.*', flags: ''}, + }; + + const narrowCtxResolved = { + recursive: true, + mode: 'sync', + filter: /\.\/narrow\/.*/, + from: '/ctx', + id: '/ctx sync recursive /\\.\\/narrow\\/.*/', + }; + + const narrowCtxPath = deriveAbsolutePathFromContext( + '/ctx', + narrowCtxParams, + ); + + it('creates two context modules in the initial traversal', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Create a different context module with the same base path and origin module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: narrowCtxParams, + key: '/ctx2', + }, + }); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // The transformer receives the arguments necessary to generate each context module + expect(mockTransform).toHaveBeenCalledWith(ctxPath, ctxResolved); + expect(mockTransform).toHaveBeenCalledWith( + narrowCtxPath, + narrowCtxResolved, + ); + // Ensure the modules have been created + expect(graph.dependencies.get(ctxPath)).not.toBe(undefined); + expect(graph.dependencies.get(narrowCtxPath)).not.toBe(undefined); + // No module at /ctx or /ctx/narrow - those dependencies turned into the context modules + expect(graph.dependencies.get('/ctx')).toBe(undefined); + expect(graph.dependencies.get('/ctx/narrow')).toBe(undefined); + // Not conflating the key with the virtual path + expect(graph.dependencies.get('/ctx2')).toBe(undefined); + + // We can match paths against the contexts + expect(getMatchingContextModules(graph, '/ctx/matched-file')).toEqual( + new Set([ctxPath]), + ); + expect( + getMatchingContextModules(graph, '/ctx/narrow/matched-file'), + ).toEqual(new Set([ctxPath, narrowCtxPath])); + }); + + it('add a file matched by both contexts', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Create a different context module with the same base path and origin module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: narrowCtxParams, + key: '/ctx2', + }, + }); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Create the file matched by the contexts + Actions.createFile('/ctx/narrow/matched-file'); + Actions.addInferredDependency(ctxPath, '/ctx/narrow/matched-file'); + Actions.addInferredDependency(narrowCtxPath, '/ctx/narrow/matched-file'); + // Propagate the addition to the context modules (normally DeltaCalculator's responsibility) + markModifiedContextModules(graph, '/ctx/narrow/matched-file', files); + + // Compute the new graph incrementally + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set(['/ctx/narrow/matched-file']), + modified: new Set([ctxPath, narrowCtxPath]), + deleted: new Set([]), + }); + }); + + it('deleting one context does not delete a file matched by both contexts', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + }, + }); + + // Create a different context module with the same base path and origin module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: narrowCtxParams, + key: '/ctx2', + }, + }); + + // Create the file matched by the contexts + Actions.createFile('/ctx/narrow/matched-file'); + Actions.addInferredDependency(ctxPath, '/ctx/narrow/matched-file'); + Actions.addInferredDependency(narrowCtxPath, '/ctx/narrow/matched-file'); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Remove the reference to one of the context modules + Actions.removeDependency('/bundle', '/ctx'); + + // Compute the new graph incrementally + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set([]), + modified: new Set(['/bundle']), + deleted: new Set([ctxPath]), + }); + }); }); }); @@ -2335,7 +2751,7 @@ describe('optional dependencies', () => { const createMockTransform = (notOptional?: string[]) => { /* $FlowFixMe[missing-this-annot] The 'this' type annotation(s) required by * Flow's LTI update could not be added via codemod */ - return async function (path: string) { + return async function (path: string, context: ?RequireContext) { // $FlowFixMe[object-this-reference]: transform should not be bound to anything const result = await mockTransform.apply(this, arguments); return { @@ -2401,8 +2817,10 @@ describe('optional dependencies', () => { describe('parallel edges', () => { it('add twice w/ same name, build and remove once', async () => { // Create a second edge between /foo and /bar. - Actions.addDependency('/foo', '/bar', undefined, undefined, { - key: 'bar-second-key', + Actions.addDependency('/foo', '/bar', { + data: { + key: 'bar-second-key', + }, }); await initialTraverseDependencies(graph, options); @@ -2421,8 +2839,10 @@ describe('parallel edges', () => { it('add twice w/ same name, build and remove twice', async () => { // Create a second edge between /foo and /bar. - Actions.addDependency('/foo', '/bar', undefined, undefined, { - key: 'bar-second-key', + Actions.addDependency('/foo', '/bar', { + data: { + key: 'bar-second-key', + }, }); await initialTraverseDependencies(graph, options); @@ -2442,7 +2862,7 @@ describe('parallel edges', () => { it('add twice w/ different names, build and remove once', async () => { // Create a second edge between /foo and /bar, with a different `name`. - Actions.addDependency('/foo', '/bar', undefined, 'bar-second'); + Actions.addDependency('/foo', '/bar', {name: 'bar-second'}); await initialTraverseDependencies(graph, options); @@ -2460,7 +2880,7 @@ describe('parallel edges', () => { it('add twice w/ different names, build and remove twice', async () => { // Create a second edge between /foo and /bar, with a different `name`. - Actions.addDependency('/foo', '/bar', undefined, 'bar-second'); + Actions.addDependency('/foo', '/bar', {name: 'bar-second'}); await initialTraverseDependencies(graph, options); @@ -2477,35 +2897,3 @@ describe('parallel edges', () => { }); }); }); - -describe('getContextModulesMatchingFilePath', () => { - it(`matches a file against internally resolved context modules`, () => { - graph.privateState.resolvedContext.set('/ctx?ctx=xxx', { - absolutePath: '/ctx?ctx=xxx', - from: '/', - recursive: true, - filter: /.*/, - }); - graph.privateState.resolvedContext.set('/ctx?ctx=xxx2', { - absolutePath: '/ctx?ctx=xxx2', - from: '/', - recursive: true, - filter: /foobar/, - }); - - // This won't match - graph.privateState.resolvedContext.set('/ctx?ctx=xxx3', { - absolutePath: '/ctx?ctx=xxx3', - from: '/', - recursive: true, - filter: /no-match/, - }); - - const contextModules = getContextModulesMatchingFilePath( - graph, - '/ctx/foobar', - new Set(), - ); - expect(contextModules).toEqual(['/ctx?ctx=xxx', '/ctx?ctx=xxx2']); - }); -}); diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index 71b191aec1..418734587a 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -72,7 +72,7 @@ type NodeColor = // Private state for the graph that persists between operations. export opaque type PrivateState = { /** Resolved context parameters from `require.context`. */ - +resolvedContext: Map, + +resolvedContexts: Map, +gc: { // GC state for nodes in the graph (graph.dependencies) +color: Map, @@ -89,7 +89,7 @@ function createGraph(options: GraphInputOptions): Graph { dependencies: new Map(), importBundleNames: new Set(), privateState: { - resolvedContext: new Map(), + resolvedContexts: new Map(), gc: { color: new Map(), possibleCycleRoots: new Set(), @@ -281,17 +281,13 @@ async function processModule( contextParams: ?RequireContextParams = graph.dependencies.get(path) ?.contextParams, ): Promise> { - // Transform the file via the given option. - // TODO: Unbind the transform method from options - let result; + let resolvedContext; if (contextParams != null) { - const resolvedContext = nullthrows( - graph.privateState.resolvedContext.get(path), - ); - result = await options.transform(resolvedContext.from, resolvedContext); - } else { - result = await options.transform(path); + resolvedContext = nullthrows(graph.privateState.resolvedContexts.get(path)); } + // Transform the file via the given option. + // TODO: Unbind the transform method from options + const result = await options.transform(path, resolvedContext); // Get the absolute path of all sub-dependencies (some of them could have been // moved but maintain the same relative path). @@ -459,10 +455,6 @@ function removeDependency( decrementImportBundleReference(dependency, graph); } - if (dependency.data.data.contextParams) { - graph.privateState.resolvedContext.delete(dependency.absolutePath); - } - const module = graph.dependencies.get(absolutePath); if (!module) { @@ -485,21 +477,19 @@ function removeDependency( /** * Collect a list of context modules which include a given file. */ -function getContextModulesMatchingFilePath( +function markModifiedContextModules( graph: Graph, filePath: string, - modifiedFiles: Set, -): string[] { - const modulePaths: string[] = []; - graph.privateState.resolvedContext.forEach(context => { + modifiedPaths: Set, +) { + for (const [absolutePath, context] of graph.privateState.resolvedContexts) { if ( - !modifiedFiles.has(context.absolutePath) && + !modifiedPaths.has(absolutePath) && fileMatchesContext(filePath, context) ) { - modulePaths.push(context.absolutePath); + modifiedPaths.add(absolutePath); } - }); - return modulePaths; + } } function resolveDependencies( @@ -523,7 +513,6 @@ function resolveDependencies( const resolvedContext: RequireContext = { id: getContextModuleId(from, contextParams), from, - absolutePath, mode: contextParams.mode, recursive: contextParams.recursive, filter: new RegExp( @@ -532,7 +521,7 @@ function resolveDependencies( ), }; - graph.privateState.resolvedContext.set(absolutePath, resolvedContext); + graph.privateState.resolvedContexts.set(absolutePath, resolvedContext); resolvedDep = { absolutePath, @@ -708,6 +697,7 @@ function freeModule(module: Module, graph: Graph, delta: Delta) { delta.earlyInverseDependencies.delete(module.path); graph.privateState.gc.possibleCycleRoots.delete(module.path); graph.privateState.gc.color.delete(module.path); + graph.privateState.resolvedContexts.delete(module.path); } // Mark a module as a possible cycle root @@ -826,5 +816,5 @@ module.exports = { initialTraverseDependencies, traverseDependencies, reorderGraph, - getContextModulesMatchingFilePath, + markModifiedContextModules, }; diff --git a/packages/metro/src/lib/__tests__/contextModule-test.js b/packages/metro/src/lib/__tests__/contextModule-test.js index 009f4dfa3d..f42a5d1350 100644 --- a/packages/metro/src/lib/__tests__/contextModule-test.js +++ b/packages/metro/src/lib/__tests__/contextModule-test.js @@ -54,7 +54,6 @@ describe('fileMatchesContext', () => { it(`matches files`, () => { expect( fileMatchesContext('/path/to/project/index.js', { - absolutePath: '...', id: '...', mode: 'lazy', from: '/path/to/project', diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js index c85cb50b48..24dcc1dbc4 100644 --- a/packages/metro/src/lib/contextModule.js +++ b/packages/metro/src/lib/contextModule.js @@ -26,9 +26,8 @@ export type RequireContext = $ReadOnly<{ from: string, + // TODO: derive this later, shouldn't be in the type id: string, - - absolutePath: string, }>; /** Get an ID for a context module. */ diff --git a/packages/metro/src/lib/transformHelpers.js b/packages/metro/src/lib/transformHelpers.js index 4dceb91d1b..59751f52a0 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -141,7 +141,7 @@ async function getTransformFn( const template = getContextModuleTemplate( requireContext.mode, - modulePath, + requireContext.from, files, requireContext.id, ); From c66d11264bc11e4caa422ab2ad3ae4938c370067 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 9 Aug 2022 12:33:33 +0100 Subject: [PATCH 24/38] Fix getTransformFn after absolute path change --- packages/metro/src/lib/transformHelpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metro/src/lib/transformHelpers.js b/packages/metro/src/lib/transformHelpers.js index 59751f52a0..7b6766e4d3 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -134,7 +134,7 @@ async function getTransformFn( // Search against all files, this is very expensive. // TODO: Maybe we could let the user specify which root to check against. - const files = graph.matchFilesWithContext(modulePath, { + const files = graph.matchFilesWithContext(requireContext.from, { filter: requireContext.filter, recursive: requireContext.recursive, }); From 7e4308eca4aebaf1b13f4174f603de7356b144ce Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 9 Aug 2022 13:31:31 +0100 Subject: [PATCH 25/38] Add require.context integration test --- .../require-context-test.js.snap | 83 +++++++++++++++++++ .../__tests__/require-context-test.js | 67 +++++++++++++++ .../basic_bundle/require-context/matching.js | 27 ++++++ .../require-context/mode-eager.js | 24 ++++++ .../require-context/mode-lazy-once.js | 24 ++++++ .../basic_bundle/require-context/mode-lazy.js | 24 ++++++ .../basic_bundle/require-context/mode-sync.js | 22 +++++ .../basic_bundle/require-context/subdir/a.js | 11 +++ .../basic_bundle/require-context/subdir/b.js | 11 +++ .../basic_bundle/require-context/subdir/c.js | 11 +++ .../require-context/subdir/nested/d.js | 11 +++ .../basic_bundle/require-context/utils.js | 44 ++++++++++ 12 files changed, 359 insertions(+) create mode 100644 packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap create mode 100644 packages/metro/src/integration_tests/__tests__/require-context-test.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/matching.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/mode-eager.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/mode-lazy-once.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/mode-lazy.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/mode-sync.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/subdir/a.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/subdir/b.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/subdir/c.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/subdir/nested/d.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/utils.js diff --git a/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap b/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap new file mode 100644 index 0000000000..d761d3933e --- /dev/null +++ b/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`require-context/matching.js 1`] = ` +Object { + "ab": Array [ + "./a.js", + "./b.js", + ], + "abc": Array [ + "./a.js", + "./b.js", + "./c.js", + ], + "abcd": Array [ + "./a.js", + "./b.js", + "./c.js", + "./nested/d.js", + ], +} +`; + +exports[`require-context/mode-eager.js 1`] = ` +Object { + "./a.js": "a", + "./b.js": Object { + "default": "b", + }, + "./c.js": "c", + "./nested/d.js": "d", +} +`; + +exports[`require-context/mode-lazy.js 1`] = ` +Object { + "./a.js": Object { + "0": "a", + "default": "a", + }, + "./b.js": Object { + "default": "b", + }, + "./c.js": Object { + "0": "c", + "default": "c", + }, + "./nested/d.js": Object { + "0": "d", + "default": "d", + }, +} +`; + +exports[`require-context/mode-lazy-once.js 1`] = ` +Object { + "./a.js": Object { + "0": "a", + "default": "a", + }, + "./b.js": Object { + "default": "b", + }, + "./c.js": Object { + "0": "c", + "default": "c", + }, + "./nested/d.js": Object { + "0": "d", + "default": "d", + }, +} +`; + +exports[`require-context/mode-sync.js 1`] = ` +Object { + "./a.js": "a", + "./b.js": Object { + "default": "b", + }, + "./c.js": "c", + "./nested/d.js": "d", +} +`; diff --git a/packages/metro/src/integration_tests/__tests__/require-context-test.js b/packages/metro/src/integration_tests/__tests__/require-context-test.js new file mode 100644 index 0000000000..5f21d0ac83 --- /dev/null +++ b/packages/metro/src/integration_tests/__tests__/require-context-test.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. + * + * @emails oncall+metro_bundler + * @format + */ + +'use strict'; + +const Metro = require('../../..'); +const execBundle = require('../execBundle'); + +jest.unmock('cosmiconfig'); + +jest.setTimeout(30 * 1000); + +it('require-context/matching.js', async () => { + await expect( + execTest('require-context/matching.js'), + ).resolves.toMatchSnapshot(); +}); + +it('require-context/mode-lazy.js', async () => { + await expect( + execTest('require-context/mode-lazy.js'), + ).resolves.toMatchSnapshot(); +}); + +it('require-context/mode-lazy-once.js', async () => { + await expect( + execTest('require-context/mode-lazy-once.js'), + ).resolves.toMatchSnapshot(); +}); + +it('require-context/mode-eager.js', async () => { + await expect( + execTest('require-context/mode-eager.js'), + ).resolves.toMatchSnapshot(); +}); + +it('require-context/mode-sync.js', async () => { + await expect( + execTest('require-context/mode-sync.js'), + ).resolves.toMatchSnapshot(); +}); + +async function execTest(entry) { + const config = await Metro.loadConfig( + { + config: require.resolve('../metro.config.js'), + }, + { + transformer: { + unstable_allowRequireContext: true, + }, + }, + ); + + const result = await Metro.runBuild(config, { + entry, + }); + + return execBundle(result.code); +} diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/matching.js b/packages/metro/src/integration_tests/basic_bundle/require-context/matching.js new file mode 100644 index 0000000000..214850bcce --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/matching.js @@ -0,0 +1,27 @@ +/** + * 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-local + */ + +import type {RequireWithContext} from './utils'; + +declare var require: RequireWithContext; + +const ab = require.context('./subdir', false, /\/(a|b)\.js$/); +const abc = require.context('./subdir', false); +const abcd = require.context('./subdir', true); + +function main() { + return { + ab: ab.keys(), + abc: abc.keys(), + abcd: abcd.keys(), + }; +} + +module.exports = (main(): mixed); diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/mode-eager.js b/packages/metro/src/integration_tests/basic_bundle/require-context/mode-eager.js new file mode 100644 index 0000000000..893004c125 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/mode-eager.js @@ -0,0 +1,24 @@ +/** + * 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-local + */ + +import {copyContextToObject, awaitProperties} from './utils'; +import type {RequireWithContext} from './utils'; + +declare var require: RequireWithContext; + +function main() { + return awaitProperties( + copyContextToObject( + require.context('./subdir', undefined, undefined, 'eager'), + ), + ); +} + +module.exports = (main(): mixed); diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/mode-lazy-once.js b/packages/metro/src/integration_tests/basic_bundle/require-context/mode-lazy-once.js new file mode 100644 index 0000000000..d70c839b83 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/mode-lazy-once.js @@ -0,0 +1,24 @@ +/** + * 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-local + */ + +import {copyContextToObject, awaitProperties} from './utils'; +import type {RequireWithContext} from './utils'; + +declare var require: RequireWithContext; + +function main() { + return awaitProperties( + copyContextToObject( + require.context('./subdir', undefined, undefined, 'lazy-once'), + ), + ); +} + +module.exports = (main(): mixed); diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/mode-lazy.js b/packages/metro/src/integration_tests/basic_bundle/require-context/mode-lazy.js new file mode 100644 index 0000000000..827bd610a8 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/mode-lazy.js @@ -0,0 +1,24 @@ +/** + * 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-local + */ + +import {copyContextToObject, awaitProperties} from './utils'; +import type {RequireWithContext} from './utils'; + +declare var require: RequireWithContext; + +function main() { + return awaitProperties( + copyContextToObject( + require.context('./subdir', undefined, undefined, 'lazy'), + ), + ); +} + +module.exports = (main(): mixed); diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/mode-sync.js b/packages/metro/src/integration_tests/basic_bundle/require-context/mode-sync.js new file mode 100644 index 0000000000..72b6a0b37a --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/mode-sync.js @@ -0,0 +1,22 @@ +/** + * 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-local + */ + +import {copyContextToObject} from './utils'; +import type {RequireWithContext} from './utils'; + +declare var require: RequireWithContext; + +function main() { + return copyContextToObject( + require.context('./subdir', undefined, undefined, 'sync'), + ); +} + +module.exports = (main(): mixed); diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/a.js b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/a.js new file mode 100644 index 0000000000..2f2cc498ea --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/a.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 + * @flow strict + */ + +module.exports = 'a'; diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/b.js b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/b.js new file mode 100644 index 0000000000..548b11d439 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/b.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 + * @flow strict + */ + +export default 'b'; diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/c.js b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/c.js new file mode 100644 index 0000000000..aebd334cac --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/c.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 + * @flow strict + */ + +module.exports = 'c'; diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/nested/d.js b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/nested/d.js new file mode 100644 index 0000000000..15112d476c --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir/nested/d.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 + * @flow strict + */ + +module.exports = 'd'; diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/utils.js b/packages/metro/src/integration_tests/basic_bundle/require-context/utils.js new file mode 100644 index 0000000000..fa23225710 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/utils.js @@ -0,0 +1,44 @@ +/** + * 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 + */ + +type ContextModule = { + (key: string): T, + keys(): Array, +}; + +export type RequireWithContext = { + (id: string): any, + resolve: (id: string, options?: {paths?: Array, ...}) => string, + cache: any, + main: typeof module, + context( + name: string, + recursive?: boolean, + filter?: RegExp, + mode?: 'sync' | 'eager' | 'lazy' | 'lazy-once', + ): ContextModule, +}; + +export function copyContextToObject(ctx: ContextModule): { + [key: string]: T, +} { + return Object.fromEntries(ctx.keys().map(key => [key, ctx(key)])); +} + +export function awaitProperties( + obj: $ReadOnly<{[key: string]: Promise}>, +): Promise<{[key: string]: T}> { + const result = {}; + return Promise.all( + Object.keys(obj).map(key => { + return obj[key].then(value => (result[key] = value)); + }), + ).then(() => result); +} From 0795f6c8488cae0fd452133bc9e568825e7320b8 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 9 Aug 2022 13:32:05 +0100 Subject: [PATCH 26/38] Make order of keys in context module deterministic --- .../metro/src/lib/contextModuleTemplates.js | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/metro/src/lib/contextModuleTemplates.js b/packages/metro/src/lib/contextModuleTemplates.js index 75c628e957..ea94503a3f 100644 --- a/packages/metro/src/lib/contextModuleTemplates.js +++ b/packages/metro/src/lib/contextModuleTemplates.js @@ -18,28 +18,32 @@ function createFileMap( ): string { let mapString = ''; - files.map(file => { - let filePath = path.relative(modulePath, file); - - // NOTE(EvanBacon): I'd prefer we prevent the ability for a module to require itself (`require.context('./')`) - // but Webpack allows this, keeping it here provides better parity between bundlers. - - // Ensure relative file paths start with `./` so they match the - // patterns (filters) used to include them. - if (!filePath.startsWith('.')) { - filePath = `.${path.sep}` + filePath; - } - const key = JSON.stringify(filePath); - // NOTE(EvanBacon): Webpack uses `require.resolve` in order to load modules on demand, - // Metro doesn't have this functionality so it will use getters instead. Modules need to - // be loaded on demand because if we imported directly then users would get errors from importing - // a file without exports as soon as they create a new file and the context module is updated. - - // NOTE: The values are set to `enumerable` so the `context.keys()` method works as expected. - mapString += `${key}: { enumerable: true, get() { return ${processModule( - file, - )}; } },`; - }); + files + .slice() + // Sort for deterministic output + .sort() + .forEach(file => { + let filePath = path.relative(modulePath, file); + + // NOTE(EvanBacon): I'd prefer we prevent the ability for a module to require itself (`require.context('./')`) + // but Webpack allows this, keeping it here provides better parity between bundlers. + + // Ensure relative file paths start with `./` so they match the + // patterns (filters) used to include them. + if (!filePath.startsWith('.')) { + filePath = `.${path.sep}` + filePath; + } + const key = JSON.stringify(filePath); + // NOTE(EvanBacon): Webpack uses `require.resolve` in order to load modules on demand, + // Metro doesn't have this functionality so it will use getters instead. Modules need to + // be loaded on demand because if we imported directly then users would get errors from importing + // a file without exports as soon as they create a new file and the context module is updated. + + // NOTE: The values are set to `enumerable` so the `context.keys()` method works as expected. + mapString += `${key}: { enumerable: true, get() { return ${processModule( + file, + )}; } },`; + }); return `Object.defineProperties({}, {${mapString}})`; } From ec46ce0554f7bfb282d72c221a5373b00e597516 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 9 Aug 2022 13:40:35 +0100 Subject: [PATCH 27/38] Add integration test for require and context with the same first arg --- .../require-context-test.js.snap | 9 +++++++ .../__tests__/require-context-test.js | 6 +++++ .../basic_bundle/require-context/conflict.js | 27 +++++++++++++++++++ .../require-context/subdir-conflict/index.js | 1 + 4 files changed, 43 insertions(+) create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/conflict.js create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/subdir-conflict/index.js diff --git a/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap b/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap index d761d3933e..7013f6c850 100644 --- a/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap +++ b/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap @@ -1,5 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`require-context/conflict.js 1`] = ` +Object { + "contextModule": Object { + "./index.js": "contents of subdir-conflict/index.js", + }, + "normalModule": "contents of subdir-conflict/index.js", +} +`; + exports[`require-context/matching.js 1`] = ` Object { "ab": Array [ diff --git a/packages/metro/src/integration_tests/__tests__/require-context-test.js b/packages/metro/src/integration_tests/__tests__/require-context-test.js index 5f21d0ac83..2adceaf940 100644 --- a/packages/metro/src/integration_tests/__tests__/require-context-test.js +++ b/packages/metro/src/integration_tests/__tests__/require-context-test.js @@ -47,6 +47,12 @@ it('require-context/mode-sync.js', async () => { ).resolves.toMatchSnapshot(); }); +it('require-context/conflict.js', async () => { + await expect( + execTest('require-context/conflict.js'), + ).resolves.toMatchSnapshot(); +}); + async function execTest(entry) { const config = await Metro.loadConfig( { diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/conflict.js b/packages/metro/src/integration_tests/basic_bundle/require-context/conflict.js new file mode 100644 index 0000000000..b4005da693 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/conflict.js @@ -0,0 +1,27 @@ +/** + * 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-local + */ + +import type {RequireWithContext} from './utils'; + +import {copyContextToObject} from './utils'; + +declare var require: RequireWithContext; + +const normalModule = require('./subdir-conflict'); +const contextModule = require.context('./subdir-conflict'); + +function main() { + return { + normalModule, + contextModule: copyContextToObject(contextModule), + }; +} + +module.exports = (main(): mixed); diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/subdir-conflict/index.js b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir-conflict/index.js new file mode 100644 index 0000000000..700d3ae996 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir-conflict/index.js @@ -0,0 +1 @@ +module.exports = 'contents of subdir-conflict/index.js'; \ No newline at end of file From 2550dc8043bcf671c05301bc20c9d1ab24fdf626 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 9 Aug 2022 14:49:20 +0100 Subject: [PATCH 28/38] Remove contextParams from Module type and processModule args --- .../__tests__/DeltaCalculatorContext-test.js | 10 ---------- .../traverseDependencies-test.js.snap | 5 ----- .../metro/src/DeltaBundler/graphOperations.js | 19 ++----------------- packages/metro/src/DeltaBundler/types.flow.js | 1 - 4 files changed, 2 insertions(+), 33 deletions(-) diff --git a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js index 0c13837711..c98c7b6c2b 100644 --- a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js @@ -88,14 +88,6 @@ describe('DeltaCalculator', () => { name: 'ctx', }, path: '/ctx?ctx=xxx', - contextParams: { - recursive: true, - filter: { - pattern: '.*', - flags: '', - }, - mode: 'sync', - }, }; fooModule = { @@ -129,8 +121,6 @@ describe('DeltaCalculator', () => { ); }); - // Entry -> ctx -> [foo, bar] - afterEach(() => { deltaCalculator.end(); diff --git a/packages/metro/src/DeltaBundler/__tests__/__snapshots__/traverseDependencies-test.js.snap b/packages/metro/src/DeltaBundler/__tests__/__snapshots__/traverseDependencies-test.js.snap index ad26ca0027..b16bca6ac8 100644 --- a/packages/metro/src/DeltaBundler/__tests__/__snapshots__/traverseDependencies-test.js.snap +++ b/packages/metro/src/DeltaBundler/__tests__/__snapshots__/traverseDependencies-test.js.snap @@ -4,7 +4,6 @@ exports[`should do the initial traversal correctly 1`] = ` Object { "dependencies": Map { "/bundle" => Object { - "contextParams": undefined, "dependencies": Map { "C+7Hteo/D9vJXQ3UfzxbwnXaijM=" => Object { "absolutePath": "/foo", @@ -33,7 +32,6 @@ Object { "path": "/bundle", }, "/foo" => Object { - "contextParams": undefined, "dependencies": Map { "Ys23Ag/5IOWqZCw9QGaVDdHwH00=" => Object { "absolutePath": "/bar", @@ -75,7 +73,6 @@ Object { "path": "/foo", }, "/bar" => Object { - "contextParams": undefined, "dependencies": Map {}, "getSource": [Function], "inverseDependencies": Array [ @@ -94,7 +91,6 @@ Object { "path": "/bar", }, "/baz" => Object { - "contextParams": undefined, "dependencies": Map {}, "getSource": [Function], "inverseDependencies": Array [ @@ -133,7 +129,6 @@ exports[`should not traverse past the initial module if \`shallow\` is passed 1` Object { "dependencies": Map { "/bundle" => Object { - "contextParams": undefined, "dependencies": Map { "C+7Hteo/D9vJXQ3UfzxbwnXaijM=" => Object { "absolutePath": "/foo", diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index 418734587a..7568c99672 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -30,7 +30,6 @@ 'use strict'; -import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; import type {RequireContext} from '../lib/contextModule'; import type { Dependency, @@ -276,15 +275,8 @@ async function processModule( graph: Graph, delta: Delta, options: InternalOptions, - // This fallback is used when a new dependency is added after the initial bundle has been created - // the invocation comes from `traverseDependenciesForSingleFile`. - contextParams: ?RequireContextParams = graph.dependencies.get(path) - ?.contextParams, ): Promise> { - let resolvedContext; - if (contextParams != null) { - resolvedContext = nullthrows(graph.privateState.resolvedContexts.get(path)); - } + const resolvedContext = graph.privateState.resolvedContexts.get(path); // Transform the file via the given option. // TODO: Unbind the transform method from options const result = await options.transform(path, resolvedContext); @@ -308,7 +300,6 @@ async function processModule( // Update the module information. const module = { ...previousModule, - contextParams: contextParams ?? undefined, dependencies: new Map(previousDependencies), getSource: result.getSource, output: result.output, @@ -410,13 +401,7 @@ async function addDependency( delta.earlyInverseDependencies.set(path, new CountingSet()); options.onDependencyAdd(); - module = await processModule( - path, - graph, - delta, - options, - dependency.data.data.contextParams, - ); + module = await processModule(path, graph, delta, options); options.onDependencyAdded(); graph.dependencies.set(module.path, module); diff --git a/packages/metro/src/DeltaBundler/types.flow.js b/packages/metro/src/DeltaBundler/types.flow.js index f2cd6495a2..06fc355591 100644 --- a/packages/metro/src/DeltaBundler/types.flow.js +++ b/packages/metro/src/DeltaBundler/types.flow.js @@ -67,7 +67,6 @@ export type Dependency = { }; export type Module = { - +contextParams?: RequireContextParams, +dependencies: Map, +inverseDependencies: CountingSet, +output: $ReadOnlyArray, From d96a3bc7310f072b5ff6eff49381f7d28cfcefab Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 9 Aug 2022 19:42:58 +0100 Subject: [PATCH 29/38] Tighten incremental edge cases + add more tests --- .../__tests__/traverseDependencies-test.js | 85 +++++++++++++++++++ .../metro/src/DeltaBundler/graphOperations.js | 45 ++++++++-- 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js index 2efe4580a6..2b940845ca 100644 --- a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js @@ -2663,6 +2663,91 @@ describe('require.context', () => { deleted: new Set([ctxPath]), }); }); + + it('edge case: changing context params incrementally under the same key', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + key: '/ctx', + }, + }); + // Create the file matched by the contexts + Actions.createFile('/ctx/narrow/matched-file'); + Actions.addInferredDependency(ctxPath, '/ctx/narrow/matched-file'); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Remove the reference to one of the context modules + Actions.removeDependency('/bundle', '/ctx'); + // Replace it with a context with different params + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: narrowCtxParams, + key: '/ctx', + }, + }); + Actions.addInferredDependency(narrowCtxPath, '/ctx/narrow/matched-file'); + + // Compute the new graph incrementally + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set([narrowCtxPath]), + modified: new Set(['/bundle']), + deleted: new Set([ctxPath]), + }); + + // We can match paths against the updated context + expect(getMatchingContextModules(graph, '/ctx/matched-file')).toEqual( + new Set(), + ); + expect( + getMatchingContextModules(graph, '/ctx/narrow/matched-file'), + ).toEqual(new Set([narrowCtxPath])); + }); + }); + + it('edge case: replacing a generated context file with a file that happens to have the same name and key', async () => { + // Create a context module + Actions.addDependency('/bundle', '/ctx', { + data: { + contextParams: ctxParams, + key: '/ctx', + }, + }); + // Create the file matched by the context + Actions.createFile('/ctx/matched-file'); + Actions.addInferredDependency(ctxPath, '/ctx/matched-file'); + + // Compute the initial graph + files.clear(); + await initialTraverseDependencies(graph, localOptions); + + // Remove the reference to the context module + Actions.removeDependency('/bundle', '/ctx'); + // Create a real file that collides with the context module's generated path + Actions.createFile(ctxPath); + Actions.addDependency('/bundle', ctxPath, {data: {key: '/ctx'}}); + Actions.createFile('/other-file'); + Actions.removeInferredDependency(ctxPath, '/ctx/matched-file'); + Actions.addDependency(ctxPath, '/other-file'); + + // Compute the new graph incrementally + expect( + getPaths(await traverseDependencies([...files], graph, localOptions)), + ).toEqual({ + added: new Set(['/other-file']), + modified: new Set(['/bundle', ctxPath]), + deleted: new Set(['/ctx/matched-file']), + }); + + // We can no longer match paths against the context because it has been deleted + expect(getMatchingContextModules(graph, '/ctx/matched-file')).toEqual( + new Set(), + ); }); }); diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index 7568c99672..236a6a93fd 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -30,6 +30,7 @@ 'use strict'; +import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; import type {RequireContext} from '../lib/contextModule'; import type { Dependency, @@ -311,10 +312,7 @@ async function processModule( const curDependency = currentDependencies.get(key); if ( !curDependency || - curDependency.absolutePath !== prevDependency.absolutePath || - (options.experimentalImportBundleSupport && - curDependency.data.data.asyncType !== - prevDependency.data.data.asyncType) + !dependenciesEqual(prevDependency, curDependency, options) ) { removeDependency(module, key, prevDependency, graph, delta, options); } @@ -326,10 +324,7 @@ async function processModule( const prevDependency = previousDependencies.get(key); if ( !prevDependency || - prevDependency.absolutePath !== curDependency.absolutePath || - (options.experimentalImportBundleSupport && - prevDependency.data.data.asyncType !== - curDependency.data.data.asyncType) + !dependenciesEqual(prevDependency, curDependency, options) ) { promises.push( addDependency(module, key, curDependency, graph, delta, options), @@ -357,6 +352,36 @@ async function processModule( return module; } +function dependenciesEqual( + a: Dependency, + b: Dependency, + options: $ReadOnly<{experimentalImportBundleSupport: boolean, ...}>, +): boolean { + return ( + a === b || + (a.absolutePath === b.absolutePath && + (!options.experimentalImportBundleSupport || + a.data.data.asyncType === b.data.data.asyncType) && + contextParamsEqual(a.data.data.contextParams, b.data.data.contextParams)) + ); +} + +function contextParamsEqual( + a: ?RequireContextParams, + b: ?RequireContextParams, +): boolean { + return ( + a === b || + (a == null && b == null) || + (a != null && + b != null && + a.recursive === b.recursive && + a.filter.pattern === b.filter.pattern && + a.filter.flags === b.filter.flags && + a.mode === b.mode) + ); +} + async function addDependency( parentModule: Module, key: string, @@ -518,6 +543,10 @@ function resolveDependencies( absolutePath: options.resolve(parentPath, dep.name), data: dep, }; + + // This dependency may have existed previously as a require.context - + // clean it up. + graph.privateState.resolvedContexts.delete(resolvedDep.absolutePath); } catch (error) { // Ignore unavailable optional dependencies. They are guarded // with a try-catch block and will be handled during runtime. From e05634f5052722d0512ac1f7fc17e1b19e85802e Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 9 Aug 2022 19:45:12 +0100 Subject: [PATCH 30/38] Fix order sensitive test snapshot --- .../__tests__/__snapshots__/contextModuleTemplates-test.js.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap b/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap index d23cfbe7e6..2376394c5a 100644 --- a/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap +++ b/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap @@ -26,7 +26,7 @@ module.exports = metroContext;" exports[`getContextModuleTemplate creates a lazy-once template 1`] = ` "// All of the requested modules are loaded behind enumerable getters. -const map = Object.defineProperties({}, {\\"./foo.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/foo.js\\"); } },\\"./another/bar.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/another/bar.js\\"); } },}); +const map = Object.defineProperties({}, {\\"./another/bar.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/another/bar.js\\"); } },\\"./foo.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/foo.js\\"); } },}); function metroContext(request) { return map[request]; From a7dd372a8eeb9f61e2a835d096f1a40b63381ebe Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Tue, 9 Aug 2022 19:47:27 +0100 Subject: [PATCH 31/38] Add some line breaks to context module template --- .../contextModuleTemplates-test.js.snap | 17 +++++++++++++---- .../metro/src/lib/contextModuleTemplates.js | 6 +++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap b/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap index 2376394c5a..eeeda5c3da 100644 --- a/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap +++ b/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap @@ -2,7 +2,9 @@ exports[`getContextModuleTemplate creates a lazy template 1`] = ` "// All of the requested modules are loaded behind enumerable getters. -const map = Object.defineProperties({}, {\\"./foo.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/foo.js\\"); } },}); +const map = Object.defineProperties({}, { + \\"./foo.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/foo.js\\"); } }, +}); function metroContext(request) { return map[request]; @@ -26,7 +28,10 @@ module.exports = metroContext;" exports[`getContextModuleTemplate creates a lazy-once template 1`] = ` "// All of the requested modules are loaded behind enumerable getters. -const map = Object.defineProperties({}, {\\"./another/bar.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/another/bar.js\\"); } },\\"./foo.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/foo.js\\"); } },}); +const map = Object.defineProperties({}, { + \\"./another/bar.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/another/bar.js\\"); } }, + \\"./foo.js\\": { enumerable: true, get() { return import(\\"/path/to/project/src/foo.js\\"); } }, +}); function metroContext(request) { return map[request]; @@ -50,7 +55,9 @@ module.exports = metroContext;" exports[`getContextModuleTemplate creates a sync template 1`] = ` "// All of the requested modules are loaded behind enumerable getters. -const map = Object.defineProperties({}, {\\"./foo.js\\": { enumerable: true, get() { return require(\\"/path/to/project/src/foo.js\\"); } },}); +const map = Object.defineProperties({}, { + \\"./foo.js\\": { enumerable: true, get() { return require(\\"/path/to/project/src/foo.js\\"); } }, +}); function metroContext(request) { return map[request]; @@ -74,7 +81,9 @@ module.exports = metroContext;" exports[`getContextModuleTemplate creates an eager template 1`] = ` "// All of the requested modules are loaded behind enumerable getters. -const map = Object.defineProperties({}, {\\"./foo.js\\": { enumerable: true, get() { return require(\\"/path/to/project/src/foo.js\\"); } },}); +const map = Object.defineProperties({}, { + \\"./foo.js\\": { enumerable: true, get() { return require(\\"/path/to/project/src/foo.js\\"); } }, +}); function metroContext(request) { // Here Promise.resolve().then() is used instead of new Promise() to prevent diff --git a/packages/metro/src/lib/contextModuleTemplates.js b/packages/metro/src/lib/contextModuleTemplates.js index ea94503a3f..99066b23ea 100644 --- a/packages/metro/src/lib/contextModuleTemplates.js +++ b/packages/metro/src/lib/contextModuleTemplates.js @@ -16,7 +16,7 @@ function createFileMap( files: string[], processModule: (moduleId: string) => string, ): string { - let mapString = ''; + let mapString = '\n'; files .slice() @@ -40,9 +40,9 @@ function createFileMap( // a file without exports as soon as they create a new file and the context module is updated. // NOTE: The values are set to `enumerable` so the `context.keys()` method works as expected. - mapString += `${key}: { enumerable: true, get() { return ${processModule( + mapString += ` ${key}: { enumerable: true, get() { return ${processModule( file, - )}; } },`; + )}; } },\n`; }); return `Object.defineProperties({}, {${mapString}})`; } From 6bf2b5a103bf689bdacecef2399f33b2746c9018 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 10 Aug 2022 09:11:34 +0100 Subject: [PATCH 32/38] Fix up types post Flow upgrade Flow was having some trouble with jest.fn(), added annotations to fix --- .../__tests__/traverseDependencies-test.js | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js index 2b940845ca..15dd639841 100644 --- a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js @@ -10,15 +10,17 @@ */ import type { - TransformInputOptions, - TransformFn, - Module, - MixedOutput, - Dependency, Dependencies, + Dependency, + Graph, + MixedOutput, + Module, + TransformFn, + TransformInputOptions, + TransformResultDependency, + TransformResultWithSource, } from '../types.flow'; import type {PrivateState} from '../graphOperations'; -import type {Graph, TransformResultDependency} from '../types.flow'; import CountingSet from '../../lib/CountingSet'; import nullthrows from 'nullthrows'; @@ -322,7 +324,10 @@ beforeEach(async () => { mockedDependencyTree = new Map(); mockTransform = jest - .fn() + .fn< + [string, ?RequireContext], + Promise>, + >() .mockImplementation(async (path: string, context: ?RequireContext) => { return { dependencies: (mockedDependencyTree.get(path) || []).map(dep => ({ @@ -493,12 +498,12 @@ it('should retry traversing dependencies after a transform error', async () => { const localOptions = { ...options, - transform(path: string) { + transform(path: string, context: ?RequireContext) { if (path === '/bad') { throw new BadError(); } // $FlowFixMe[object-this-reference]: transform should not be bound to anything - return options.transform.apply(this, arguments); + return options.transform.call(this, path, context); }, }; @@ -2110,29 +2115,31 @@ describe('edge cases', () => { let deferredSlow; let fastResolved = false; - localMockTransform.mockImplementation(async (path, context) => { - const result = await mockTransform(path, context); + localMockTransform.mockImplementation( + async (path: string, context: ?RequireContext) => { + const result = await mockTransform(path, context); - if (path === slowPath && !fastResolved) { - // Return a Promise that won't be resolved after fastPath. - deferredSlow = deferred(result); - return deferredSlow.promise; - } + if (path === slowPath && !fastResolved) { + // Return a Promise that won't be resolved after fastPath. + deferredSlow = deferred(result); + return deferredSlow.promise; + } - if (path === fastPath) { - fastResolved = true; + if (path === fastPath) { + fastResolved = true; - if (deferredSlow) { - return new Promise(async resolve => { - await resolve(result); + if (deferredSlow) { + return new Promise(async resolve => { + await resolve(result); - deferredSlow.resolve(); - }); + deferredSlow.resolve(); + }); + } } - } - return result; - }); + return result; + }, + ); } const assertOrder = async function () { @@ -2838,7 +2845,7 @@ describe('optional dependencies', () => { * Flow's LTI update could not be added via codemod */ return async function (path: string, context: ?RequireContext) { // $FlowFixMe[object-this-reference]: transform should not be bound to anything - const result = await mockTransform.apply(this, arguments); + const result = await mockTransform.call(this, path, context); return { ...result, dependencies: result.dependencies.map(dep => { From 4aae4353be4ed6f84bf97f3af81d0985f404d018 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 10 Aug 2022 10:14:05 +0100 Subject: [PATCH 33/38] id --> debugId, add tests --- .../__tests__/traverseDependencies-test.js | 2 -- .../metro/src/DeltaBundler/graphOperations.js | 2 -- .../require-context-test.js.snap | 18 +++++++++++ .../__tests__/require-context-test.js | 14 ++++++++- .../basic_bundle/require-context/empty.js | 26 ++++++++++++++++ .../contextModuleTemplates-test.js.snap | 24 ++++++++++---- .../src/lib/__tests__/contextModule-test.js | 3 +- packages/metro/src/lib/contextModule.js | 15 ++++++--- .../metro/src/lib/contextModuleTemplates.js | 31 ++++++++++++------- packages/metro/src/lib/transformHelpers.js | 13 +++++++- 10 files changed, 119 insertions(+), 29 deletions(-) create mode 100644 packages/metro/src/integration_tests/basic_bundle/require-context/empty.js diff --git a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js index 15dd639841..f933fa5644 100644 --- a/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js @@ -2209,7 +2209,6 @@ describe('require.context', () => { mode: 'sync', filter: /.*/, from: '/ctx', - id: '/ctx sync recursive /.*/', }; const ctxPath = deriveAbsolutePathFromContext('/ctx', ctxParams); @@ -2544,7 +2543,6 @@ describe('require.context', () => { mode: 'sync', filter: /\.\/narrow\/.*/, from: '/ctx', - id: '/ctx sync recursive /\\.\\/narrow\\/.*/', }; const narrowCtxPath = deriveAbsolutePathFromContext( diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index 236a6a93fd..063294d507 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -45,7 +45,6 @@ import CountingSet from '../lib/CountingSet'; import { deriveAbsolutePathFromContext, fileMatchesContext, - getContextModuleId, } from '../lib/contextModule'; import * as path from 'path'; @@ -521,7 +520,6 @@ function resolveDependencies( const absolutePath = deriveAbsolutePathFromContext(from, contextParams); const resolvedContext: RequireContext = { - id: getContextModuleId(from, contextParams), from, mode: contextParams.mode, recursive: contextParams.recursive, diff --git a/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap b/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap index 7013f6c850..9e804c3207 100644 --- a/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap +++ b/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap @@ -9,6 +9,24 @@ Object { } `; +exports[`require-context/empty.js - release 1`] = ` +Object { + "error": Object { + "code": "MODULE_NOT_FOUND", + "message": "No modules for context ''", + }, +} +`; + +exports[`require-context/empty.js 1`] = ` +Object { + "error": Object { + "code": "MODULE_NOT_FOUND", + "message": "No modules for context 'require-context/no-such-dir sync recursive /.*/'", + }, +} +`; + exports[`require-context/matching.js 1`] = ` Object { "ab": Array [ diff --git a/packages/metro/src/integration_tests/__tests__/require-context-test.js b/packages/metro/src/integration_tests/__tests__/require-context-test.js index 2adceaf940..f0eb932578 100644 --- a/packages/metro/src/integration_tests/__tests__/require-context-test.js +++ b/packages/metro/src/integration_tests/__tests__/require-context-test.js @@ -53,7 +53,17 @@ it('require-context/conflict.js', async () => { ).resolves.toMatchSnapshot(); }); -async function execTest(entry) { +it('require-context/empty.js', async () => { + await expect(execTest('require-context/empty.js')).resolves.toMatchSnapshot(); +}); + +it('require-context/empty.js - release', async () => { + await expect( + execTest('require-context/empty.js', {dev: false}), + ).resolves.toMatchSnapshot(); +}); + +async function execTest(entry, {dev = true}: $ReadOnly<{dev: boolean}> = {}) { const config = await Metro.loadConfig( { config: require.resolve('../metro.config.js'), @@ -67,6 +77,8 @@ async function execTest(entry) { const result = await Metro.runBuild(config, { entry, + dev, + minify: !dev, }); return execBundle(result.code); diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/empty.js b/packages/metro/src/integration_tests/basic_bundle/require-context/empty.js new file mode 100644 index 0000000000..7b454a5528 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/empty.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-local + */ + +import type {RequireWithContext} from './utils'; + +declare var require: RequireWithContext; + +const empty = require.context('./no-such-dir'); + +function main() { + try { + empty('./no-such-file.js'); + } catch (e) { + return {error: {message: e.message, code: e.code}}; + } + return null; +} + +module.exports = (main(): mixed); diff --git a/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap b/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap index eeeda5c3da..f2aaa75035 100644 --- a/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap +++ b/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap @@ -20,8 +20,10 @@ metroContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } +if (__DEV__) { // Readable identifier for the context module. -metroContext.id = \\"/path/to/project/src lazy /(?:)/\\"; +metroContext.debugId = \\"/path/to/project/src lazy /(?:)/\\"; +} module.exports = metroContext;" `; @@ -47,8 +49,10 @@ metroContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } +if (__DEV__) { // Readable identifier for the context module. -metroContext.id = \\"/path/to/project/src lazy recursive /(?:)/\\"; +metroContext.debugId = \\"/path/to/project/src lazy recursive /(?:)/\\"; +} module.exports = metroContext;" `; @@ -73,8 +77,10 @@ metroContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } +if (__DEV__) { // Readable identifier for the context module. -metroContext.id = \\"/path/to/project/src sync recursive /(?:)/\\"; +metroContext.debugId = \\"/path/to/project/src sync recursive /(?:)/\\"; +} module.exports = metroContext;" `; @@ -101,8 +107,10 @@ metroContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } +if (__DEV__) { // Readable identifier for the context module. -metroContext.id = \\"/path/to/project/src eager /(?:)/\\"; +metroContext.debugId = \\"/path/to/project/src eager /(?:)/\\"; +} module.exports = metroContext;" `; @@ -110,7 +118,9 @@ module.exports = metroContext;" exports[`getContextModuleTemplate creates an empty template 1`] = ` " function metroEmptyContext(request) { - let e = new Error(\\"No modules for context '\\" + \\"/path/to/project/src sync recursive /(?:)/\\" + \\"'\\"); + let e = new Error(\\"No modules for context '\\" + ( + metroEmptyContext.debugId || '' + ) + \\"'\\"); e.code = 'MODULE_NOT_FOUND'; throw e; } @@ -123,8 +133,10 @@ metroEmptyContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } +if (__DEV__) { // Readable identifier for the context module. -metroEmptyContext.id = \\"/path/to/project/src sync recursive /(?:)/\\"; +metroEmptyContext.debugId = \\"/path/to/project/src sync recursive /(?:)/\\"; +} module.exports = metroEmptyContext;" `; diff --git a/packages/metro/src/lib/__tests__/contextModule-test.js b/packages/metro/src/lib/__tests__/contextModule-test.js index f42a5d1350..c3f5887ecd 100644 --- a/packages/metro/src/lib/__tests__/contextModule-test.js +++ b/packages/metro/src/lib/__tests__/contextModule-test.js @@ -46,7 +46,7 @@ describe('deriveAbsolutePathFromContext', () => { mode: 'eager', recursive: true, }), - ).toBe('/path/to/project?ctx=7d330128a8fe64375c6932e9204a6a5f40087f99'); + ).toBe('/path/to/project?ctx=fd99d04afc2c8f6f913c8a955e33e978aa1e9977'); }); }); @@ -54,7 +54,6 @@ describe('fileMatchesContext', () => { it(`matches files`, () => { expect( fileMatchesContext('/path/to/project/index.js', { - id: '...', mode: 'lazy', from: '/path/to/project', filter: /.*/, diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js index 24dcc1dbc4..8dc45eca5c 100644 --- a/packages/metro/src/lib/contextModule.js +++ b/packages/metro/src/lib/contextModule.js @@ -25,9 +25,6 @@ export type RequireContext = $ReadOnly<{ mode: ContextMode, from: string, - - // TODO: derive this later, shouldn't be in the type - id: string, }>; /** Get an ID for a context module. */ @@ -60,7 +57,17 @@ export function deriveAbsolutePathFromContext( // and we want to normalize the folder name as much as possible to prevent duplicates. // This also makes the files show up in the correct location when debugging in Chrome. const filePath = from.endsWith(path.sep) ? from.slice(0, -1) : from; - return filePath + '?ctx=' + toHash(getContextModuleId(filePath, context)); + return ( + filePath + + '?ctx=' + + toHash( + getContextModuleId( + // NOTE: No need to make the hash sensitive to filePath since it is already part of the generated path + '', + context, + ), + ) + ); } /** Match a file against a require context. */ diff --git a/packages/metro/src/lib/contextModuleTemplates.js b/packages/metro/src/lib/contextModuleTemplates.js index 99066b23ea..fb13c89cb0 100644 --- a/packages/metro/src/lib/contextModuleTemplates.js +++ b/packages/metro/src/lib/contextModuleTemplates.js @@ -47,10 +47,15 @@ function createFileMap( return `Object.defineProperties({}, {${mapString}})`; } -function getEmptyContextModuleTemplate(modulePath: string, id: string): string { +function getEmptyContextModuleTemplate( + modulePath: string, + debugId: string, +): string { return ` function metroEmptyContext(request) { - let e = new Error("No modules for context '" + ${JSON.stringify(id)} + "'"); + let e = new Error("No modules for context '" + ( + metroEmptyContext.debugId || '' + ) + "'"); e.code = 'MODULE_NOT_FOUND'; throw e; } @@ -63,8 +68,10 @@ metroEmptyContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } +if (__DEV__) { // Readable identifier for the context module. -metroEmptyContext.id = ${JSON.stringify(id)}; +metroEmptyContext.debugId = ${JSON.stringify(debugId)}; +} module.exports = metroEmptyContext;`; } @@ -72,7 +79,7 @@ module.exports = metroEmptyContext;`; function getLoadableContextModuleTemplate( modulePath: string, files: string[], - id: string, + debugId: string, importSyntax: string, getContextTemplate: string, ): string { @@ -97,8 +104,10 @@ metroContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } +if (__DEV__) { // Readable identifier for the context module. -metroContext.id = ${JSON.stringify(id)}; +metroContext.debugId = ${JSON.stringify(debugId)}; +} module.exports = metroContext;`; } @@ -109,7 +118,7 @@ module.exports = metroContext;`; * @prop {ContextMode} mode indicates how the modules should be loaded. * @prop {string} modulePath virtual file path for the virtual module. Example: `require.context('./src')` -> `'/path/to/project/src'`. * @prop {string[]} files list of absolute file paths that must be exported from the context module. Example: `['/path/to/project/src/index.js']`. - * @prop {string} id virtual ID representing the context module. Example: `'/path/to/project/src sync recursive /(?:)/'` + * @prop {string} debugId virtual ID representing the context module. Example: `'/path/to/project/src sync recursive /(?:)/'` * * @returns a string representing a context module (virtual file contents). */ @@ -117,17 +126,17 @@ export function getContextModuleTemplate( mode: ContextMode, modulePath: string, files: string[], - id: string, + debugId: string, ): string { if (!files.length) { - return getEmptyContextModuleTemplate(modulePath, id); + return getEmptyContextModuleTemplate(modulePath, debugId); } switch (mode) { case 'eager': return getLoadableContextModuleTemplate( modulePath, files, - id, + debugId, // NOTE(EvanBacon): It's unclear if we should use `import` or `require` here so sticking // with the more stable option (`require`) for now. 'require', @@ -141,7 +150,7 @@ export function getContextModuleTemplate( return getLoadableContextModuleTemplate( modulePath, files, - id, + debugId, 'require', ' return map[request];', ); @@ -150,7 +159,7 @@ export function getContextModuleTemplate( return getLoadableContextModuleTemplate( modulePath, files, - id, + debugId, 'import', ' return map[request];', ); diff --git a/packages/metro/src/lib/transformHelpers.js b/packages/metro/src/lib/transformHelpers.js index 7b6766e4d3..7c4bf20197 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -21,6 +21,7 @@ import type {RequireContext} from './contextModule'; import {getContextModuleTemplate} from './contextModuleTemplates'; const path = require('path'); +import {getContextModuleId} from './contextModule'; type InlineRequiresRaw = {+blockList: {[string]: true, ...}, ...} | boolean; @@ -143,7 +144,17 @@ async function getTransformFn( requireContext.mode, requireContext.from, files, - requireContext.id, + getContextModuleId( + path.relative(config.projectRoot, requireContext.from), + { + recursive: requireContext.recursive, + mode: requireContext.mode, + filter: { + pattern: requireContext.filter.source, + flags: requireContext.filter.flags, + }, + }, + ), ); templateBuffer = Buffer.from(template); From 2172d4299a719113f6e523eb5b2d0a20377803d3 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 10 Aug 2022 10:30:26 +0100 Subject: [PATCH 34/38] Remove debugId, getContextModuleId --- .../require-context-test.js.snap | 4 +- .../contextModuleTemplates-test.js.snap | 29 +---------- .../src/lib/__tests__/contextModule-test.js | 49 +++++++++---------- .../__tests__/contextModuleTemplates-test.js | 29 ++++------- packages/metro/src/lib/contextModule.js | 30 +++--------- .../metro/src/lib/contextModuleTemplates.js | 27 ++-------- packages/metro/src/lib/transformHelpers.js | 12 ----- 7 files changed, 47 insertions(+), 133 deletions(-) diff --git a/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap b/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap index 9e804c3207..985fd12af2 100644 --- a/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap +++ b/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap @@ -13,7 +13,7 @@ exports[`require-context/empty.js - release 1`] = ` Object { "error": Object { "code": "MODULE_NOT_FOUND", - "message": "No modules for context ''", + "message": "No modules in context", }, } `; @@ -22,7 +22,7 @@ exports[`require-context/empty.js 1`] = ` Object { "error": Object { "code": "MODULE_NOT_FOUND", - "message": "No modules for context 'require-context/no-such-dir sync recursive /.*/'", + "message": "No modules in context", }, } `; diff --git a/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap b/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap index f2aaa75035..1926b05ca2 100644 --- a/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap +++ b/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap @@ -20,11 +20,6 @@ metroContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } -if (__DEV__) { -// Readable identifier for the context module. -metroContext.debugId = \\"/path/to/project/src lazy /(?:)/\\"; -} - module.exports = metroContext;" `; @@ -49,11 +44,6 @@ metroContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } -if (__DEV__) { -// Readable identifier for the context module. -metroContext.debugId = \\"/path/to/project/src lazy recursive /(?:)/\\"; -} - module.exports = metroContext;" `; @@ -77,11 +67,6 @@ metroContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } -if (__DEV__) { -// Readable identifier for the context module. -metroContext.debugId = \\"/path/to/project/src sync recursive /(?:)/\\"; -} - module.exports = metroContext;" `; @@ -107,20 +92,13 @@ metroContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } -if (__DEV__) { -// Readable identifier for the context module. -metroContext.debugId = \\"/path/to/project/src eager /(?:)/\\"; -} - module.exports = metroContext;" `; exports[`getContextModuleTemplate creates an empty template 1`] = ` " function metroEmptyContext(request) { - let e = new Error(\\"No modules for context '\\" + ( - metroEmptyContext.debugId || '' - ) + \\"'\\"); + let e = new Error('No modules in context'); e.code = 'MODULE_NOT_FOUND'; throw e; } @@ -133,10 +111,5 @@ metroEmptyContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } -if (__DEV__) { -// Readable identifier for the context module. -metroEmptyContext.debugId = \\"/path/to/project/src sync recursive /(?:)/\\"; -} - module.exports = metroEmptyContext;" `; diff --git a/packages/metro/src/lib/__tests__/contextModule-test.js b/packages/metro/src/lib/__tests__/contextModule-test.js index c3f5887ecd..42d30d44fa 100644 --- a/packages/metro/src/lib/__tests__/contextModule-test.js +++ b/packages/metro/src/lib/__tests__/contextModule-test.js @@ -11,33 +11,8 @@ import { fileMatchesContext, deriveAbsolutePathFromContext, - getContextModuleId, } from '../contextModule'; -describe('getContextModuleId', () => { - it(`creates a context module ID`, () => { - for (const [ctx, results] of [ - [ - { - filter: {pattern: '.*', flags: ''}, - mode: 'eager', - recursive: true, - }, - '/path/to eager recursive /.*/', - ], - [ - { - filter: {pattern: '.*', flags: ''}, - mode: 'lazy', - recursive: false, - }, - '/path/to lazy /.*/', - ], - ]) - expect(getContextModuleId('/path/to', ctx)).toBe(results); - }); -}); - describe('deriveAbsolutePathFromContext', () => { it(`appends a context query parameter to the input path`, () => { expect( @@ -47,6 +22,30 @@ describe('deriveAbsolutePathFromContext', () => { recursive: true, }), ).toBe('/path/to/project?ctx=fd99d04afc2c8f6f913c8a955e33e978aa1e9977'); + + expect( + deriveAbsolutePathFromContext('/path/to/elsewhere', { + filter: {pattern: '[a-zA-Z]+', flags: ''}, + mode: 'eager', + recursive: true, + }), + ).toBe('/path/to/elsewhere?ctx=fd99d04afc2c8f6f913c8a955e33e978aa1e9977'); + + expect( + deriveAbsolutePathFromContext('/path/to/project', { + filter: {pattern: '.*', flags: ''}, + mode: 'eager', + recursive: true, + }), + ).toBe('/path/to/project?ctx=84326df05531bdd74cf80ae1c288b203517fd25a'); + + expect( + deriveAbsolutePathFromContext('/path/to/project', { + filter: {pattern: '.*', flags: ''}, + mode: 'lazy', + recursive: false, + }), + ).toBe('/path/to/project?ctx=a22638608f758d428784408c78f67162c8c0dd53'); }); }); diff --git a/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js b/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js index 068b56bdbf..e2a8c25c61 100644 --- a/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js +++ b/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js @@ -12,12 +12,9 @@ import {getContextModuleTemplate} from '../contextModuleTemplates'; describe('getContextModuleTemplate', () => { it(`creates a sync template`, () => { - const template = getContextModuleTemplate( - 'sync', - '/path/to/project/src', - ['/path/to/project/src/foo.js'], - '/path/to/project/src sync recursive /(?:)/', - ); + const template = getContextModuleTemplate('sync', '/path/to/project/src', [ + '/path/to/project/src/foo.js', + ]); expect(template).toMatch(/foo\.js/); expect(template).toMatchSnapshot(); }); @@ -26,27 +23,20 @@ describe('getContextModuleTemplate', () => { 'sync', '/path/to/project/src', [], - '/path/to/project/src sync recursive /(?:)/', ); expect(template).toMatch(/MODULE_NOT_FOUND/); expect(template).toMatchSnapshot(); }); it(`creates an eager template`, () => { - const template = getContextModuleTemplate( - 'eager', - '/path/to/project/src', - ['/path/to/project/src/foo.js'], - '/path/to/project/src eager /(?:)/', - ); + const template = getContextModuleTemplate('eager', '/path/to/project/src', [ + '/path/to/project/src/foo.js', + ]); expect(template).toMatchSnapshot(); }); it(`creates a lazy template`, () => { - const template = getContextModuleTemplate( - 'lazy', - '/path/to/project/src', - ['/path/to/project/src/foo.js'], - '/path/to/project/src lazy /(?:)/', - ); + const template = getContextModuleTemplate('lazy', '/path/to/project/src', [ + '/path/to/project/src/foo.js', + ]); expect(template).toMatchSnapshot(); }); it(`creates a lazy-once template`, () => { @@ -54,7 +44,6 @@ describe('getContextModuleTemplate', () => { 'lazy-once', '/path/to/project/src', ['/path/to/project/src/foo.js', '/path/to/project/src/another/bar.js'], - '/path/to/project/src lazy recursive /(?:)/', ); expect(template).toMatchSnapshot(); diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js index 8dc45eca5c..5c504a851e 100644 --- a/packages/metro/src/lib/contextModule.js +++ b/packages/metro/src/lib/contextModule.js @@ -23,26 +23,10 @@ export type RequireContext = $ReadOnly<{ filter: RegExp, /** Mode for resolving dynamic dependencies. Defaults to `sync` */ mode: ContextMode, - + /** Absolute path of the directory to search in */ from: string, }>; -/** Get an ID for a context module. */ -export function getContextModuleId( - modulePath: string, - context: RequireContextParams, -): string { - // Similar to other `require.context` implementations. - return [ - modulePath, - context.mode, - context.recursive ? 'recursive' : '', - new RegExp(context.filter.pattern, context.filter.flags).toString(), - ] - .filter(Boolean) - .join(' '); -} - function toHash(value: string): string { // Use `hex` to ensure filepath safety. return crypto.createHash('sha1').update(value).digest('hex'); @@ -61,11 +45,13 @@ export function deriveAbsolutePathFromContext( filePath + '?ctx=' + toHash( - getContextModuleId( - // NOTE: No need to make the hash sensitive to filePath since it is already part of the generated path - '', - context, - ), + [ + context.mode, + context.recursive ? 'recursive' : '', + new RegExp(context.filter.pattern, context.filter.flags).toString(), + ] + .filter(Boolean) + .join(' '), ) ); } diff --git a/packages/metro/src/lib/contextModuleTemplates.js b/packages/metro/src/lib/contextModuleTemplates.js index fb13c89cb0..fb0cd9145f 100644 --- a/packages/metro/src/lib/contextModuleTemplates.js +++ b/packages/metro/src/lib/contextModuleTemplates.js @@ -47,15 +47,10 @@ function createFileMap( return `Object.defineProperties({}, {${mapString}})`; } -function getEmptyContextModuleTemplate( - modulePath: string, - debugId: string, -): string { +function getEmptyContextModuleTemplate(modulePath: string): string { return ` function metroEmptyContext(request) { - let e = new Error("No modules for context '" + ( - metroEmptyContext.debugId || '' - ) + "'"); + let e = new Error('No modules in context'); e.code = 'MODULE_NOT_FOUND'; throw e; } @@ -68,18 +63,12 @@ metroEmptyContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } -if (__DEV__) { -// Readable identifier for the context module. -metroEmptyContext.debugId = ${JSON.stringify(debugId)}; -} - module.exports = metroEmptyContext;`; } function getLoadableContextModuleTemplate( modulePath: string, files: string[], - debugId: string, importSyntax: string, getContextTemplate: string, ): string { @@ -104,11 +93,6 @@ metroContext.resolve = function metroContextResolve(request) { throw new Error('Unimplemented Metro module context functionality'); } -if (__DEV__) { -// Readable identifier for the context module. -metroContext.debugId = ${JSON.stringify(debugId)}; -} - module.exports = metroContext;`; } @@ -118,7 +102,6 @@ module.exports = metroContext;`; * @prop {ContextMode} mode indicates how the modules should be loaded. * @prop {string} modulePath virtual file path for the virtual module. Example: `require.context('./src')` -> `'/path/to/project/src'`. * @prop {string[]} files list of absolute file paths that must be exported from the context module. Example: `['/path/to/project/src/index.js']`. - * @prop {string} debugId virtual ID representing the context module. Example: `'/path/to/project/src sync recursive /(?:)/'` * * @returns a string representing a context module (virtual file contents). */ @@ -126,17 +109,15 @@ export function getContextModuleTemplate( mode: ContextMode, modulePath: string, files: string[], - debugId: string, ): string { if (!files.length) { - return getEmptyContextModuleTemplate(modulePath, debugId); + return getEmptyContextModuleTemplate(modulePath); } switch (mode) { case 'eager': return getLoadableContextModuleTemplate( modulePath, files, - debugId, // NOTE(EvanBacon): It's unclear if we should use `import` or `require` here so sticking // with the more stable option (`require`) for now. 'require', @@ -150,7 +131,6 @@ export function getContextModuleTemplate( return getLoadableContextModuleTemplate( modulePath, files, - debugId, 'require', ' return map[request];', ); @@ -159,7 +139,6 @@ export function getContextModuleTemplate( return getLoadableContextModuleTemplate( modulePath, files, - debugId, 'import', ' return map[request];', ); diff --git a/packages/metro/src/lib/transformHelpers.js b/packages/metro/src/lib/transformHelpers.js index 7c4bf20197..9ccd05a353 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -21,7 +21,6 @@ import type {RequireContext} from './contextModule'; import {getContextModuleTemplate} from './contextModuleTemplates'; const path = require('path'); -import {getContextModuleId} from './contextModule'; type InlineRequiresRaw = {+blockList: {[string]: true, ...}, ...} | boolean; @@ -144,17 +143,6 @@ async function getTransformFn( requireContext.mode, requireContext.from, files, - getContextModuleId( - path.relative(config.projectRoot, requireContext.from), - { - recursive: requireContext.recursive, - mode: requireContext.mode, - filter: { - pattern: requireContext.filter.source, - flags: requireContext.filter.flags, - }, - }, - ), ); templateBuffer = Buffer.from(template); From 612562c43f6a6062a80211f15ce058c79a3717c4 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 10 Aug 2022 14:04:11 +0100 Subject: [PATCH 35/38] Rewrite DeltaCalculator context tests and fix bugs --- .../metro/src/DeltaBundler/DeltaCalculator.js | 45 ++- .../__tests__/DeltaCalculator-context-test.js | 315 +++++++++++++++ .../__tests__/DeltaCalculator-test.js | 30 +- .../__tests__/DeltaCalculatorContext-test.js | 362 ------------------ 4 files changed, 373 insertions(+), 379 deletions(-) create mode 100644 packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-context-test.js delete mode 100644 packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js diff --git a/packages/metro/src/DeltaBundler/DeltaCalculator.js b/packages/metro/src/DeltaBundler/DeltaCalculator.js index a09ae4abf9..b6bee83414 100644 --- a/packages/metro/src/DeltaBundler/DeltaCalculator.js +++ b/packages/metro/src/DeltaBundler/DeltaCalculator.js @@ -184,18 +184,45 @@ class DeltaCalculator extends EventEmitter { filePath: string, ... }): mixed => { + let state: void | 'deleted' | 'modified' | 'added'; + if (this._deletedFiles.has(filePath)) { + state = 'deleted'; + } else if (this._modifiedFiles.has(filePath)) { + state = 'modified'; + } else if (this._addedFiles.has(filePath)) { + state = 'added'; + } + + let nextState: 'deleted' | 'modified' | 'added'; if (type === 'delete') { - this._deletedFiles.add(filePath); - this._modifiedFiles.delete(filePath); - this._addedFiles.delete(filePath); + nextState = 'deleted'; } else if (type === 'add') { - this._addedFiles.add(filePath); - this._deletedFiles.delete(filePath); - this._modifiedFiles.delete(filePath); + // A deleted+added file is modified + nextState = state === 'deleted' ? 'modified' : 'added'; } else { - this._modifiedFiles.add(filePath); - this._deletedFiles.delete(filePath); - this._addedFiles.delete(filePath); + // type === 'change' + // An added+modified file is added + nextState = state === 'added' ? 'added' : 'modified'; + } + + switch (nextState) { + case 'deleted': + this._deletedFiles.add(filePath); + this._modifiedFiles.delete(filePath); + this._addedFiles.delete(filePath); + break; + case 'added': + this._addedFiles.add(filePath); + this._deletedFiles.delete(filePath); + this._modifiedFiles.delete(filePath); + break; + case 'modified': + this._modifiedFiles.add(filePath); + this._deletedFiles.delete(filePath); + this._addedFiles.delete(filePath); + break; + default: + (nextState: empty); } // Notify users that there is a change in some of the bundle files. This diff --git a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-context-test.js b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-context-test.js new file mode 100644 index 0000000000..f0b2fc4988 --- /dev/null +++ b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-context-test.js @@ -0,0 +1,315 @@ +/** + * 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. + * + * @emails oncall+metro_bundler + * @format + * @flow strict-local + */ + +'use strict'; + +const initialTraverseDependencies = jest.fn(); +const traverseDependencies = jest.fn(); +const markModifiedContextModules = jest.fn(); +jest.doMock('../graphOperations', () => ({ + ...jest.requireActual('../graphOperations'), + initialTraverseDependencies, + traverseDependencies, + markModifiedContextModules, +})); + +const DeltaCalculator = require('../DeltaCalculator'); +const {EventEmitter} = require('events'); + +describe('DeltaCalculator + require.context', () => { + let deltaCalculator; + let fileWatcher; + + const options = { + unstable_allowRequireContext: true, + experimentalImportBundleSupport: false, + onProgress: null, + resolve: (from: string, to: string) => { + throw new Error('Never called'); + }, + shallow: false, + transform: (modulePath: string) => { + throw new Error('Never called'); + }, + transformOptions: { + // NOTE: These options are ignored because we mock out the transformer (via traverseDependencies). + dev: false, + hot: false, + minify: false, + platform: null, + runtimeBytecodeVersion: null, + type: 'module', + unstable_transformProfile: 'default', + }, + }; + + beforeEach(async () => { + fileWatcher = new EventEmitter(); + + markModifiedContextModules.mockImplementation( + (graph, filePath, modifiedContexts) => { + if (filePath.startsWith('/ctx/')) { + modifiedContexts.add('/ctx?ctx=xxx'); + } + }, + ); + + /* + ┌─────────┐ require.context('./ctx', ...) ┌──────────────┐ ┌──────────┐ + │ /bundle │ ───────────────────────────────▶ │ /ctx?ctx=xxx │ ──▶ │ /ctx/foo │ + └─────────┘ └──────────────┘ └──────────┘ + */ + + initialTraverseDependencies.mockImplementationOnce(async (graph, opt) => { + graph.dependencies.set('/bundle', { + dependencies: new Map([['ctx', '/ctx?ctx=xxx']]), + inverseDependencies: [], + output: { + name: 'bundle', + }, + path: '/bundle', + }); + graph.dependencies.set('/ctx?ctx=xxx', { + dependencies: new Map([['foo', '/ctx/foo']]), + inverseDependencies: ['/bundle'], + output: { + name: 'ctx', + }, + path: '/ctx?ctx=xxx', + }); + graph.dependencies.set('/ctx/foo', { + dependencies: new Map(), + inverseDependencies: ['/ctx?ctx=xxx'], + output: { + name: 'foo', + }, + path: '/ctx/foo', + }); + + return { + added: new Map(graph.dependencies), + modified: new Map(), + deleted: new Set(), + }; + }); + + // We don't assert on the actual deltas, so use an empty mock. + traverseDependencies.mockReturnValue( + Promise.resolve({ + added: new Map(), + modified: new Map(), + deleted: new Set(), + }), + ); + + deltaCalculator = new DeltaCalculator( + new Set(['/bundle']), + fileWatcher, + options, + ); + }); + + afterEach(() => { + deltaCalculator.end(); + + traverseDependencies.mockReset(); + initialTraverseDependencies.mockReset(); + }); + + it('removing a file from a context marks the context as modified', async () => { + // Initial build + await deltaCalculator.getDelta({reset: false, shallow: false}); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'delete', filePath: '/ctx/foo'}], + }); + + // Incremental build + await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + expect(traverseDependencies).toBeCalledWith( + ['/ctx?ctx=xxx'], + deltaCalculator.getGraph(), + expect.anything(), + ); + + // We rely on inverse dependencies to update a context module. + expect(markModifiedContextModules).not.toBeCalled(); + + expect(traverseDependencies).toBeCalledTimes(1); + }); + + it('adding a file to a context marks the context as modified', async () => { + // Initial build + await deltaCalculator.getDelta({reset: false, shallow: false}); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'add', filePath: '/ctx/foo2'}], + }); + + // Incremental build + await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + expect(traverseDependencies).toBeCalledWith( + ['/ctx?ctx=xxx'], + deltaCalculator.getGraph(), + expect.anything(), + ); + + expect(traverseDependencies).toBeCalledTimes(1); + }); + + it('modifying an existing file in a context does not mark the context as modified', async () => { + // Initial build + await deltaCalculator.getDelta({reset: false, shallow: false}); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/ctx/foo'}], + }); + + // Incremental build + await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + expect(traverseDependencies).toBeCalledWith( + ['/ctx/foo'], + deltaCalculator.getGraph(), + expect.anything(), + ); + + expect(traverseDependencies).toBeCalledTimes(1); + }); + + it('modifying a potential match of a context, without adding it, does not trigger a rebuild', async () => { + // Initial build + await deltaCalculator.getDelta({reset: false, shallow: false}); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/ctx/foo2'}], + }); + + // Incremental build + await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + expect(traverseDependencies).not.toBeCalled(); + }); + + it('adding a file to a context, and immediately modifying it, marks the context as modified', async () => { + // Initial build + await deltaCalculator.getDelta({reset: false, shallow: false}); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'add', filePath: '/ctx/foo2'}], + }); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/ctx/foo2'}], + }); + + // Incremental build + await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + expect(traverseDependencies).toBeCalledWith( + ['/ctx?ctx=xxx'], + deltaCalculator.getGraph(), + expect.anything(), + ); + + expect(traverseDependencies).toBeCalledTimes(1); + }); + + it('adding a file to a context, and immediately removing it, does not trigger a rebuild', async () => { + // Initial build + await deltaCalculator.getDelta({reset: false, shallow: false}); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'add', filePath: '/ctx/foo2'}], + }); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'delete', filePath: '/ctx/foo2'}], + }); + + // Incremental build + await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + expect(traverseDependencies).not.toBeCalled(); + }); + + it('removing a file from a context, and immediately adding it back, only rebuilds the file itself', async () => { + // Initial build + await deltaCalculator.getDelta({reset: false, shallow: false}); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'delete', filePath: '/ctx/foo'}], + }); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'add', filePath: '/ctx/foo'}], + }); + + // Incremental build + await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + expect(traverseDependencies).toBeCalledWith( + ['/ctx/foo'], + deltaCalculator.getGraph(), + expect.anything(), + ); + }); + + it('modifying an existing file in a context, and immediately removing it, marks the context as modified', async () => { + // Initial build + await deltaCalculator.getDelta({reset: false, shallow: false}); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/ctx/foo'}], + }); + + fileWatcher.emit('change', { + eventsQueue: [{type: 'delete', filePath: '/ctx/foo'}], + }); + + // Incremental build + await deltaCalculator.getDelta({ + reset: false, + shallow: false, + }); + + expect(traverseDependencies).toBeCalledWith( + ['/ctx?ctx=xxx'], + deltaCalculator.getGraph(), + expect.anything(), + ); + + expect(traverseDependencies).toBeCalledTimes(1); + }); +}); diff --git a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js index 187ff4ffa1..120d05808f 100644 --- a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculator-test.js @@ -37,7 +37,7 @@ describe('DeltaCalculator', () => { let fileWatcher; const options = { - unstable_allowRequireContext: true, + unstable_allowRequireContext: false, experimentalImportBundleSupport: false, onProgress: null, resolve: (from: string, to: string) => { @@ -242,7 +242,9 @@ describe('DeltaCalculator', () => { it('should calculate a delta after a simple modification', async () => { await deltaCalculator.getDelta({reset: false, shallow: false}); - fileWatcher.emit('change', {eventsQueue: [{filePath: '/foo'}]}); + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/foo'}], + }); traverseDependencies.mockReturnValue( Promise.resolve({ @@ -271,7 +273,9 @@ describe('DeltaCalculator', () => { // Get initial delta await deltaCalculator.getDelta({reset: false, shallow: false}); - fileWatcher.emit('change', {eventsQueue: [{filePath: '/foo'}]}); + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/foo'}], + }); traverseDependencies.mockReturnValue( Promise.resolve({ @@ -300,7 +304,9 @@ describe('DeltaCalculator', () => { // Get initial delta await deltaCalculator.getDelta({reset: false, shallow: false}); - fileWatcher.emit('change', {eventsQueue: [{filePath: '/foo'}]}); + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/foo'}], + }); const quxModule = { dependencies: new Map(), @@ -342,7 +348,9 @@ describe('DeltaCalculator', () => { deltaCalculator.on('change', () => done()); - fileWatcher.emit('change', {eventsQueue: [{filePath: '/foo'}]}); + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/foo'}], + }); }); it('should emit an event when a file is added', async () => { @@ -382,7 +390,9 @@ describe('DeltaCalculator', () => { it('should retry to build the last delta after getting an error', async () => { await deltaCalculator.getDelta({reset: false, shallow: false}); - fileWatcher.emit('change', {eventsQueue: [{filePath: '/foo'}]}); + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/foo'}], + }); traverseDependencies.mockReturnValue(Promise.reject(new Error())); @@ -400,7 +410,9 @@ describe('DeltaCalculator', () => { await deltaCalculator.getDelta({reset: false, shallow: false}); // First modify the file - fileWatcher.emit('change', {eventsQueue: [{filePath: '/foo'}]}); + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/foo'}], + }); // Then delete that same file fileWatcher.emit('change', { @@ -466,7 +478,9 @@ describe('DeltaCalculator', () => { }); // Then add it again - fileWatcher.emit('change', {eventsQueue: [{filePath: '/foo'}]}); + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/foo'}], + }); traverseDependencies.mockReturnValue( Promise.resolve({ diff --git a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js b/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js deleted file mode 100644 index c98c7b6c2b..0000000000 --- a/packages/metro/src/DeltaBundler/__tests__/DeltaCalculatorContext-test.js +++ /dev/null @@ -1,362 +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. - * - * @emails oncall+metro_bundler - * @format - * @flow strict-local - */ - -'use strict'; - -jest.mock('../../Bundler'); -const initialTraverseDependencies = jest.fn(); -const traverseDependencies = jest.fn(); -const reorderGraph = jest.fn(); -const markModifiedContextModules = jest.fn(); -jest.doMock('../graphOperations', () => ({ - ...jest.requireActual('../graphOperations'), - initialTraverseDependencies, - traverseDependencies, - reorderGraph, - markModifiedContextModules, -})); - -const DeltaCalculator = require('../DeltaCalculator'); -const {EventEmitter} = require('events'); - -describe('DeltaCalculator', () => { - let entryModule; - let fooModule; - let ctxModule; - - let deltaCalculator; - let fileWatcher; - - const options = { - unstable_allowRequireContext: true, - experimentalImportBundleSupport: false, - onProgress: null, - resolve: (from: string, to: string) => { - throw new Error('Never called'); - }, - shallow: false, - transform: (modulePath: string) => { - throw new Error('Never called'); - }, - transformOptions: { - // NOTE: These options are ignored because we mock out the transformer (via traverseDependencies). - dev: false, - hot: false, - minify: false, - platform: null, - runtimeBytecodeVersion: null, - type: 'module', - unstable_transformProfile: 'default', - }, - }; - - beforeEach(async () => { - fileWatcher = new EventEmitter(); - - markModifiedContextModules.mockReset(); - // ~/ - // ├─ bundle - // ├─ ctx/ - // │ ├─ foo - - initialTraverseDependencies.mockImplementationOnce(async (graph, opt) => { - // - // require.context('./ctx') - // - entryModule = { - dependencies: new Map([['ctx', '/ctx?ctx=xxx']]), - inverseDependencies: [], - output: { - name: 'bundle', - }, - path: '/bundle', - }; - - // Virtual context module. - ctxModule = { - dependencies: new Map([['foo', '/ctx/foo']]), - inverseDependencies: ['/bundle'], - output: { - name: 'ctx', - }, - path: '/ctx?ctx=xxx', - }; - - fooModule = { - dependencies: new Map(), - inverseDependencies: ['/ctx?ctx=xxx'], - output: { - name: 'foo', - }, - path: '/ctx/foo', - }; - - graph.dependencies.set('/bundle', entryModule); - graph.dependencies.set('/ctx?ctx=xxx', ctxModule); - graph.dependencies.set('/foo', fooModule); - - return { - added: new Map([ - ['/bundle', entryModule], - ['/ctx?ctx=xxx', entryModule], - ['/ctx/foo', fooModule], - ]), - modified: new Map(), - deleted: new Set(), - }; - }); - - deltaCalculator = new DeltaCalculator( - new Set(['/bundle']), - fileWatcher, - options, - ); - }); - - afterEach(() => { - deltaCalculator.end(); - - traverseDependencies.mockReset(); - initialTraverseDependencies.mockReset(); - }); - - it('should include the entry file when calculating the initial bundle', async () => { - const result = await deltaCalculator.getDelta({ - reset: false, - shallow: false, - }); - - expect(result).toEqual({ - added: new Map([ - ['/bundle', entryModule], - [ - '/ctx?ctx=xxx', - { - dependencies: new Map([['ctx', '/ctx?ctx=xxx']]), - inverseDependencies: [], - output: { - name: 'bundle', - }, - path: '/bundle', - }, - ], - ['/ctx/foo', fooModule], - ]), - modified: new Map(), - deleted: new Set(), - reset: true, - }); - - jest.runAllTicks(); - }); - - it('should calculate a delta after removing a dependency', async () => { - // Get initial delta - await deltaCalculator.getDelta({reset: false, shallow: false}); - - fileWatcher.emit('change', { - eventsQueue: [{type: 'delete', filePath: '/foo'}], - }); - - traverseDependencies.mockReturnValue( - Promise.resolve({ - added: new Map(), - modified: new Map([['/ctx?ctx=xxx', ctxModule]]), - deleted: new Set(['/foo']), - }), - ); - - const result = await deltaCalculator.getDelta({ - reset: false, - shallow: false, - }); - - expect(traverseDependencies).toBeCalledWith( - ['/ctx?ctx=xxx'], - expect.anything(), - expect.anything(), - ); - - expect(result).toEqual({ - added: new Map(), - modified: new Map([['/ctx?ctx=xxx', expect.anything()]]), - deleted: new Set(['/foo']), - reset: false, - }); - - // We rely on inverse dependencies to update a context module. - expect(markModifiedContextModules).not.toBeCalled(); - - expect(traverseDependencies.mock.calls.length).toBe(1); - }); - - it('should calculate a delta after adding/removing dependencies', async () => { - // Emulate matching the deleted file against the first context module - markModifiedContextModules.mockImplementationOnce( - (graph, filePath, modifiedDependencies) => { - modifiedDependencies.add('/ctx?ctx=xxx'); - }, - ); - - // Get initial delta: (_addedFiles, _deletedFiles, _modifiedFiles) -> _getChangedDependencies -> traverseDependencies - await deltaCalculator.getDelta({reset: false, shallow: false}); - - // _handleMultipleFileChanges -> _handleFileChange -> (_addedFiles, _deletedFiles, _modifiedFiles) - fileWatcher.emit('change', { - eventsQueue: [{type: 'add', filePath: '/qux'}], - }); - - const quxModule = { - dependencies: new Map(), - inverseDependencies: [], - output: {name: 'qux'}, - path: '/qux', - }; - - traverseDependencies.mockImplementation(async (path, graph, options) => { - graph.dependencies.set('/qux', quxModule); - return { - added: new Map([['/qux', quxModule]]), - modified: new Map([['/ctx?ctx=xxx', ctxModule]]), - deleted: new Set([]), - }; - }); - - const result = await deltaCalculator.getDelta({ - reset: false, - shallow: false, - }); - - // Test if the new module matches any of the context modules. - expect(markModifiedContextModules).toBeCalledWith( - expect.anything(), - '/qux', - new Set(['/ctx?ctx=xxx']), - ); - - // Called with context module - expect(traverseDependencies).toBeCalledWith( - ['/ctx?ctx=xxx'], - expect.anything(), - expect.anything(), - ); - - expect(result).toEqual({ - added: new Map([['/qux', quxModule]]), - modified: new Map([['/ctx?ctx=xxx', ctxModule]]), - deleted: new Set(), - reset: false, - }); - }); - - it('should retry to build the last delta after getting an error', async () => { - await deltaCalculator.getDelta({reset: false, shallow: false}); - - fileWatcher.emit('change', {eventsQueue: [{filePath: '/ctx?ctx=xxx'}]}); - - traverseDependencies.mockReturnValue(Promise.reject(new Error())); - - await expect( - deltaCalculator.getDelta({reset: false, shallow: false}), - ).rejects.toBeInstanceOf(Error); - - // This second time it should still throw an error. - await expect( - deltaCalculator.getDelta({reset: false, shallow: false}), - ).rejects.toBeInstanceOf(Error); - }); - - it('should never try to traverse a file after deleting it', async () => { - await deltaCalculator.getDelta({reset: false, shallow: false}); - - // First modify the file - fileWatcher.emit('change', {eventsQueue: [{filePath: '/ctx?ctx=xxx'}]}); - - // Then delete that same file - fileWatcher.emit('change', { - eventsQueue: [{type: 'delete', filePath: '/ctx?ctx=xxx'}], - }); - - traverseDependencies.mockReturnValue( - Promise.resolve({ - added: new Map(), - modified: new Map([['/bundle', entryModule]]), - deleted: new Set(['/ctx?ctx=xxx']), - }), - ); - - expect( - await deltaCalculator.getDelta({reset: false, shallow: false}), - ).toEqual({ - added: new Map(), - modified: new Map([['/bundle', entryModule]]), - deleted: new Set(['/ctx?ctx=xxx']), - reset: false, - }); - - expect(traverseDependencies).toHaveBeenCalledTimes(1); - expect(traverseDependencies.mock.calls[0][0]).toEqual(['/bundle']); - }); - - it('does not traverse a file after deleting it and one of its dependencies', async () => { - await deltaCalculator.getDelta({reset: false, shallow: false}); - - // Delete a file - fileWatcher.emit('change', { - eventsQueue: [{type: 'delete', filePath: '/ctx?ctx=xxx'}], - }); - - // Delete a dependency of the deleted file - fileWatcher.emit('change', { - eventsQueue: [{type: 'delete', filePath: '/foo'}], - }); - - traverseDependencies.mockReturnValue( - Promise.resolve({ - added: new Map(), - modified: new Map([['/bundle', entryModule]]), - deleted: new Set(['/ctx?ctx=xxx']), - }), - ); - - await deltaCalculator.getDelta({reset: false, shallow: false}); - - // Only the /bundle module should have been traversed (since it's an - // inverse dependency of /ctx?ctx=xxx). - expect(traverseDependencies).toHaveBeenCalledTimes(1); - expect(traverseDependencies.mock.calls[0][0]).toEqual(['/bundle']); - }); - - it('should not do unnecessary work when adding a context module file after deleting it', async () => { - await deltaCalculator.getDelta({reset: false, shallow: false}); - - // First delete a file - fileWatcher.emit('change', { - eventsQueue: [{type: 'delete', filePath: '/ctx?ctx=xxx'}], - }); - - // Then add it again - fileWatcher.emit('change', {eventsQueue: [{filePath: '/ctx?ctx=xxx'}]}); - - traverseDependencies.mockReturnValue( - Promise.resolve({ - added: new Map(), - modified: new Map([['/ctx?ctx=xxx', entryModule]]), - deleted: new Set(), - }), - ); - - await deltaCalculator.getDelta({reset: false, shallow: false}); - - expect(traverseDependencies).toHaveBeenCalledTimes(1); - expect(traverseDependencies.mock.calls[0][0]).toEqual(['/ctx?ctx=xxx']); - }); -}); From 6404eb0e0255e3be2b816c5744d9b9b7b15390b2 Mon Sep 17 00:00:00 2001 From: Moti Zilberman Date: Wed, 10 Aug 2022 15:23:59 +0100 Subject: [PATCH 36/38] Light cleanup + file header fixes --- .../metro-file-map/src/__tests__/HasteFS-test.js | 2 +- packages/metro/src/DeltaBundler/types.flow.js | 2 +- .../require-context/subdir-conflict/index.js | 12 +++++++++++- .../metro/src/lib/__tests__/contextModule-test.js | 2 +- .../src/lib/__tests__/contextModuleTemplates-test.js | 2 +- packages/metro/src/lib/contextModule.js | 2 +- packages/metro/src/lib/contextModuleTemplates.js | 2 +- packages/metro/src/node-haste/DependencyGraph.js | 3 ++- 8 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/metro-file-map/src/__tests__/HasteFS-test.js b/packages/metro-file-map/src/__tests__/HasteFS-test.js index aaf98f661c..bfab641ffe 100644 --- a/packages/metro-file-map/src/__tests__/HasteFS-test.js +++ b/packages/metro-file-map/src/__tests__/HasteFS-test.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow + * @flow strict-local * @format */ diff --git a/packages/metro/src/DeltaBundler/types.flow.js b/packages/metro/src/DeltaBundler/types.flow.js index 06fc355591..93013ecefd 100644 --- a/packages/metro/src/DeltaBundler/types.flow.js +++ b/packages/metro/src/DeltaBundler/types.flow.js @@ -120,7 +120,7 @@ export type AllowOptionalDependencies = | AllowOptionalDependenciesWithOptions; export type Options = { - +resolve: (from: string, to: string, context?: ?RequireContext) => string, + +resolve: (from: string, to: string) => string, +transform: TransformFn, +transformOptions: TransformInputOptions, +onProgress: ?(numProcessed: number, total: number) => mixed, diff --git a/packages/metro/src/integration_tests/basic_bundle/require-context/subdir-conflict/index.js b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir-conflict/index.js index 700d3ae996..038e19fc4b 100644 --- a/packages/metro/src/integration_tests/basic_bundle/require-context/subdir-conflict/index.js +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir-conflict/index.js @@ -1 +1,11 @@ -module.exports = 'contents of subdir-conflict/index.js'; \ No newline at end of file +/** + * 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 + */ + +module.exports = 'contents of subdir-conflict/index.js'; diff --git a/packages/metro/src/lib/__tests__/contextModule-test.js b/packages/metro/src/lib/__tests__/contextModule-test.js index 42d30d44fa..df71f73c0b 100644 --- a/packages/metro/src/lib/__tests__/contextModule-test.js +++ b/packages/metro/src/lib/__tests__/contextModule-test.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow + * @flow strict-local * @format */ diff --git a/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js b/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js index e2a8c25c61..81dc4c8c6c 100644 --- a/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js +++ b/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow + * @flow strict-local * @format */ diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js index 5c504a851e..5ac75af232 100644 --- a/packages/metro/src/lib/contextModule.js +++ b/packages/metro/src/lib/contextModule.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow + * @flow strict-local * @format */ diff --git a/packages/metro/src/lib/contextModuleTemplates.js b/packages/metro/src/lib/contextModuleTemplates.js index fb0cd9145f..a05533af5d 100644 --- a/packages/metro/src/lib/contextModuleTemplates.js +++ b/packages/metro/src/lib/contextModuleTemplates.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow + * @flow strict-local * @format */ diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index 8169b61dcd..ce1dfdb0e0 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -12,6 +12,7 @@ import type Package from './Package'; import type {ConfigT} from 'metro-config/src/configTypes.flow'; import type MetroFileMap, {HasteFS} from 'metro-file-map'; import type Module from './Module'; + import {ModuleMap as MetroFileMapModuleMap} from 'metro-file-map'; const createHasteMap = require('./DependencyGraph/createHasteMap'); @@ -99,7 +100,7 @@ class DependencyGraph extends EventEmitter { }); } - // Waits for the dependency graph to become ready after initialization. + // Waits for the dependency graph to become ready after initialisation. // Don't read anything from the graph until this resolves. async ready(): Promise { await this._readyPromise; From 6833b44af772e9ca80bb3075e67fb0fbed352ebc Mon Sep 17 00:00:00 2001 From: evanbacon Date: Mon, 15 Aug 2022 12:02:31 +0200 Subject: [PATCH 37/38] Update HasteFS.js --- packages/metro-file-map/src/HasteFS.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/metro-file-map/src/HasteFS.js b/packages/metro-file-map/src/HasteFS.js index 99829512c6..3bb795164d 100644 --- a/packages/metro-file-map/src/HasteFS.js +++ b/packages/metro-file-map/src/HasteFS.js @@ -105,10 +105,10 @@ export default class HasteFS { for (const file of this.getAbsoluteFileIterator()) { const filePath = fastPath.relative(root, file); - const isRelative = + const isUnderRoot = filePath && !filePath.startsWith('..') && !path.isAbsolute(filePath); // Ignore everything outside of the provided `root`. - if (!isRelative) { + if (!isUnderRoot) { continue; } From 49b97a3da5eef107a195ff59ae5be0690441c14e Mon Sep 17 00:00:00 2001 From: evanbacon Date: Mon, 15 Aug 2022 12:24:35 +0200 Subject: [PATCH 38/38] PR Feedback --- packages/metro-file-map/src/HasteFS.js | 12 ++---------- packages/metro/src/DeltaBundler/DeltaCalculator.js | 2 +- packages/metro/src/lib/contextModule.js | 10 +--------- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/metro-file-map/src/HasteFS.js b/packages/metro-file-map/src/HasteFS.js index 3bb795164d..eb7cf36bf8 100644 --- a/packages/metro-file-map/src/HasteFS.js +++ b/packages/metro-file-map/src/HasteFS.js @@ -105,8 +105,7 @@ export default class HasteFS { for (const file of this.getAbsoluteFileIterator()) { const filePath = fastPath.relative(root, file); - const isUnderRoot = - filePath && !filePath.startsWith('..') && !path.isAbsolute(filePath); + const isUnderRoot = filePath && !filePath.startsWith('..'); // Ignore everything outside of the provided `root`. if (!isUnderRoot) { continue; @@ -122,7 +121,7 @@ export default class HasteFS { // NOTE(EvanBacon): Ensure files start with `./` for matching purposes // this ensures packages work across Metro and Webpack (ex: Storybook for React DOM / React Native). // `a/b.js` -> `./a/b.js` - prefix + normalizeSlashes(filePath), + prefix + filePath.replace(/\\/g, '/'), ) ) { files.push(file); @@ -150,10 +149,3 @@ export default class HasteFS { return this._files.get(relativePath); } } - -function normalizeSlashes(path: string): string { - if (/^\\\\\?\\/.test(path) || /[^\u0000-\u0080]+/.test(path)) { - return path; - } - return path.replace(/\\/g, '/'); -} diff --git a/packages/metro/src/DeltaBundler/DeltaCalculator.js b/packages/metro/src/DeltaBundler/DeltaCalculator.js index b6bee83414..f23f18e958 100644 --- a/packages/metro/src/DeltaBundler/DeltaCalculator.js +++ b/packages/metro/src/DeltaBundler/DeltaCalculator.js @@ -10,10 +10,10 @@ 'use strict'; -import {markModifiedContextModules} from './graphOperations'; import { createGraph, initialTraverseDependencies, + markModifiedContextModules, reorderGraph, traverseDependencies, } from './graphOperations'; diff --git a/packages/metro/src/lib/contextModule.js b/packages/metro/src/lib/contextModule.js index 5ac75af232..b4824da3fe 100644 --- a/packages/metro/src/lib/contextModule.js +++ b/packages/metro/src/lib/contextModule.js @@ -76,7 +76,7 @@ export function fileMatchesContext( // NOTE(EvanBacon): Ensure files start with `./` for matching purposes // this ensures packages work across Metro and Webpack (ex: Storybook for React DOM / React Native). // `a/b.js` -> `./a/b.js` - './' + normalizeSlashes(filePath), + './' + filePath.replace(/\\/g, '/'), ) ) { return false; @@ -84,11 +84,3 @@ export function fileMatchesContext( return true; } - -/** Convert back slashes (windows) to forward slashes. */ -function normalizeSlashes(path: string): string { - if (/^\\\\\?\\/.test(path) || /[^\u0000-\u0080]+/.test(path)) { - return path; - } - return path.replace(/\\/g, '/'); -}