diff --git a/packages/metro-file-map/src/HasteFS.js b/packages/metro-file-map/src/HasteFS.js index b19e155a01..b4ad744304 100644 --- a/packages/metro-file-map/src/HasteFS.js +++ b/packages/metro-file-map/src/HasteFS.js @@ -9,8 +9,10 @@ */ import type {FileData, FileMetaData, Glob, Path} from './flow-types'; + import H from './constants'; import * as fastPath from './lib/fast_path'; +import * as path from 'path'; import {globsToMatcher, replacePathSepForGlob} from 'jest-util'; export default class HasteFS { @@ -79,6 +81,52 @@ export default class HasteFS { return files; } + /** + * 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<{ + /* Should search for files recursively. */ + recursive: boolean, + /* Filter relative paths against a pattern. */ + filter: RegExp, + }>, + ): Array { + const files = []; + const prefix = './'; + + for (const file of this.getAbsoluteFileIterator()) { + const filePath = fastPath.relative(root, file); + + const isUnderRoot = filePath && !filePath.startsWith('..'); + // Ignore everything outside of the provided `root`. + if (!isUnderRoot) { + continue; + } + + // Prevent searching in child directories during a non-recursive search. + if (!context.recursive && filePath.includes(path.sep)) { + continue; + } + + 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.replace(/\\/g, '/'), + ) + ) { + 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__/HasteFS-test.js b/packages/metro-file-map/src/__tests__/HasteFS-test.js new file mode 100644 index 0000000000..bfab641ffe --- /dev/null +++ b/packages/metro-file-map/src/__tests__/HasteFS-test.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +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([ + [ + '/foo/another.js', + // $FlowFixMe: mocking files + {}, + ], + [ + '/bar.js', + // $FlowFixMe: mocking files + {}, + ], + ]), + }); + + // 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 0c2fdde28a..6520077a58 100644 --- a/packages/metro-runtime/src/polyfills/require.js +++ b/packages/metro-runtime/src/polyfills/require.js @@ -279,6 +279,20 @@ function metroImportAll(moduleId: ModuleID | VerboseModuleNameForDev | number) { } metroRequire.importAll = metroImportAll; +// 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 your Metro configuration.', + ); + } + throw new Error( + 'The experimental Metro feature `require.context` is not enabled in your project.', + ); +}; + let inGuard = false; function guardedLoadModule( moduleId: ModuleID, diff --git a/packages/metro/src/Bundler.js b/packages/metro/src/Bundler.js index aabb62703d..f7ebc4c334 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 bd2a56178f..f23f18e958 100644 --- a/packages/metro/src/DeltaBundler/DeltaCalculator.js +++ b/packages/metro/src/DeltaBundler/DeltaCalculator.js @@ -10,14 +10,15 @@ 'use strict'; -import type {DeltaResult, Graph, Options} from './types.flow'; - -const { +import { createGraph, initialTraverseDependencies, + markModifiedContextModules, reorderGraph, traverseDependencies, -} = require('./graphOperations'); +} from './graphOperations'; +import type {DeltaResult, Graph, Options} from './types.flow'; + const {EventEmitter} = require('events'); /** @@ -33,6 +34,7 @@ class DeltaCalculator extends EventEmitter { _currentBuildPromise: ?Promise>; _deletedFiles: Set = new Set(); _modifiedFiles: Set = new Set(); + _addedFiles: Set = new Set(); _graph: Graph; @@ -72,6 +74,7 @@ class DeltaCalculator extends EventEmitter { }); this._modifiedFiles = new Set(); this._deletedFiles = new Set(); + this._addedFiles = new Set(); } /** @@ -99,6 +102,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 +111,7 @@ class DeltaCalculator extends EventEmitter { this._currentBuildPromise = this._getChangedDependencies( modifiedFiles, deletedFiles, + addedFiles, ); let result; @@ -121,6 +127,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 @@ -177,12 +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); + nextState = 'deleted'; + } else if (type === 'add') { + // A deleted+added file is modified + nextState = state === 'deleted' ? 'modified' : 'added'; } else { - this._deletedFiles.delete(filePath); - this._modifiedFiles.add(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 @@ -193,6 +233,7 @@ class DeltaCalculator extends EventEmitter { async _getChangedDependencies( modifiedFiles: Set, deletedFiles: Set, + addedFiles: Set, ): Promise> { if (!this._graph.dependencies.size) { const {added} = await initialTraverseDependencies( @@ -224,6 +265,18 @@ 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) { + // Check if any added or removed files are matched in a context module. + // 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 => { + markModifiedContextModules(this._graph, filePath, modifiedFiles); + }); + } + // We only want to process files that are in the bundle. const modifiedDependencies = Array.from(modifiedFiles).filter( (filePath: string) => this._graph.dependencies.has(filePath), diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index cea560befd..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'); @@ -66,6 +67,7 @@ class Transformer { async transformFile( filePath: string, transformerOptions: TransformOptions, + fileBuffer?: Buffer, ): Promise> { const cache = this._cache; @@ -119,7 +121,14 @@ class Transformer { unstable_transformProfile, ]); - const sha1 = this._getSha1(filePath); + 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); @@ -127,7 +136,11 @@ 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, + ); // 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 +154,9 @@ class Transformer { return { ...data.result, getSource(): Buffer { + if (fileBuffer) { + return fileBuffer; + } return fs.readFileSync(filePath); }, }; diff --git a/packages/metro/src/DeltaBundler/Worker.flow.js b/packages/metro/src/DeltaBundler/Worker.flow.js index a8d0451406..9c80b7e35d 100644 --- a/packages/metro/src/DeltaBundler/Worker.flow.js +++ b/packages/metro/src/DeltaBundler/Worker.flow.js @@ -51,11 +51,53 @@ 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, projectRoot: string, transformerConfig: TransformerConfig, + fileBuffer?: Buffer, +): Promise { + let data; + + const fileBufferObject = asDeserializedBuffer(fileBuffer); + if (fileBufferObject) { + data = fileBufferObject; + } 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 +113,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 f4eccb6ebb..c33e1075f8 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/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__/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 5f50570fda..120d05808f 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, @@ -36,6 +37,7 @@ describe('DeltaCalculator', () => { let fileWatcher; const options = { + unstable_allowRequireContext: false, experimentalImportBundleSupport: false, onProgress: null, resolve: (from: string, to: string) => { @@ -208,10 +210,41 @@ 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}); - fileWatcher.emit('change', {eventsQueue: [{filePath: '/foo'}]}); + fileWatcher.emit('change', { + eventsQueue: [{type: 'change', filePath: '/foo'}], + }); traverseDependencies.mockReturnValue( Promise.resolve({ @@ -240,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({ @@ -269,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(), @@ -311,7 +348,26 @@ 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 () => { + 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 () => { @@ -334,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())); @@ -352,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', { @@ -418,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__/Transformer-test.js b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js index 423c0f4a9c..8f44337d27 100644 --- a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js @@ -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__/traverseDependencies-test.js b/packages/metro/src/DeltaBundler/__tests__/traverseDependencies-test.js index 661793ed51..f933fa5644 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'; @@ -28,8 +30,10 @@ const { initialTraverseDependencies, reorderGraph, traverseDependencies: traverseDependenciesImpl, + markModifiedContextModules, } = require('../graphOperations'); - +import {deriveAbsolutePathFromContext} from '../../lib/contextModule'; +import type {RequireContext} from '../../lib/contextModule'; const {objectContaining} = expect; type DependencyDataInput = $Shape; @@ -59,6 +63,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)) { @@ -85,11 +97,33 @@ const Actions = { addDependency( path: string, dependencyPath: string, - position?: ?number, - name?: string, - data?: DependencyDataInput, + options: { + position?: ?number, + name?: string, + data?: DependencyDataInput, + } = {}, ) { - const deps = nullthrows(mockedDependencyTree.get(path)); + 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') .createHash('sha1') @@ -114,11 +148,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); @@ -126,8 +164,6 @@ const Actions = { deps.splice(index, 1); mockedDependencyTree.set(path, deps); } - - files.add(path); }, }; @@ -203,6 +239,7 @@ function computeInverseDependencies( +shallow: boolean, +transform: TransformFn<>, +transformOptions: TransformInputOptions, + +unstable_allowRequireContext: boolean, }, ) { const allInverseDependencies = new Map(); @@ -244,6 +281,7 @@ async function traverseDependencies( +shallow: boolean, +transform: TransformFn<>, +transformOptions: TransformInputOptions, + +unstable_allowRequireContext: boolean, }, ) { // Get a snapshot of the graph before the traversal. @@ -275,40 +313,53 @@ 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< + [string, ?RequireContext], + Promise>, + >() + .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: false, 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)) { @@ -447,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); }, }; @@ -613,7 +664,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)), @@ -839,7 +890,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)), @@ -1399,8 +1450,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', + }, }); /* @@ -1426,8 +1479,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', + }, }); /* @@ -1447,8 +1502,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', + }, }); /* @@ -1466,8 +1523,10 @@ describe('edge cases', () => { files.clear(); Actions.createFile('/quux'); - Actions.addDependency('/bundle', '/quux', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bundle', '/quux', { + data: { + asyncType: 'async', + }, }); /* @@ -1507,8 +1566,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', + }, }); /* @@ -1547,8 +1608,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', + }, }); /* @@ -1568,8 +1631,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', + }, }); /* @@ -1611,8 +1676,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', + }, }); /* @@ -1648,8 +1715,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', + }, }); /* @@ -1686,8 +1755,10 @@ describe('edge cases', () => { */ await initialTraverseDependencies(graph, options); - Actions.addDependency('/bar', '/foo', undefined, undefined, { - asyncType: 'async', + Actions.addDependency('/bar', '/foo', { + data: { + asyncType: 'async', + }, }); /* @@ -1716,8 +1787,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', + }, }); /* @@ -1761,8 +1834,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'); @@ -1791,8 +1866,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'); @@ -1858,8 +1935,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', + }, }); /* @@ -1906,8 +1985,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'); @@ -1962,7 +2043,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)), @@ -2034,29 +2115,31 @@ describe('edge cases', () => { let deferredSlow; let fastResolved = false; - localMockTransform.mockImplementation(async path => { - const result = await mockTransform(path); + 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 () { @@ -2095,14 +2178,581 @@ 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', + }; + + 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', + }; + + 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]), + }); + }); + + 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(), + ); }); }); @@ -2191,9 +2841,9 @@ 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); + const result = await mockTransform.call(this, path, context); return { ...result, dependencies: result.dependencies.map(dep => { @@ -2257,8 +2907,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); @@ -2277,8 +2929,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); @@ -2298,7 +2952,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); @@ -2316,7 +2970,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); diff --git a/packages/metro/src/DeltaBundler/graphOperations.js b/packages/metro/src/DeltaBundler/graphOperations.js index 3d715df2da..063294d507 100644 --- a/packages/metro/src/DeltaBundler/graphOperations.js +++ b/packages/metro/src/DeltaBundler/graphOperations.js @@ -30,6 +30,8 @@ 'use strict'; +import type {RequireContextParams} from '../ModuleGraph/worker/collectDependencies'; +import type {RequireContext} from '../lib/contextModule'; import type { Dependency, Graph, @@ -40,7 +42,12 @@ import type { } from './types.flow'; import CountingSet from '../lib/CountingSet'; +import { + deriveAbsolutePathFromContext, + fileMatchesContext, +} from '../lib/contextModule'; +import * as path from 'path'; const invariant = require('invariant'); const nullthrows = require('nullthrows'); @@ -63,6 +70,8 @@ type NodeColor = // Private state for the graph that persists between operations. export opaque type PrivateState = { + /** Resolved context parameters from `require.context`. */ + +resolvedContexts: Map, +gc: { // GC state for nodes in the graph (graph.dependencies) +color: Map, @@ -79,6 +88,7 @@ function createGraph(options: GraphInputOptions): Graph { dependencies: new Map(), importBundleNames: new Set(), privateState: { + resolvedContexts: new Map(), gc: { color: new Map(), possibleCycleRoots: new Set(), @@ -144,7 +154,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. @@ -266,13 +276,15 @@ async function processModule( delta: Delta, options: InternalOptions, ): Promise> { + 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); + 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). const currentDependencies = resolveDependencies( + graph, path, result.dependencies, options, @@ -299,10 +311,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); } @@ -314,10 +323,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), @@ -345,6 +351,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, @@ -447,7 +483,26 @@ function removeDependency( } } +/** + * Collect a list of context modules which include a given file. + */ +function markModifiedContextModules( + graph: Graph, + filePath: string, + modifiedPaths: Set, +) { + for (const [absolutePath, context] of graph.privateState.resolvedContexts) { + if ( + !modifiedPaths.has(absolutePath) && + fileMatchesContext(filePath, context) + ) { + modifiedPaths.add(absolutePath); + } + } +} + function resolveDependencies( + graph: Graph, parentPath: string, dependencies: $ReadOnlyArray, options: InternalOptions, @@ -455,18 +510,50 @@ function resolveDependencies( const maybeResolvedDeps = new Map(); for (const dep of dependencies) { let resolvedDep; - try { + + // `require.context` + const {contextParams} = dep.data; + if (contextParams) { + // Ensure the filepath has uniqueness applied to ensure multiple `require.context` + // statements can be used to target the same file with different properties. + const from = path.join(parentPath, '..', dep.name); + const absolutePath = deriveAbsolutePathFromContext(from, contextParams); + + const resolvedContext: RequireContext = { + from, + mode: contextParams.mode, + recursive: contextParams.recursive, + filter: new RegExp( + contextParams.filter.pattern, + contextParams.filter.flags, + ), + }; + + graph.privateState.resolvedContexts.set(absolutePath, resolvedContext); + resolvedDep = { - absolutePath: options.resolve(parentPath, dep.name), + absolutePath, data: dep, }; - } catch (error) { - // Ignore unavailable optional dependencies. They are guarded - // with a try-catch block and will be handled during runtime. - if (dep.data.isOptional !== true) { - throw error; + } else { + try { + resolvedDep = { + 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. + if (dep.data.isOptional !== true) { + throw error; + } } } + const key = dep.data.key; if (maybeResolvedDeps.has(key)) { throw new Error( @@ -622,6 +709,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 @@ -740,4 +828,5 @@ module.exports = { initialTraverseDependencies, traverseDependencies, reorderGraph, + markModifiedContextModules, }; diff --git a/packages/metro/src/DeltaBundler/types.flow.js b/packages/metro/src/DeltaBundler/types.flow.js index eb0932529e..93013ecefd 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'; @@ -107,9 +108,10 @@ export type TransformResultWithSource = $ReadOnly<{ getSource: () => Buffer, }>; -export type TransformFn = string => Promise< - TransformResultWithSource, ->; +export type TransformFn = ( + string, + ?RequireContext, +) => Promise>; export type AllowOptionalDependenciesWithOptions = { +exclude: Array, }; @@ -123,6 +125,7 @@ export type Options = { +transformOptions: TransformInputOptions, +onProgress: ?(numProcessed: number, total: number) => mixed, +experimentalImportBundleSupport: boolean, + +unstable_allowRequireContext: boolean, +shallow: boolean, }; diff --git a/packages/metro/src/HmrServer.js b/packages/metro/src/HmrServer.js index df4873dfa8..c804598498 100644 --- a/packages/metro/src/HmrServer.js +++ b/packages/metro/src/HmrServer.js @@ -125,6 +125,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..0740e2fe18 100644 --- a/packages/metro/src/IncrementalBundler.js +++ b/packages/metro/src/IncrementalBundler.js @@ -123,6 +123,8 @@ class IncrementalBundler { onProgress: otherOptions.onProgress, experimentalImportBundleSupport: this._config.transformer.experimentalImportBundleSupport, + unstable_allowRequireContext: + this._config.transformer.unstable_allowRequireContext, shallow: otherOptions.shallow, }); @@ -164,6 +166,8 @@ class IncrementalBundler { onProgress: otherOptions.onProgress, experimentalImportBundleSupport: this._config.transformer.experimentalImportBundleSupport, + unstable_allowRequireContext: + this._config.transformer.unstable_allowRequireContext, shallow: otherOptions.shallow, }, ); @@ -218,6 +222,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 d970b8580f..c9b007916a 100644 --- a/packages/metro/src/ModuleGraph/worker/collectDependencies.js +++ b/packages/metro/src/ModuleGraph/worker/collectDependencies.js @@ -39,7 +39,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 f438df64a2..885df79b51 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -544,6 +544,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. @@ -1166,6 +1168,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/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 34d44c907f..3f0dd21553 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, }, ); }); @@ -660,6 +661,7 @@ describe('processRequest', () => { type: 'module', unstable_transformProfile: 'hermes-stable', }, + unstable_allowRequireContext: false, }, ); }); @@ -881,6 +883,7 @@ describe('processRequest', () => { type: 'module', unstable_transformProfile: 'default', }, + 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.`; 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..985fd12af2 --- /dev/null +++ b/packages/metro/src/integration_tests/__tests__/__snapshots__/require-context-test.js.snap @@ -0,0 +1,110 @@ +// 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/empty.js - release 1`] = ` +Object { + "error": Object { + "code": "MODULE_NOT_FOUND", + "message": "No modules in context", + }, +} +`; + +exports[`require-context/empty.js 1`] = ` +Object { + "error": Object { + "code": "MODULE_NOT_FOUND", + "message": "No modules in context", + }, +} +`; + +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..f0eb932578 --- /dev/null +++ b/packages/metro/src/integration_tests/__tests__/require-context-test.js @@ -0,0 +1,85 @@ +/** + * 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(); +}); + +it('require-context/conflict.js', async () => { + await expect( + execTest('require-context/conflict.js'), + ).resolves.toMatchSnapshot(); +}); + +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'), + }, + { + transformer: { + unstable_allowRequireContext: true, + }, + }, + ); + + 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/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/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/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-conflict/index.js b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir-conflict/index.js new file mode 100644 index 0000000000..038e19fc4b --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/require-context/subdir-conflict/index.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 = 'contents of subdir-conflict/index.js'; 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); +} 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..1926b05ca2 --- /dev/null +++ b/packages/metro/src/lib/__tests__/__snapshots__/contextModuleTemplates-test.js.snap @@ -0,0 +1,115 @@ +// 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'); +} + +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\\"); } }, +}); + +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'); +} + +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'); +} + +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'); +} + +module.exports = metroContext;" +`; + +exports[`getContextModuleTemplate creates an empty template 1`] = ` +" +function metroEmptyContext(request) { + let e = new Error('No modules in context'); + 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'); +} + +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..df71f73c0b --- /dev/null +++ b/packages/metro/src/lib/__tests__/contextModule-test.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import { + fileMatchesContext, + deriveAbsolutePathFromContext, +} from '../contextModule'; + +describe('deriveAbsolutePathFromContext', () => { + it(`appends a context query parameter to the input path`, () => { + expect( + deriveAbsolutePathFromContext('/path/to/project', { + filter: {pattern: '[a-zA-Z]+', flags: ''}, + mode: 'eager', + 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'); + }); +}); + +describe('fileMatchesContext', () => { + it(`matches files`, () => { + expect( + fileMatchesContext('/path/to/project/index.js', { + mode: 'lazy', + from: '/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..81dc4c8c6c --- /dev/null +++ b/packages/metro/src/lib/__tests__/contextModuleTemplates-test.js @@ -0,0 +1,51 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +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', + ]); + expect(template).toMatch(/foo\.js/); + expect(template).toMatchSnapshot(); + }); + it(`creates an empty template`, () => { + const template = getContextModuleTemplate( + 'sync', + '/path/to/project/src', + [], + ); + 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', + ]); + expect(template).toMatchSnapshot(); + }); + it(`creates a lazy template`, () => { + const template = getContextModuleTemplate('lazy', '/path/to/project/src', [ + '/path/to/project/src/foo.js', + ]); + 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'], + ); + + 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 new file mode 100644 index 0000000000..b4824da3fe --- /dev/null +++ b/packages/metro/src/lib/contextModule.js @@ -0,0 +1,86 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import crypto from 'crypto'; +import path from 'path'; +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, + /** Absolute path of the directory to search in */ + from: string, +}>; + +function toHash(value: string): string { + // Use `hex` to ensure filepath safety. + return crypto.createHash('sha1').update(value).digest('hex'); +} + +/** 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 filePath = from.endsWith(path.sep) ? from.slice(0, -1) : from; + return ( + filePath + + '?ctx=' + + toHash( + [ + context.mode, + context.recursive ? 'recursive' : '', + new RegExp(context.filter.pattern, context.filter.flags).toString(), + ] + .filter(Boolean) + .join(' '), + ) + ); +} + +/** Match a file against a require context. */ +export function fileMatchesContext( + testPath: string, + 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 = context.filter; + if ( + // Ignore everything outside of the provided `root`. + !(filePath && !filePath.startsWith('..')) || + // Prevent searching in child directories during a non-recursive search. + (!context.recursive && filePath.includes(path.sep)) || + // Test against the filter. + !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` + './' + filePath.replace(/\\/g, '/'), + ) + ) { + return false; + } + + return true; +} diff --git a/packages/metro/src/lib/contextModuleTemplates.js b/packages/metro/src/lib/contextModuleTemplates.js new file mode 100644 index 0000000000..a05533af5d --- /dev/null +++ b/packages/metro/src/lib/contextModuleTemplates.js @@ -0,0 +1,148 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import * as path from 'path'; +import type {ContextMode} from '../ModuleGraph/worker/collectDependencies'; + +function createFileMap( + modulePath: string, + files: string[], + processModule: (moduleId: string) => string, +): string { + let mapString = '\n'; + + 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, + )}; } },\n`; + }); + return `Object.defineProperties({}, {${mapString}})`; +} + +function getEmptyContextModuleTemplate(modulePath: string): string { + return ` +function metroEmptyContext(request) { + let e = new Error('No modules in context'); + 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'); +} + +module.exports = metroEmptyContext;`; +} + +function getLoadableContextModuleTemplate( + modulePath: string, + files: 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'); +} + +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']`. + * + * @returns a string representing a context module (virtual file contents). + */ +export function getContextModuleTemplate( + mode: ContextMode, + modulePath: string, + files: string[], +): string { + if (!files.length) { + return getEmptyContextModuleTemplate(modulePath); + } + switch (mode) { + case 'eager': + return getLoadableContextModuleTemplate( + modulePath, + files, + // 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, + 'require', + ' return map[request];', + ); + case 'lazy': + case 'lazy-once': + return getLoadableContextModuleTemplate( + modulePath, + files, + '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..4c457d234c 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 { @@ -45,6 +47,7 @@ function getGraphId( runtimeBytecodeVersion: options.runtimeBytecodeVersion, type: options.type, experimentalImportBundleSupport, + unstable_allowRequireContext, shallow, unstable_transformProfile: options.unstable_transformProfile || 'default', 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 4455abfb92..9ccd05a353 100644 --- a/packages/metro/src/lib/transformHelpers.js +++ b/packages/metro/src/lib/transformHelpers.js @@ -16,6 +16,9 @@ import type {TransformInputOptions} from '../DeltaBundler/types.flow'; 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'; const path = require('path'); @@ -68,6 +71,8 @@ async function calcTransformerOptions( onProgress: null, experimentalImportBundleSupport: config.transformer.experimentalImportBundleSupport, + unstable_allowRequireContext: + config.transformer.unstable_allowRequireContext, shallow: false, }); @@ -118,15 +123,47 @@ async function getTransformFn( options, ); - return async (path: string) => { - return await bundler.transformFile(path, { - ...transformOptions, - type: getType(transformOptions.type, path, config.resolver.assetExts), - inlineRequires: removeInlineRequiresBlockListFromOptions( - path, - inlineRequires, - ), - }); + return async (modulePath: string, requireContext: ?RequireContext) => { + let templateBuffer: Buffer; + + if (requireContext) { + const graph = await bundler.getDependencyGraph(); + + // 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(requireContext.from, { + filter: requireContext.filter, + recursive: requireContext.recursive, + }); + + const template = getContextModuleTemplate( + requireContext.mode, + requireContext.from, + files, + ); + + templateBuffer = Buffer.from(template); + } + + return await bundler.transformFile( + modulePath, + { + ...transformOptions, + type: getType( + transformOptions.type, + modulePath, + config.resolver.assetExts, + ), + inlineRequires: removeInlineRequiresBlockListFromOptions( + modulePath, + inlineRequires, + ), + }, + templateBuffer, + ); }; } diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index d397fae313..ce1dfdb0e0 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -225,6 +225,19 @@ class DependencyGraph extends EventEmitter { this._haste.end(); } + /** Given a search context, return a list of file paths matching the query. */ + matchFilesWithContext( + from: string, + context: $ReadOnly<{ + /* Should search for files recursively. */ + recursive: boolean, + /* Filter relative paths against a pattern. */ + filter: RegExp, + }>, + ): string[] { + return this._hasteFS.matchFilesWithContext(from, context); + } + resolveDependency( from: string, to: string,