diff --git a/docs/Resolution.md b/docs/Resolution.md index 9572af498c..a64ef796f5 100644 --- a/docs/Resolution.md +++ b/docs/Resolution.md @@ -75,18 +75,21 @@ Parameters: (*context*, *moduleName*, *platform*) 2. If resolved as a Haste package path, then 1. Perform the algorithm for resolving a path (step 2 above). Throw an error if this resolution fails. For example, if the Haste package path for `'a/b'` is `foo/package.json`, perform step 2 as if _moduleName_ was `foo/c`. -6. If [`context.disableHierarchicalLookup`](#disableHierarchicalLookup-boolean) is not `true`, then +6. If [`context.enablePackageExports`](#enablepackageexports-boolean) is enabled, then + 1. Get the result of [**PACKAGE_SELF_RESOLVE**](#package_self_resolve)(*context*, *moduleName*, *platform*). + 2. If resolved, return result. +7. If [`context.disableHierarchicalLookup`](#disableHierarchicalLookup-boolean) is not `true`, then 1. Try resolving _moduleName_ under `node_modules` from the current directory (i.e. parent of [`context.originModulePath`](#originmodulepath-string)) up to the root directory. 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*) for each candidate path. -7. For each element _nodeModulesPath_ of [`context.nodeModulesPaths`](#nodemodulespaths-readonlyarraystring): +8. For each element _nodeModulesPath_ of [`context.nodeModulesPaths`](#nodemodulespaths-readonlyarraystring): 1. Try resolving _moduleName_ under _nodeModulesPath_ as if the latter was another `node_modules` directory (similar to step 5 above). 2. Perform [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*) for each candidate path. -8. If [`context.extraNodeModules`](#extranodemodules-string-string) is set: +9. If [`context.extraNodeModules`](#extranodemodules-string-string) is set: 1. Split _moduleName_ into a package name (including an optional [scope](https://docs.npmjs.com/cli/v8/using-npm/scope)) and relative path. 2. Look up the package name in [`context.extraNodeModules`](#extranodemodules-string-string). If found, then 1. Construct a path _modulePath_ by replacing the package name part of _moduleName_ with the value found in [`context.extraNodeModules`](#extranodemodules-string-string) 2. Return the result of [**RESOLVE_PACKAGE**](#resolve_package)(*context*, *modulePath*, *platform*). -9. If no valid resolution has been found, throw a resolution failure error. +10. If no valid resolution has been found, throw a resolution failure error. #### RESOLVE_MODULE @@ -110,6 +113,18 @@ Parameters: (*context*, *moduleName*, *platform*) 2. Else, log either a package configuration or package encapsulation warning. 2. Return the result of [**RESOLVE_MODULE**](#resolve_module)(*context*, *filePath*, *platform*). +#### PACKAGE_SELF_RESOLVE + +Parameters: (*context*, *moduleName*, *platform*) + +1. Find the closest package to [`context.originModulePath`](#originmodulepath-string). If no package is found, return no resolution. +2. If the package does not declare both a `"name"` field and an `"exports"` field, return no resolution. +3. Split _moduleName_ into a package name (including an optional [scope](https://docs.npmjs.com/cli/v8/using-npm/scope)) and relative path. +4. If the package name does not match the current package's `"name"` field, return no resolution. +5. Get the result of [**RESOLVE_PACKAGE_EXPORTS**](#resolve_package-exports)(*context*, *packagePath*, *filePath*, *exportsField*, *platform*) against the current package's `"exports"` field. + 1. If resolved path exists, return result. + 2. Else, log either a package configuration or package encapsulation warning, then continue with normal resolution. + #### RESOLVE_PACKAGE_EXPORTS Parameters: (*context*, *packagePath*, *filePath*, *exportsField*, *platform*) diff --git a/flow-typed/npm/babel-types_v7.x.x.js b/flow-typed/npm/babel-types_v7.x.x.js index 00aaa15964..c6b6cd7e64 100644 --- a/flow-typed/npm/babel-types_v7.x.x.js +++ b/flow-typed/npm/babel-types_v7.x.x.js @@ -3848,8 +3848,8 @@ declare module "@babel/types" { declare export function prependToMemberExpression(member: BabelNodeMemberExpression, prepend: BabelNodeExpression): BabelNodeMemberExpression declare export function removeProperties(n: T, opts: ?{}): void; declare export function removePropertiesDeep(n: T, opts: ?{}): T; - declare export function getBindingIdentifiers(node: BabelNode, duplicates: boolean, outerOnly?: boolean): { [key: string]: BabelNodeIdentifier | Array } - declare export function getOuterBindingIdentifiers(node: Node, duplicates: boolean): { [key: string]: BabelNodeIdentifier | Array } + declare export function getBindingIdentifiers(node: BabelNode, duplicates?: boolean, outerOnly?: boolean): { [key: string]: BabelNodeIdentifier | Array } + declare export function getOuterBindingIdentifiers(node: Node, duplicates?: boolean): { [key: string]: BabelNodeIdentifier | Array } declare export type TraversalAncestors = Array<{ node: BabelNode, key: string, diff --git a/packages/buck-worker-tool/package.json b/packages/buck-worker-tool/package.json index 6718bf2ee3..48a932bce9 100644 --- a/packages/buck-worker-tool/package.json +++ b/packages/buck-worker-tool/package.json @@ -1,6 +1,6 @@ { "name": "buck-worker-tool", - "version": "0.84.6", + "version": "0.84.7", "description": "Implementation of the Buck worker protocol for Node.js.", "license": "MIT", "repository": { diff --git a/packages/metro-babel-register/package.json b/packages/metro-babel-register/package.json index 0da642023f..bf8583555e 100644 --- a/packages/metro-babel-register/package.json +++ b/packages/metro-babel-register/package.json @@ -1,6 +1,6 @@ { "name": "metro-babel-register", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 babel/register configuration for Metro.", "main": "src/babel-register.js", "exports": { diff --git a/packages/metro-babel-transformer/package.json b/packages/metro-babel-transformer/package.json index 2847747421..e5bfd92028 100644 --- a/packages/metro-babel-transformer/package.json +++ b/packages/metro-babel-transformer/package.json @@ -1,6 +1,6 @@ { "name": "metro-babel-transformer", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Base Babel transformer for Metro.", "main": "src/index.js", "exports": { @@ -26,7 +26,7 @@ "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", "hermes-parser": "0.35.0", - "metro-cache-key": "0.84.6", + "metro-cache-key": "0.84.7", "nullthrows": "^1.1.1" }, "engines": { diff --git a/packages/metro-babel-transformer/src/index.js b/packages/metro-babel-transformer/src/index.js index 4a6eb8aa8d..b9ee815a8d 100644 --- a/packages/metro-babel-transformer/src/index.js +++ b/packages/metro-babel-transformer/src/index.js @@ -40,6 +40,7 @@ type BabelTransformerOptions = Readonly<{ extendsBabelConfigPath?: string, experimentalImportSupport?: boolean, hermesParser?: boolean, + inlinePlatform?: boolean, minify: boolean, platform: ?string, projectRoot: string, @@ -105,7 +106,12 @@ function transform( const babelConfig: BabelTransformOptions = { ast: true, babelrc: options.enableBabelRCLookup, - caller: {bundler: 'metro', name: 'metro', platform: options.platform}, + caller: { + bundler: 'metro', + name: 'metro', + platform: options.platform, + inlinePlatform: options.inlinePlatform, + }, // NOTE(EvanBacon): We split the parse/transform steps up to accommodate // Hermes parsing, but this defaults to cloning the AST which increases // the transformation time by a fair amount. diff --git a/packages/metro-babel-transformer/types/index.d.ts b/packages/metro-babel-transformer/types/index.d.ts index 2a38a188d4..508593dac8 100644 --- a/packages/metro-babel-transformer/types/index.d.ts +++ b/packages/metro-babel-transformer/types/index.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<2503f88efd80b39e4d860870017af66d>> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-babel-transformer/src/index.js @@ -35,6 +35,7 @@ type BabelTransformerOptions = Readonly<{ extendsBabelConfigPath?: string; experimentalImportSupport?: boolean; hermesParser?: boolean; + inlinePlatform?: boolean; minify: boolean; platform: null | undefined | string; projectRoot: string; diff --git a/packages/metro-cache-key/package.json b/packages/metro-cache-key/package.json index d1fde9993c..27269bbac6 100644 --- a/packages/metro-cache-key/package.json +++ b/packages/metro-cache-key/package.json @@ -1,6 +1,6 @@ { "name": "metro-cache-key", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Cache key utility.", "main": "src/index.js", "exports": { diff --git a/packages/metro-cache/package.json b/packages/metro-cache/package.json index 15f32d2ddd..f5abe67634 100644 --- a/packages/metro-cache/package.json +++ b/packages/metro-cache/package.json @@ -1,6 +1,6 @@ { "name": "metro-cache", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Cache layers for Metro.", "main": "src/index.js", "exports": { @@ -21,7 +21,7 @@ "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", - "metro-core": "0.84.6" + "metro-core": "0.84.7" }, "devDependencies": { "memfs": "^4.38.2" diff --git a/packages/metro-cache/src/stores/HttpStore.js b/packages/metro-cache/src/stores/HttpStore.js index 951671b30b..c0d8f43ebe 100644 --- a/packages/metro-cache/src/stores/HttpStore.js +++ b/packages/metro-cache/src/stores/HttpStore.js @@ -369,10 +369,22 @@ export default class HttpStore { res.resume(); }); + // Without this listener a socket-level failure mid-upload (e.g. the peer + // resetting a pooled keep-alive connection) is emitted as an unhandled + // 'error' and takes down the whole process instead of rejecting, which + // also makes #withRetries unable to retry the write. + req.on('error', err => { + reject(new NetworkError(err.message, err.code)); + }); + req.on('timeout', () => { req.destroy(new Error('Request timed out')); }); + gzip.on('error', err => { + reject(err); + }); + gzip.pipe(req); if (value instanceof Buffer) { @@ -396,14 +408,12 @@ export default class HttpStore { return backOff(fn, { jitter: 'full', maxDelay: 30000, - numOfAttempts: this.#getEndpoint.maxAttempts || Number.POSITIVE_INFINITY, + numOfAttempts: endpoint.maxAttempts || Number.POSITIVE_INFINITY, retry: (e: Error) => { if (e instanceof HttpError) { - return this.#getEndpoint.retryStatuses.has(e.code); + return endpoint.retryStatuses.has(e.code); } - return ( - e instanceof NetworkError && this.#getEndpoint.retryNetworkErrors - ); + return e instanceof NetworkError && endpoint.retryNetworkErrors; }, }); } diff --git a/packages/metro-cache/src/stores/__tests__/HttpStore-test.js b/packages/metro-cache/src/stores/__tests__/HttpStore-test.js index e8496ab2b9..7cb8ec1190 100644 --- a/packages/metro-cache/src/stores/__tests__/HttpStore-test.js +++ b/packages/metro-cache/src/stores/__tests__/HttpStore-test.js @@ -290,6 +290,39 @@ describe('HttpStore', () => { }); }); + test('set() retries use the set endpoint config, not the get endpoint', async () => { + jest.useRealTimers(); + // Distinct read/write retry config: reads do not retry, writes retry 503s. + const store = new HttpStore({ + getOptions: {endpoint: 'http://example.com', maxAttempts: 1}, + setOptions: { + endpoint: 'http://example.com', + maxAttempts: 2, + retryStatuses: [503], + }, + }); + const {request} = require('http'); + + request.mockImplementation((opts, callback) => { + if (request.mock.calls.length === 1) { + callback(responseHttpError(503)); + } else { + callback(responseHttpOk('')); + } + return new PassThrough(); + }); + + let error; + try { + await store.set(Buffer.from('key-set'), {foo: 42}); + } catch (e) { + error = e; + } + + expect(error).toBeUndefined(); + expect(request).toHaveBeenCalledTimes(2); + }); + test('sets using the network via PUT method', done => { const store = new HttpStore({endpoint: 'http://www.example.com/endpoint'}); const promise = store.set(Buffer.from('key-set'), {foo: 42}); @@ -332,6 +365,56 @@ describe('HttpStore', () => { }); }); + test('rejects with a NetworkError when the set request socket errors', async () => { + const store = new HttpStore({ + endpoint: 'http://example.com', + maxAttempts: 1, + }); + const promise = store.set(Buffer.from('key-set'), {foo: 42}); + const [opts] = require('http').request.mock.calls[0]; + + expect(opts.method).toEqual('PUT'); + + const err = new Error('read ECONNRESET'); + err.code = 'ECONNRESET'; + httpPassThrough.emit('error', err); + + await expect(promise).rejects.toMatchObject({ + constructor: HttpStore.NetworkError, + code: 'ECONNRESET', + message: 'read ECONNRESET', + }); + }); + + test('set() retries a request socket error when retryNetworkErrors is set', async () => { + jest.useRealTimers(); + const store = new HttpStore({ + endpoint: 'http://example.com', + maxAttempts: 2, + retryNetworkErrors: true, + }); + const {request} = require('http'); + + request.mockImplementation((opts, callback) => { + const req = new PassThrough(); + + if (request.mock.calls.length === 1) { + const err = new Error('read ECONNRESET'); + err.code = 'ECONNRESET'; + process.nextTick(() => req.emit('error', err)); + } else { + callback(responseHttpOk('')); + req.resume(); + } + + return req; + }); + + await store.set(Buffer.from('key-set'), {foo: 42}); + + expect(request).toHaveBeenCalledTimes(2); + }); + test('set() resolves when the HTTP code is in additionalSuccessStatuses', done => { const store = new HttpStore({ endpoint: 'http://www.example.com/endpoint', diff --git a/packages/metro-config/package.json b/packages/metro-config/package.json index 0adaf79df8..c2b6efe80a 100644 --- a/packages/metro-config/package.json +++ b/packages/metro-config/package.json @@ -1,6 +1,6 @@ { "name": "metro-config", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Config parser for Metro.", "main": "src/index.js", "exports": { @@ -22,15 +22,15 @@ "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", - "metro": "0.84.6", - "metro-cache": "0.84.6", - "metro-core": "0.84.6", - "metro-runtime": "0.84.6", + "metro": "0.84.7", + "metro-cache": "0.84.7", + "metro-core": "0.84.7", + "metro-runtime": "0.84.7", "yaml": "^2.6.1" }, "devDependencies": { "@types/connect": "^3.4.35", - "metro-babel-register": "0.84.6", + "metro-babel-register": "0.84.7", "pretty-format": "^29.7.0" }, "engines": { diff --git a/packages/metro-config/src/__tests__/mergeConfig-test.js b/packages/metro-config/src/__tests__/mergeConfig-test.js index 569d3541b3..4a9c78a4e5 100644 --- a/packages/metro-config/src/__tests__/mergeConfig-test.js +++ b/packages/metro-config/src/__tests__/mergeConfig-test.js @@ -29,6 +29,20 @@ describe('mergeConfig', () => { }); }); + test('applies trailing overrides after an async config function', async () => { + const base: InputConfigT = {server: {port: 8081}}; + const asyncOverride = (): Promise => + Promise.resolve({transformer: {assetPlugins: ['async-plugin']}}); + const trailing: InputConfigT = {resolver: {sourceExts: ['ts']}}; + + const result = await mergeConfig(base, asyncOverride, trailing); + + // The base and every config in the chain must survive the async branch. + expect(result.server?.port).toBe(8081); + expect(result.transformer?.assetPlugins).toEqual(['async-plugin']); + expect(result.resolver?.sourceExts).toEqual(['ts']); + }); + describe('server.tls merging', () => { describe('override IS applied when tls is false or object', () => { test('override tls: object replaces base tls: false', () => { diff --git a/packages/metro-config/src/loadConfig.js b/packages/metro-config/src/loadConfig.js index 72d73a8561..b121ed79b7 100644 --- a/packages/metro-config/src/loadConfig.js +++ b/packages/metro-config/src/loadConfig.js @@ -244,7 +244,12 @@ function mergeConfig< typeof next === 'function' ? next(currentConfig) : next; if (nextConfig instanceof Promise) { // $FlowFixMe[incompatible-type] Not clear why Flow doesn't like this - return mergeConfigAsync(nextConfig, reversedConfigs.toReversed()); + return mergeConfigAsync( + nextConfig.then(resolved => + mergeConfigObjects(currentConfig, resolved), + ), + ...reversedConfigs.toReversed(), + ); } currentConfig = mergeConfigObjects(currentConfig, nextConfig) as T; } diff --git a/packages/metro-core/package.json b/packages/metro-core/package.json index 51754b7582..d37b4ca7bc 100644 --- a/packages/metro-core/package.json +++ b/packages/metro-core/package.json @@ -1,6 +1,6 @@ { "name": "metro-core", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Metro's core package.", "main": "src/index.js", "exports": { @@ -20,7 +20,7 @@ "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", - "metro-resolver": "0.84.6" + "metro-resolver": "0.84.7" }, "license": "MIT", "engines": { diff --git a/packages/metro-file-map/package.json b/packages/metro-file-map/package.json index a9f7c6ff1e..4894ad4cdd 100644 --- a/packages/metro-file-map/package.json +++ b/packages/metro-file-map/package.json @@ -1,6 +1,6 @@ { "name": "metro-file-map", - "version": "0.84.6", + "version": "0.84.7", "description": "[Experimental] - 🚇 File crawling, watching and mapping for Metro", "main": "src/index.js", "exports": { @@ -26,8 +26,7 @@ "invariant": "^2.2.4", "jest-worker": "^29.7.0", "micromatch": "^4.0.4", - "nullthrows": "^1.1.1", - "walker": "^1.0.7" + "nullthrows": "^1.1.1" }, "devDependencies": { "slash": "^3.0.0" diff --git a/packages/metro-file-map/src/lib/RootPathUtils.js b/packages/metro-file-map/src/lib/RootPathUtils.js index 5720d60ee9..bb3312108e 100644 --- a/packages/metro-file-map/src/lib/RootPathUtils.js +++ b/packages/metro-file-map/src/lib/RootPathUtils.js @@ -46,6 +46,9 @@ const SEP_UP_FRAGMENT = path.sep + '..'; const UP_FRAGMENT_SEP_LENGTH = UP_FRAGMENT_SEP.length; const CURRENT_FRAGMENT = '.' + path.sep; +const IS_WIN32 = path.sep === '\\'; +const ROOT_BASE_IDX = IS_WIN32 ? 0 : 1; + export class RootPathUtils { #rootDir: string; #rootDirnames: ReadonlyArray; @@ -149,6 +152,12 @@ export class RootPathUtils { const right = pos === 0 ? normalPath : normalPath.slice(pos); if (right.length === 0) { return left; + } else if (IS_WIN32 && pos > this.#rootDepth * UP_FRAGMENT_SEP_LENGTH) { + // On a real file system, navigating to `..` at the top level (posix `/` + // or Windows drive) is a no-op, but we can't respect that on Windows + // because Metro uses e.g. `..\..\D:\foo` to represent cross-drive + // relative paths. + return right; } // left may already end in a path separator only if it is a filesystem root, // '/' or 'X:\'. @@ -198,7 +207,9 @@ export class RootPathUtils { if (relativePath === '') { return {collapsedSegments: 0, normalPath}; } - const left = normalPath + path.sep; + const left = normalPath.endsWith(path.sep) + ? normalPath + : normalPath + path.sep; const rawPath = left + relativePath; if (normalPath === '..' || normalPath.endsWith(SEP_UP_FRAGMENT)) { const collapsed = this.#tryCollapseIndirectionsInSuffix(rawPath, 0, 0); @@ -299,9 +310,10 @@ export class RootPathUtils { }; } - // Cap the number of indirections at the total number of root segments. - // File systems treat '..' at the root as '.'. - if (totalUpIndirections < this.#rootParts.length - 1) { + // Cap the number of indirections at the total number of root parts. + // File systems treat '..' at the root as '.'. For Windows, cross-device + // paths need to survive this. + if (totalUpIndirections < this.#rootParts.length - ROOT_BASE_IDX) { totalUpIndirections++; } diff --git a/packages/metro-file-map/src/lib/TreeFS.js b/packages/metro-file-map/src/lib/TreeFS.js index 7b1691ae2c..0df66c84af 100644 --- a/packages/metro-file-map/src/lib/TreeFS.js +++ b/packages/metro-file-map/src/lib/TreeFS.js @@ -30,7 +30,7 @@ type FileNode = FileMetadata; type MixedNode = FileNode | DirectoryNode; function isDirectory(node: ?MixedNode): node is DirectoryNode { - return node instanceof Map; + return node != null && !Array.isArray(node); } function isRegularFile(node: FileNode): boolean { @@ -493,6 +493,13 @@ export default class TreeFS implements MutableFileSystem { remove(mixedPath: Path, changeListener?: FileSystemListener): void { const normalPath = this.#normalizePath(mixedPath); + this.#removeNormalPath(normalPath, changeListener); + } + + #removeNormalPath( + normalPath: string, + changeListener?: FileSystemListener, + ): void { const result = this.#lookupByNormalPath(normalPath, {followLeaf: false}); if (!result.exists) { return; @@ -501,7 +508,10 @@ export default class TreeFS implements MutableFileSystem { if (isDirectory(node) && node.size > 0) { for (const basename of node.keys()) { - this.remove(canonicalPath + path.sep + basename, changeListener); + this.#removeNormalPath( + canonicalPath + path.sep + basename, + changeListener, + ); } // Removing the last file will delete this directory return; @@ -521,7 +531,7 @@ export default class TreeFS implements MutableFileSystem { // that's not expected to be a case common enough to justify // implementation complexity, or slowing down more common uses of // _lookupByNormalPath. - this.remove(path.dirname(canonicalPath), changeListener); + this.#removeNormalPath(path.dirname(canonicalPath), changeListener); } } } @@ -1224,16 +1234,19 @@ export default class TreeFS implements MutableFileSystem { typeof literalSymlinkTarget === 'string', 'Expected symlink target to be populated.', ); - const absoluteSymlinkTarget = path.resolve( + let absoluteSymlinkTarget = path.resolve( this.#rootDir, canonicalPathOfSymlink, '..', // Symlink target is relative to its containing directory. literalSymlinkTarget, // May be absolute, in which case the above are ignored ); - const normalSymlinkTarget = path.relative( - this.#rootDir, + if (absoluteSymlinkTarget.endsWith(path.sep)) { + absoluteSymlinkTarget = absoluteSymlinkTarget.slice(0, -1); + } + const normalSymlinkTarget = this.#pathUtils.absoluteToNormal( absoluteSymlinkTarget, ); + const result = { ancestorOfRootIdx: this.#pathUtils.getAncestorOfRootIdx(normalSymlinkTarget), diff --git a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js index 8fb693c498..493f9b3eb4 100644 --- a/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js +++ b/packages/metro-file-map/src/lib/__tests__/RootPathUtils-test.js @@ -91,37 +91,59 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => { expect(pathRelative).toHaveBeenCalled(); }); - test.each([ - p('..'), - p('../..'), - p('../../'), - p('normal/path'), - p('normal/path/'), - p('../normal/path'), - p('../normal/path/'), - p('../../normal/path'), - p('../../../normal/path'), - ])(`normalToAbsolute('%s') matches path.resolve`, normalPath => { - let expected = mockPathModule.resolve(rootDir, normalPath); - // Unlike path.resolve, we expect to preserve trailing separators. - if (normalPath.endsWith(sep) && !expected.endsWith(sep)) { - expected += sep; - } - expect(pathUtils.normalToAbsolute(normalPath)).toEqual(expected); - }); + const normalToAbsoluteInputs = + rootDir === p('/project/root') + ? [ + p('..'), + p('../..'), + p('../../'), + p('normal/path'), + p('normal/path/'), + p('../normal/path'), + p('../normal/path/'), + p('../../normal/path'), + // On POSIX, `..` at the root re-enters the root + ...(platform === 'posix' ? [p('../../../normal/path')] : []), + ] + : [ + p('..'), + p('../..'), + p('../../'), + p('normal/path'), + p('normal/path/'), + ]; - test.each([ - p('..'), - p('../root'), - p('../root/path'), - p('../project'), - p('../project/'), - p('../../project/root'), - p('../../project/root/'), - p('../../../normal/path'), - p('../../../normal/path/'), - p('../../..'), - ])( + test.each(normalToAbsoluteInputs)( + `normalToAbsolute('%s') matches path.resolve`, + normalPath => { + let expected = mockPathModule.resolve(rootDir, normalPath); + // Unlike path.resolve, we expect to preserve trailing separators. + if (normalPath.endsWith(sep) && !expected.endsWith(sep)) { + expected += sep; + } + expect(pathUtils.normalToAbsolute(normalPath)).toEqual(expected); + }, + ); + + const relativeToNormalInputs = + rootDir === p('/project/root') + ? [ + p('..'), + p('../root'), + p('../root/path'), + p('../project'), + p('../project/'), + p('../../project/root'), + p('../../project/root/'), + p('../../..'), + // On POSIX, `..` at the root re-enters the root + ...(platform === 'posix' + ? [p('../../../normal/path'), p('../../../normal/path/')] + : []), + ] + : [p('..')]; + + test.each(relativeToNormalInputs)( `relativeToNormal('%s') matches path.resolve + path.relative`, relativePath => { let expected = mockPathModule.relative( @@ -153,4 +175,75 @@ describe.each([['win32'], ['posix']])('RootPathUtils on %s', platform => { ])('getAncestorOfRootIdx (%s => %s)', (input, expected) => { expect(pathUtils.getAncestorOfRootIdx(input)).toEqual(expected); }); + + if (platform === 'win32') { + describe('cross-drive absolute paths (Windows)', () => { + test.each([['C:\\project\\root'], ['C:\\']])( + 'path.relative returns cross-drive target as-is from rootDir=%s', + rootDir => { + expect(mockPathModule.relative(rootDir, 'D:\\some\\file.js')).toEqual( + 'D:\\some\\file.js', + ); + }, + ); + + test.each([ + [ + 'C:\\project\\root', + 'D:\\some\\file.js', + '..\\..\\..\\D:\\some\\file.js', + ], + ['C:\\project\\root', 'D:\\some\\', '..\\..\\..\\D:\\some\\'], + ['C:\\project\\root', 'D:\\', '..\\..\\..\\D:\\'], + ['C:\\', 'D:\\some\\file.js', '..\\D:\\some\\file.js'], + ['C:\\', 'D:\\', '..\\D:\\'], + ['D:\\project\\root', 'C:\\file.js', '..\\..\\..\\C:\\file.js'], + ])( + 'absoluteToNormal emits a ..-chain (rootDir=%s, X=%s -> %s)', + (rootDir, absolutePath, expectedNormal) => { + pathUtils = new RootPathUtils(rootDir); + expect(pathUtils.absoluteToNormal(absolutePath)).toEqual( + expectedNormal, + ); + }, + ); + + test.each([ + ['C:\\project\\root', 'D:\\some\\file.js'], + ['C:\\project\\root', 'D:\\some\\'], + ['C:\\project\\root', 'D:\\'], + ['C:\\', 'D:\\some\\file.js'], + ['C:\\', 'D:\\some\\'], + ['C:\\', 'D:\\'], + ['D:\\project\\root', 'C:\\file.js'], + ['D:\\project\\root', 'C:\\'], + ])( + 'normalToAbsolute(absoluteToNormal(X)) === X for rootDir=%s, X=%s', + (rootDir, absolutePath) => { + pathUtils = new RootPathUtils(rootDir); + const normal = pathUtils.absoluteToNormal(absolutePath); + expect(pathUtils.normalToAbsolute(normal)).toEqual(absolutePath); + }, + ); + + test.each([ + ['C:\\project\\root', 'D:\\dir\\sub', 'extra\\file.js'], + ['C:\\project\\root', 'D:\\', 'foo.js'], + ['C:\\', 'D:\\dir', 'sub\\file.js'], + ])( + 'joinNormalToRelative round-trips cross-drive (rootDir=%s, base=%s, rel=%s)', + (rootDir, baseAbsolute, relativePath) => { + pathUtils = new RootPathUtils(rootDir); + const baseNormal = pathUtils.absoluteToNormal(baseAbsolute); + const {normalPath} = pathUtils.joinNormalToRelative( + baseNormal, + relativePath, + ); + expect(pathUtils.normalToAbsolute(normalPath)).toEqual( + mockPathModule.join(baseAbsolute, relativePath), + ); + }, + ); + }); + } }); diff --git a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js index a65eab4293..716647af71 100644 --- a/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js +++ b/packages/metro-file-map/src/lib/__tests__/TreeFS-test.js @@ -90,6 +90,20 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { expect(tfs.exists(p('/project/link-to-nowhere'))).toBe(false); }); + test('traverses a snapshot deserialized in another realm', () => { + // Under Jest, `node:v8` is a host module, so `deserialize` returns `Map`s + // whose prototype is not the sandbox's `Map.prototype`. + const {deserialize, serialize} = require('node:v8'); + const deserialized = TreeFS.fromDeserializedSnapshot({ + rootDir: p('/project'), + fileSystemData: deserialize(serialize(tfs.getSerializableSnapshot())), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + expect(deserialized.exists(p('/project/foo/another.js'))).toBe(true); + }); + test('implements linkStats()', () => { expect(tfs.linkStats(p('/project/link-to-foo/another.js'))).toEqual({ fileType: 'f', @@ -1235,4 +1249,85 @@ describe.each([['win32'], ['posix']])('TreeFS on %s', platform => { }); }); }); + + if (platform === 'win32') { + describe('cross-drive paths (Windows)', () => { + let tfsCD: TreeFSType; + const externalMeta: FileMetadata = [123, 4, 0, null, 0, 'external']; + + beforeEach(() => { + tfsCD = new TreeFS({ + rootDir: 'C:\\project', + files: new Map([ + ['bar.js', [234, 3, 0, null, 0, 'bar']], + ['..\\..\\D:\\external\\file.js', externalMeta], + ]), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + }); + + test('exists() finds a seeded cross-drive file', () => { + expect(tfsCD.exists('D:\\external\\file.js')).toBe(true); + }); + + test('lookup() returns the absolute drive-prefixed path as realPath', () => { + expect(tfsCD.lookup('D:\\external\\file.js')).toMatchObject({ + exists: true, + type: 'f', + realPath: 'D:\\external\\file.js', + }); + }); + + test('getAllFiles() enumerates cross-drive and in-tree files side by side', () => { + expect(tfsCD.getAllFiles().sort()).toEqual([ + 'C:\\project\\bar.js', + 'D:\\external\\file.js', + ]); + }); + + test('addOrModify() accepts a new cross-drive absolute path', () => { + tfsCD.addOrModify('D:\\added\\later.js', [1, 1, 0, null, 0, 'later']); + expect(tfsCD.exists('D:\\added\\later.js')).toBe(true); + expect(tfsCD.lookup('D:\\added\\later.js')).toMatchObject({ + exists: true, + type: 'f', + realPath: 'D:\\added\\later.js', + }); + }); + + test('remove() deletes a cross-drive entry and prunes empty ancestor dirs', () => { + tfsCD.remove('D:\\external\\file.js'); + expect(tfsCD.exists('D:\\external\\file.js')).toBe(false); + expect(tfsCD.lookup('D:\\external').exists).toBe(false); + expect(tfsCD.exists('C:\\project\\bar.js')).toBe(true); + }); + + test('lookup() reports missing for non-existent cross-drive path', () => { + expect(tfsCD.lookup('D:\\external\\missing.js')).toMatchObject({ + exists: false, + }); + expect(tfsCD.exists('E:\\anywhere.js')).toBe(false); + }); + + test('lookup() follows a symlink whose target is a cross-drive path', () => { + const tfsLink = new TreeFS({ + rootDir: 'C:\\project', + files: new Map([ + ['..\\..\\D:\\external\\file.js', externalMeta], + ['link', [0, 0, 0, null, 'D:\\external\\file.js', null]], + ]), + processFile: () => { + throw new Error('Not implemented'); + }, + }); + expect(tfsLink.lookup('C:\\project\\link')).toMatchObject({ + exists: true, + type: 'f', + realPath: 'D:\\external\\file.js', + }); + }); + }); + } }); diff --git a/packages/metro-file-map/src/plugins/DependencyPlugin.js b/packages/metro-file-map/src/plugins/DependencyPlugin.js index a9f33241cb..50f5551ef3 100644 --- a/packages/metro-file-map/src/plugins/DependencyPlugin.js +++ b/packages/metro-file-map/src/plugins/DependencyPlugin.js @@ -28,8 +28,13 @@ export default class DependencyPlugin extends FileDataPlugin */, ) { if (dependencyExtractor != null) { // $FlowFixMe[unsupported-syntax] - dynamic require - this.#dependencyExtractor = require(dependencyExtractor); + const mod = require(dependencyExtractor); + let extract = mod?.extract; // CJS export or ESM named export + if (extract == null && mod?.__esModule === true) { + extract = mod.default?.extract; + } + this.#dependencyExtractor = extract; } } @@ -40,7 +45,7 @@ module.exports = class DependencyExtractorWorker /*:: implements MetadataWorker const dependencies = this.#dependencyExtractor != null - ? this.#dependencyExtractor.extract( + ? this.#dependencyExtractor( content, filePath, defaultDependencyExtractor.extract, diff --git a/packages/metro-file-map/src/plugins/haste/worker.js b/packages/metro-file-map/src/plugins/haste/worker.js index c6c14806d4..80e01886f9 100644 --- a/packages/metro-file-map/src/plugins/haste/worker.js +++ b/packages/metro-file-map/src/plugins/haste/worker.js @@ -30,7 +30,9 @@ module.exports = class Worker /*:: implements MetadataWorker */ { ) { if (hasteImplModulePath != null) { // $FlowFixMe[unsupported-syntax] - dynamic require - this.#hasteImpl = require(hasteImplModulePath); + const mod = require(hasteImplModulePath); + this.#hasteImpl = + mod.__esModule === true && 'default' in mod ? mod.default : mod; } } diff --git a/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js b/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js index 43f2835646..36e95abf24 100644 --- a/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js +++ b/packages/metro-file-map/src/plugins/mocks/__tests__/MockPlugin-test.js @@ -107,6 +107,25 @@ Duplicate manual mock found for \`foo\`: }); }); + test('removing a non-active duplicate keeps the active mock', () => { + onFileAdded(p('a/__mocks__/foo.js')); + onFileAdded(p('b/__mocks__/foo.js')); + onFileAdded(p('c/__mocks__/foo.js')); // latest wins -> active mock is c + + expect(mockMap.getMockModule('foo')).toBe(p('/root/c/__mocks__/foo.js')); + + // Remove a NON-active duplicate (b); the active mock (c) must be kept. + mockMap.onChanged({ + addedFiles: new Map(), + modifiedFiles: new Map(), + removedFiles: new Map([[p('b/__mocks__/foo.js'), null]]), + addedDirectories: new Set(), + removedDirectories: new Set(), + }); + + expect(mockMap.getMockModule('foo')).toBe(p('/root/c/__mocks__/foo.js')); + }); + test('loads from a snapshot', async () => { await mockMap.initialize({ files: { diff --git a/packages/metro-file-map/src/watchers/FallbackWatcher.js b/packages/metro-file-map/src/watchers/FallbackWatcher.js index a9b2d4b8d9..70f31707cb 100644 --- a/packages/metro-file-map/src/watchers/FallbackWatcher.js +++ b/packages/metro-file-map/src/watchers/FallbackWatcher.js @@ -24,8 +24,6 @@ import * as common from './common'; import fs from 'fs'; import os from 'os'; import path from 'path'; -// $FlowFixMe[untyped-import] - Write libdefs for `walker` -import walker from 'walker'; const platform = os.platform(); @@ -33,6 +31,7 @@ const fsPromises = fs.promises; const TOUCH_EVENT = common.TOUCH_EVENT; const DELETE_EVENT = common.DELETE_EVENT; +const RECRAWL_EVENT = common.RECRAWL_EVENT; /** * This setting delays all events. It suppresses 'change' events that @@ -41,6 +40,13 @@ const DELETE_EVENT = common.DELETE_EVENT; */ const DEBOUNCE_MS = 100; +/** + * The number of entries `recReaddir` visits at a time. Enough to keep the libuv + * thread pool saturated, few enough that a large tree does not queue up an + * unbounded number of fs requests. + */ +const CRAWL_CONCURRENCY = 32; + export default class FallbackWatcher extends AbstractWatcher { +#changeTimers: Map = new Map(); +#dirRegistry: { @@ -52,25 +58,26 @@ export default class FallbackWatcher extends AbstractWatcher { async startWatching(): Promise { this.#watchdir(this.root); - await new Promise(resolve => { - recReaddir( - this.root, - dir => { - this.#watchdir(dir); - }, - filename => { - this.#register(filename, 'f'); - }, - symlink => { - this.#register(symlink, 'l'); - }, - () => { - resolve(); - }, - this.#checkedEmitError, - this.ignored, - ); - }); + await recReaddir( + this.root, + dir => { + this.#watchdir(dir); + }, + filename => { + this.#register(filename, 'f'); + }, + symlink => { + this.#register(symlink, 'l'); + }, + this.#checkedEmitError, + this.ignored, + ); + + if (platform === 'win32') { + // Inherited from sane: win32 needs a settling period after the watches + // are registered before events can be relied on. + await new Promise(resolve => setTimeout(resolve, 1000)); + } } /** @@ -175,12 +182,28 @@ export default class FallbackWatcher extends AbstractWatcher { if (this.#watched[dir]) { return false; } - const watcher = fs.watch(dir, {persistent: true}, (event, filename) => - this.#normalizeChange(dir, event, filename), - ); + let watcher: FSWatcher; + try { + watcher = fs.watch(dir, {persistent: true}, (event, filename) => + this.#normalizeChange(dir, event, filename), + ); + } catch (error) { + // A directory we cannot watch is still worth crawling, so report the + // error and carry on rather than losing the subtree under it. + this.#checkedEmitError(error); + return false; + } this.#watched[dir] = watcher; - watcher.on('error', this.#checkedEmitError); + watcher.on('error', error => { + // Node has already closed the watcher, and will not emit 'close'. Forget + // it, so that stopping doesn't wait on it and the path can be watched + // again. + if (this.#watched[dir] === watcher) { + delete this.#watched[dir]; + } + this.#checkedEmitError(error); + }); if (this.root !== dir) { this.#register(dir, 'd'); @@ -212,60 +235,12 @@ export default class FallbackWatcher extends AbstractWatcher { await Promise.all(promises); } - /** - * On some platforms, as pointed out on the fs docs (most likely just win32) - * the file argument might be missing from the fs event. Try to detect what - * change by detecting if something was deleted or the most recent file change. - */ - #detectChangedFile( - dir: string, - event: string, - callback: (file: string) => void, - ) { - if (!this.#dirRegistry[dir]) { - return; - } - - let found = false; - let closest: ?Readonly<{file: string, mtime: Stats['mtime']}> = null; - let c = 0; - Object.keys(this.#dirRegistry[dir]).forEach((file, i, arr) => { - fs.lstat(path.join(dir, file), (error, stat) => { - if (found) { - return; - } - - if (error) { - if (isIgnorableFileError(error)) { - found = true; - callback(file); - } else { - this.emitError(error); - } - } else { - if (closest == null || stat.mtime > closest.mtime) { - closest = {file, mtime: stat.mtime}; - } - if (arr.length === ++c) { - callback(closest.file); - } - } - }); - }); - } - /** * Normalize fs events and pass it on to be processed. */ #normalizeChange(dir: string, event: string, file: string) { if (!file) { - this.#detectChangedFile(dir, event, actualFile => { - if (actualFile) { - this.#processChange(dir, event, actualFile).catch(error => - this.emitError(error), - ); - } - }); + this.#processUnnamedChange(dir).catch(error => this.emitError(error)); } else { this.#processChange(dir, event, path.normalize(file)).catch(error => this.emitError(error), @@ -273,6 +248,35 @@ export default class FallbackWatcher extends AbstractWatcher { } } + /** + * Process an event that doesn't name the changed entry. Windows sends these + * when changes to a directory overflow the buffer they're reported through, + * so any number of entries under `dir` may have been added, changed or + * removed. + */ + async #processUnnamedChange(dir: string) { + // Watch and register anything new, so that later changes are reported. + await recReaddir( + dir, + subdir => { + this.#watchdir(subdir); + }, + filename => { + this.#register(filename, 'f'); + }, + symlink => { + this.#register(symlink, 'l'); + }, + this.#checkedEmitError, + this.ignored, + ); + // Then have the file map reconcile everything under `dir`. + this.#emitEvent({ + event: RECRAWL_EVENT, + relativePath: path.relative(this.root, dir), + }); + } + /** * Process changes. */ @@ -296,7 +300,7 @@ export default class FallbackWatcher extends AbstractWatcher { ) { return; } - recReaddir( + await recReaddir( path.resolve(this.root, relativePath), (dir, stats) => { if (this.#watchdir(dir)) { @@ -337,10 +341,15 @@ export default class FallbackWatcher extends AbstractWatcher { }); } }, - function endCallback() {}, this.#checkedEmitError, this.ignored, ); + // A directory we already knew about has been replaced, so entries we + // registered under it may since have changed or been removed. Have the + // file map reconcile it. + if (registered) { + this.#emitEvent({event: RECRAWL_EVENT, relativePath}); + } } else { const type = common.typeFromStat(stat); if (type == null) { @@ -425,45 +434,69 @@ function isIgnorableFileError(error: Error | {code: string}) { } /** - * Traverse a directory recursively calling `callback` on every directory. + * Traverse a directory recursively, reporting every entry with its stats. + * + * Entries are visited depth-first by a bounded pool, so neither the queue of + * pending entries nor the number of in-flight fs requests grows with the size + * of the tree. */ -function recReaddir( - dir: string, +async function recReaddir( + root: string, dirCallback: (string, Stats) => void, fileCallback: (string, Stats) => void, symlinkCallback: (string, Stats) => void, - endCallback: () => void, errorCallback: Error => void, ignored: ?RegExp, -) { - const walk = walker(dir); - if (ignored) { - walk.filterDir( - (currentDir: string) => - !common.posixPathMatchesPattern(ignored, currentDir), - ); - } - walk - .on('dir', normalizeProxy(dirCallback)) - .on('file', normalizeProxy(fileCallback)) - .on('symlink', normalizeProxy(symlinkCallback)) - .on('error', errorCallback) - .on('end', () => { - if (platform === 'win32') { - setTimeout(endCallback, 1000); - } else { - endCallback(); +): Promise { + const pending = [root]; + + const visit = async (entry: string) => { + let stats; + let names; + try { + stats = await fsPromises.lstat(entry); + if (!stats.isDirectory()) { + if (stats.isSymbolicLink()) { + symlinkCallback(entry, stats); + } else if (stats.isFile()) { + fileCallback(entry, stats); + } + return; } - }); -} + if (ignored != null && common.posixPathMatchesPattern(ignored, entry)) { + return; + } + // Report the directory before listing it. A consumer that starts watching + // here would otherwise miss anything written between the two. + dirCallback(entry, stats); + names = await fsPromises.readdir(entry); + } catch (error) { + errorCallback(error); + return; + } + for (const name of names) { + pending.push(path.join(entry, name)); + } + }; -/** - * Returns a callback that when called will normalize a path and call the - * original callback - */ -function normalizeProxy( - callback: (filepath: string, stats: Stats) => T, -): (string, Stats) => T { - return (filepath: string, stats: Stats) => - callback(path.normalize(filepath), stats); + await new Promise((resolve, reject) => { + let active = 0; + const pump = () => { + while (active < CRAWL_CONCURRENCY) { + const entry = pending.pop(); + if (entry == null) { + break; + } + active++; + visit(entry).then(() => { + active--; + pump(); + }, reject); + } + if (active === 0) { + resolve(); + } + }; + pump(); + }); } diff --git a/packages/metro-file-map/src/watchers/NativeWatcher.js b/packages/metro-file-map/src/watchers/NativeWatcher.js index d687fd1f1e..1c24ed9cde 100644 --- a/packages/metro-file-map/src/watchers/NativeWatcher.js +++ b/packages/metro-file-map/src/watchers/NativeWatcher.js @@ -8,6 +8,7 @@ * @format */ +import type {WatcherBackendChangeEvent} from '../flow-types'; import type {FSWatcher} from 'fs'; import {AbstractWatcher} from './AbstractWatcher'; @@ -46,6 +47,11 @@ const RECRAWL_EVENT = 'recrawl'; export default class NativeWatcher extends AbstractWatcher { #fsWatcher: ?FSWatcher; + /** + * Promise chain to emit events in the order they were received. + */ + #emitQueue: Promise = Promise.resolve(); + static isSupported(): boolean { return platform() === 'darwin'; } @@ -59,7 +65,7 @@ export default class NativeWatcher extends AbstractWatcher { ... }>, ) { - if (!NativeWatcher.isSupported) { + if (!NativeWatcher.isSupported()) { throw new Error('This watcher can only be used on macOS'); } super(dir, opts); @@ -76,7 +82,26 @@ export default class NativeWatcher extends AbstractWatcher { recursive: true, }, (event, relativePath) => { - this._handleEvent(event, relativePath).catch(error => { + // Start handling immediately so that stats are gathered concurrently + // and as close as possible to the event, but emit in arrival order. + const settled = this.#handleEvent(event, relativePath).then( + change => ({change, error: null}), + (error: Error) => ({change: null, error}), + ); + const emitted = this.#emitQueue.then(() => + settled.then(({change, error}) => { + if (error != null) { + throw error; + } + if (change != null) { + this.emitFileEvent(change); + } + }), + ); + // Report failures outside the queue, so that a throwing emitError + // (e.g. with no error listener) can't suppress later events. + this.#emitQueue = emitted.catch(() => {}); + emitted.catch(error => { this.emitError(error); }); }, @@ -95,7 +120,14 @@ export default class NativeWatcher extends AbstractWatcher { } } - async _handleEvent(event: string, relativePath: string): Promise { + /** + * Resolve a raw `fs.watch` event into the event to emit for it, or `null` if + * it should be dropped. + */ + async #handleEvent( + event: string, + relativePath: string, + ): Promise> { const absolutePath = path.resolve(this.root, relativePath); if (this.doIgnore(relativePath)) { debug( @@ -104,7 +136,7 @@ export default class NativeWatcher extends AbstractWatcher { relativePath, this.root, ); - return; + return null; } debug( 'Handling event "%s" on %s (root: %s)', @@ -119,11 +151,11 @@ export default class NativeWatcher extends AbstractWatcher { // Ignore files of an unrecognized type if (!type) { - return; + return null; } if (!includedByGlob(type, this.globs, this.dot, relativePath)) { - return; + return null; } // For directory "rename" events, notify that we need a recrawl since we @@ -136,14 +168,13 @@ export default class NativeWatcher extends AbstractWatcher { 'Directory rename detected on %s, requesting recrawl', relativePath, ); - this.emitFileEvent({ + return { event: RECRAWL_EVENT, relativePath, - }); - return; + }; } - this.emitFileEvent({ + return { event: TOUCH_EVENT, relativePath, metadata: { @@ -151,14 +182,13 @@ export default class NativeWatcher extends AbstractWatcher { modifiedTime: stat.mtime.getTime(), size: stat.size, }, - }); + }; } catch (error) { if (error?.code !== 'ENOENT') { - this.emitError(error); - return; + throw error; } - this.emitFileEvent({event: DELETE_EVENT, relativePath}); + return {event: DELETE_EVENT, relativePath}; } } } diff --git a/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js new file mode 100644 index 0000000000..c23224c3a6 --- /dev/null +++ b/packages/metro-file-map/src/watchers/__tests__/FallbackWatcher-test.js @@ -0,0 +1,137 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import FallbackWatcher from '../FallbackWatcher'; +import {createTempWatchRoot} from './helpers'; +import fs from 'node:fs'; +import {join} from 'node:path'; + +jest.useRealTimers(); +jest.setTimeout(10 * 1000); + +const {mkdir, rm, writeFile} = fs.promises; + +describe('FallbackWatcher', () => { + let watchRoot: string; + let watcher: ?FallbackWatcher; + let calls: Array; + let watchFailure: ?{code: string, path: string}; + + const indexOfCall = (op: 'watch' | 'readdir', dir: string) => + calls.indexOf(`${op}:${dir}`); + + const expectWatchedBeforeListed = (dir: string) => { + expect(indexOfCall('watch', dir)).toBeGreaterThanOrEqual(0); + expect(indexOfCall('watch', dir)).toBeLessThan(indexOfCall('readdir', dir)); + }; + + beforeEach(async () => { + watchRoot = await createTempWatchRoot('Fallback', false); + calls = []; + watchFailure = null; + + const {watch} = fs; + jest.spyOn(fs, 'watch').mockImplementation((dir, ...args) => { + calls.push(`watch:${String(dir)}`); + const failure = watchFailure; + if (failure != null && dir === failure.path) { + const error = new Error(`Cannot watch path '${String(dir)}'.`); + // $FlowFixMe[prop-missing] code + error.code = failure.code; + throw error; + } + return watch(dir, ...args); + }); + const {readdir} = fs.promises; + // $FlowFixMe[incompatible-call] - variadic passthrough + jest.spyOn(fs.promises, 'readdir').mockImplementation((dir, ...args) => { + calls.push(`readdir:${String(dir)}`); + return readdir(dir, ...args); + }); + + watcher = new FallbackWatcher(watchRoot, { + dot: true, + globs: [], + ignored: null, + watchmanDeferStates: [], + }); + }); + + afterEach(async () => { + await watcher?.stopWatching(); + jest.restoreAllMocks(); + await rm(watchRoot, {recursive: true}); + }); + + // A file written into a directory after it has been listed but before it is + // watched is reported by neither the listing nor any subsequent event, and is + // missed until the next full crawl. This is how installing a package against + // a running server loses files: https://github.com/expo/expo/issues/48950 + describe('watches each directory before listing it', () => { + test('during the initial crawl', async () => { + await mkdir(join(watchRoot, 'a', 'b'), {recursive: true}); + + await watcher?.startWatching(); + + for (const dir of ['', 'a', join('a', 'b')]) { + expectWatchedBeforeListed(join(watchRoot, dir)); + } + }); + + test('for a directory created while watching', async () => { + await watcher?.startWatching(); + calls = []; + + const nested = join(watchRoot, 'new', 'nested'); + await mkdir(nested, {recursive: true}); + await writeFile(join(nested, 'file.js'), ''); + await waitFor(() => indexOfCall('readdir', nested) >= 0); + + for (const dir of [join(watchRoot, 'new'), nested]) { + expectWatchedBeforeListed(dir); + } + }); + }); + + // A watch we cannot establish - one directory over the inotify limit, say - + // must not cost us the files under it, which would silently truncate the + // file map. + test.each([ + ['ENOSPC', 1], + ['ENOENT', 0], + ])( + 'crawls past a directory it cannot watch (%s)', + async (code, expectedErrors) => { + await mkdir(join(watchRoot, 'a', 'b'), {recursive: true}); + watchFailure = {code, path: join(watchRoot, 'a')}; + const errors: Array = []; + watcher?.onError(error => { + errors.push(error); + }); + + await expect(watcher?.startWatching()).resolves.toBeUndefined(); + + expect(errors).toHaveLength(expectedErrors); + for (const dir of ['a', join('a', 'b')]) { + expect( + indexOfCall('readdir', join(watchRoot, dir)), + ).toBeGreaterThanOrEqual(0); + } + }, + ); +}); + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 5000; + while (!predicate() && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 20)); + } +} diff --git a/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js b/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js new file mode 100644 index 0000000000..f79f21fc71 --- /dev/null +++ b/packages/metro-file-map/src/watchers/__tests__/NativeWatcher-test.js @@ -0,0 +1,164 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {WatcherBackendChangeEvent} from '../../flow-types'; + +import NativeWatcher from '../NativeWatcher'; +import fs from 'fs'; +import os from 'os'; +import {join, resolve} from 'path'; + +jest.useRealTimers(); + +// Absolute on every platform, with a drive letter on Windows, as the watcher +// resolves its root. +const ROOT = resolve('/', 'project'); + +type Deferred = { + promise: Promise, + resolve: T => void, + reject: Error => void, +}; + +function deferred(): Deferred { + let resolve: T => void = () => {}; + let reject: Error => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return {promise, resolve, reject}; +} + +function fileStat(mtimeMs: number): fs.Stats { + // $FlowFixMe[incompatible-type] - only the fields NativeWatcher reads + return { + isSymbolicLink: () => false, + isDirectory: () => false, + isFile: () => true, + mtime: new Date(mtimeMs), + size: 42, + }; +} + +function enoent(): Error { + const error = new Error('ENOENT: no such file or directory'); + // $FlowFixMe[prop-missing] - Node system errors carry a code + error.code = 'ENOENT'; + return error; +} + +// Run every pending promise continuation, including those queued by others. +const flush = () => new Promise(resolve => setImmediate(resolve)); + +describe('NativeWatcher', () => { + let watcher: NativeWatcher; + let emitFsEvent: (event: string, relativePath: string) => void; + let pendingStats: Map>; + let events: Array; + + beforeEach(async () => { + jest.spyOn(os, 'platform').mockReturnValue('darwin'); + jest.spyOn(fs, 'watch').mockImplementation((_root, _opts, listener) => { + emitFsEvent = listener; + return {close: () => {}}; + }); + pendingStats = new Map(); + jest.spyOn(fs.promises, 'lstat').mockImplementation(absolutePath => { + const stat = deferred(); + pendingStats.set(String(absolutePath), stat); + return stat.promise; + }); + + watcher = new NativeWatcher(ROOT, {dot: true, globs: [], ignored: null}); + events = []; + watcher.onFileEvent(event => { + events.push(event); + }); + await watcher.startWatching(); + }); + + afterEach(async () => { + await watcher.stopWatching(); + jest.restoreAllMocks(); + }); + + function settleStat(relativePath: string, result: fs.Stats | Error): void { + const stat = pendingStats.get(join(ROOT, relativePath)); + if (stat == null) { + throw new Error(`No lstat pending for ${relativePath}`); + } + if (result instanceof Error) { + stat.reject(result); + } else { + stat.resolve(result); + } + } + + test('emits events in the order fs.watch reported them, however their stats settle', async () => { + emitFsEvent('rename', join('app', 'moved-in', 'file.js')); + emitFsEvent('rename', join('app', 'moved-in')); + emitFsEvent('change', join('app', 'other.js')); + + // All stats start immediately, before any has settled. + expect([...pendingStats.keys()]).toEqual([ + join(ROOT, 'app', 'moved-in', 'file.js'), + join(ROOT, 'app', 'moved-in'), + join(ROOT, 'app', 'other.js'), + ]); + + // Settle in reverse order. Nothing can be emitted until the first settles. + settleStat(join('app', 'other.js'), fileStat(1000)); + settleStat(join('app', 'moved-in'), enoent()); + await flush(); + expect(events).toEqual([]); + + settleStat(join('app', 'moved-in', 'file.js'), enoent()); + await flush(); + expect(events).toEqual([ + { + event: 'delete', + relativePath: join('app', 'moved-in', 'file.js'), + root: ROOT, + }, + {event: 'delete', relativePath: join('app', 'moved-in'), root: ROOT}, + { + event: 'touch', + relativePath: join('app', 'other.js'), + root: ROOT, + metadata: {type: 'f', modifiedTime: 1000, size: 42}, + }, + ]); + }); + + test('an lstat failure is reported in order and does not block later events', async () => { + const order: Array = []; + watcher.onFileEvent(event => { + order.push(`${event.event}:${event.relativePath}`); + }); + watcher.onError(error => { + order.push(`error:${error.message}`); + }); + + emitFsEvent('change', 'first.js'); + emitFsEvent('change', 'second.js'); + emitFsEvent('change', 'third.js'); + + settleStat('third.js', fileStat(3000)); + settleStat('second.js', new Error('EACCES')); + await flush(); + expect(order).toEqual([]); + + settleStat('first.js', fileStat(1000)); + await flush(); + expect(order).toEqual(['touch:first.js', 'error:EACCES', 'touch:third.js']); + }); +}); diff --git a/packages/metro-file-map/src/worker.js b/packages/metro-file-map/src/worker.js index 92a20908e7..a76b00a8f6 100644 --- a/packages/metro-file-map/src/worker.js +++ b/packages/metro-file-map/src/worker.js @@ -36,7 +36,9 @@ class Worker { constructor({plugins = []} /*: WorkerSetupArgs */) { this.#plugins = plugins.map(({modulePath, setupArgs}) => { // $FlowFixMe[unsupported-syntax] - dynamic require - const PluginWorker = require(modulePath); + const mod = require(modulePath); + const PluginWorker = + mod.__esModule === true && 'default' in mod ? mod.default : mod; return new PluginWorker(setupArgs); }); } diff --git a/packages/metro-file-map/types/watchers/NativeWatcher.d.ts b/packages/metro-file-map/types/watchers/NativeWatcher.d.ts index 8b2069ffc6..95f70bbf82 100644 --- a/packages/metro-file-map/types/watchers/NativeWatcher.d.ts +++ b/packages/metro-file-map/types/watchers/NativeWatcher.d.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @noformat - * @generated SignedSource<<99faac6446a910cc70728424f342908c>> + * @generated SignedSource<<6297e6ebfbeb2920f00b15cba9acbce2>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-file-map/src/watchers/NativeWatcher.js @@ -50,6 +50,5 @@ declare class NativeWatcher extends AbstractWatcher { * End watching. */ stopWatching(): Promise; - _handleEvent(event: string, relativePath: string): Promise; } export default NativeWatcher; diff --git a/packages/metro-minify-terser/package.json b/packages/metro-minify-terser/package.json index ed99fbbded..63387d36d3 100644 --- a/packages/metro-minify-terser/package.json +++ b/packages/metro-minify-terser/package.json @@ -1,6 +1,6 @@ { "name": "metro-minify-terser", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Minifier for Metro based on Terser.", "main": "src/index.js", "exports": { diff --git a/packages/metro-resolver/package.json b/packages/metro-resolver/package.json index a8bd7fa5dc..a62fa64ed5 100644 --- a/packages/metro-resolver/package.json +++ b/packages/metro-resolver/package.json @@ -1,6 +1,6 @@ { "name": "metro-resolver", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Implementation of Metro's resolution logic.", "main": "src/index.js", "exports": { @@ -22,7 +22,7 @@ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" }, "devDependencies": { - "metro": "0.84.6" + "metro": "0.84.7" }, "dependencies": { "flow-enums-runtime": "^0.0.6" diff --git a/packages/metro-resolver/src/PackageResolve.js b/packages/metro-resolver/src/PackageResolve.js index f12c17a8da..fb85bffaed 100644 --- a/packages/metro-resolver/src/PackageResolve.js +++ b/packages/metro-resolver/src/PackageResolve.js @@ -164,7 +164,7 @@ export function redirectModulePath( * - `false`, indicating the module should be ignored. * - `null` when there is no entry for the subpath. */ -function matchSubpathFromMainFields( +export function matchSubpathFromMainFields( /** * The subpath, or set of subpath variants, to match. Can be either a * package-relative subpath (beginning with '.') or a bare import specifier @@ -174,24 +174,36 @@ function matchSubpathFromMainFields( pkg: PackageJson, mainFields: ReadonlyArray, ): string | false | null { - const fieldValues = mainFields + // Merge object-valued main fields ("browser"-style maps) into a single + // replacement map. We iterate `mainFields` in reverse so that, on a key + // conflict, earlier `mainFields` win, equivalent to + // `Object.assign({}, ...fieldValues.reverse())`, but avoiding any allocation + // in the the most common case (no object-valued field, e.g. only a string + // "main"/"browser"). + let replacements: {[string]: string | false} | null = null; + for (let i = mainFields.length - 1; i >= 0; i--) { // $FlowFixMe[invalid-computed-prop] - .map(name => pkg[name]) - .filter(value => value != null && typeof value !== 'string'); + const value = pkg[mainFields[i]]; + if (value != null && typeof value !== 'string') { + if (replacements == null) { + replacements = {}; + } + replacements = {...replacements, ...value}; + } + } - if (!fieldValues.length) { + if (replacements == null) { return null; } - // $FlowFixMe[unsafe-object-assign] - const replacements = Object.assign({}, ...fieldValues.reverse()); + // The list of subpath variants is only built in this rare matched case (a + // single subpath is expanded to its "browser"-spec variants; a pre-expanded + // array is matched as-is). const variants = Array.isArray(subpath) ? subpath : expandSubpathVariants(subpath); - for (const variant of variants) { const replacement = replacements[variant]; - if (replacement != null) { return replacement; } diff --git a/packages/metro-resolver/src/__tests__/browser-spec-test.js b/packages/metro-resolver/src/__tests__/browser-spec-test.js index c3122002ee..9b4f326008 100644 --- a/packages/metro-resolver/src/__tests__/browser-spec-test.js +++ b/packages/metro-resolver/src/__tests__/browser-spec-test.js @@ -99,4 +99,61 @@ describe('browser field spec', () => { }); }); }); + + describe('replace specific files', () => { + test('should resolve a bare-specifier redirect relative to the origin package root, not its containing directory', () => { + // Per the browser spec, paths in the `browser` map are relative to the + // package.json file location. When the origin module lives in a + // subdirectory of its package (here `lib/nested/`), the redirect target + // must still resolve against the package root. + const packageJson = { + name: 'origin-pkg', + main: 'lib/nested/index.js', + browser: { + 'foo-pkg': './shims/foo.js', + }, + }; + const context = { + ...createResolutionContext({ + '/root/node_modules/origin-pkg/package.json': + JSON.stringify(packageJson), + '/root/node_modules/origin-pkg/lib/nested/index.js': '', + '/root/node_modules/origin-pkg/shims/foo.js': '', + }), + originModulePath: '/root/node_modules/origin-pkg/lib/nested/index.js', + mainFields: ['browser', 'main'], + }; + + expect(Resolver.resolve(context, 'foo-pkg', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/origin-pkg/shims/foo.js', + }); + }); + + test('should resolve a bare-specifier redirect for an origin outside of `node_modules`', () => { + // Project-level package.json — there is no enclosing `node_modules` + // segment, so the old heuristic of slicing after `node_modules/` would + // misbehave. The redirect must resolve against the package root. + const context = { + ...createResolutionContext({ + '/root/project/package.json': JSON.stringify({ + name: 'project', + main: 'src/index.js', + browser: { + 'foo-pkg': './shims/foo.js', + }, + }), + '/root/project/src/index.js': '', + '/root/project/shims/foo.js': '', + }), + originModulePath: '/root/project/src/index.js', + mainFields: ['browser', 'main'], + }; + + expect(Resolver.resolve(context, 'foo-pkg', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/project/shims/foo.js', + }); + }); + }); }); diff --git a/packages/metro-resolver/src/__tests__/index-test.js b/packages/metro-resolver/src/__tests__/index-test.js index 24c07a33c7..bfe6f8876c 100644 --- a/packages/metro-resolver/src/__tests__/index-test.js +++ b/packages/metro-resolver/src/__tests__/index-test.js @@ -15,7 +15,7 @@ import type {ResolutionContext} from '../index'; import {createResolutionContext} from './utils'; -let Resolver = require('../index'); +const Resolver = require('../index'); const fileMap = { '/root/project/foo.js': '', @@ -396,67 +396,84 @@ test('throws a descriptive error when a file inside a Haste package cannot be re `); }); -describe('redirectModulePath', () => { - const mockRedirectModulePath = jest.fn(); - const context = CONTEXT; - - beforeEach(() => { - mockRedirectModulePath.mockReset(); - mockRedirectModulePath.mockImplementation(filePath => false); - - jest.resetModules(); - jest.mock('../PackageResolve', () => { - return { - ...jest.requireActual('../PackageResolve'), - redirectModulePath: (_ctx, specifier) => - mockRedirectModulePath(specifier), - }; - }); - - Resolver = require('../index'); - }); - - afterEach(() => { - jest.unmock('../PackageResolve'); - jest.resetModules(); - Resolver = require('../index'); - }); - +describe('browser spec redirection', () => { test('is used for relative path requests', () => { + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + name: 'project', + browser: { + './bar': false, + }, + }), + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/foo.js', + }; expect(Resolver.resolve(context, './bar', null)).toMatchInlineSnapshot(` Object { "type": "empty", } `); - expect(mockRedirectModulePath).toBeCalledTimes(1); - expect(mockRedirectModulePath).toBeCalledWith('/root/project/bar'); }); test('is used for absolute path requests', () => { - expect(Resolver.resolve(context, '/bar', null)).toMatchInlineSnapshot(` + // browser field redirects the specifier to false + const testFileMap = { + '/root/bar.js': '', + '/root/package.json': JSON.stringify({ + browser: { + './bar': false, + }, + }), + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/other/project/foo.js', + }; + expect(Resolver.resolve(context, '/root/bar', null)).toMatchInlineSnapshot(` Object { "type": "empty", } `); - expect(mockRedirectModulePath).toBeCalledTimes(1); - expect(mockRedirectModulePath).toBeCalledWith('/bar'); }); test('is used for non-Haste package requests', () => { + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + name: 'project', + browser: { + 'does-not-exist': false, + }, + }), + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/foo.js', + }; expect(Resolver.resolve(context, 'does-not-exist', null)) .toMatchInlineSnapshot(` Object { "type": "empty", } `); - expect(mockRedirectModulePath).toBeCalledTimes(1); - expect(mockRedirectModulePath).toBeCalledWith('does-not-exist'); }); test('can redirect to an arbitrary relative module', () => { - mockRedirectModulePath - .mockImplementationOnce(filePath => '../smth/beep') - .mockImplementation(filePath => filePath); + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + browser: { + 'does-not-exist': '../smth/beep', + }, + }), + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/foo.js', + }; expect(Resolver.resolve(context, 'does-not-exist', null)) .toMatchInlineSnapshot(` Object { @@ -464,97 +481,77 @@ describe('redirectModulePath', () => { "type": "sourceFile", } `); - expect(mockRedirectModulePath.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "does-not-exist", - ], - Array [ - "/root/smth/beep", - ], - Array [ - "/root/smth/beep.js", - ], - ] - `); }); - test("is called for source extension candidates that don't exist on disk", () => { - mockRedirectModulePath.mockImplementation(filePath => - filePath.replace('.another-fake-ext', '.js'), - ); - expect( - Resolver.resolve( - {...context, sourceExts: ['fake-ext', 'another-fake-ext']}, - '../smth/beep', - null, - ), - ).toMatchInlineSnapshot(` + test('rejects redirection of a bare specifier to an absolute path', () => { + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + name: 'project', + browser: { + 'foo-pkg': '/otherroot/foo', + }, + }), + '/otherroot/foo.js': '', + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/bar.js', + }; + expect(() => Resolver.resolve(context, 'foo-pkg', null)) + .toThrowErrorMatchingInlineSnapshot(` +"The package /root/project contains an invalid package.json configuration. Consider raising this issue with the package maintainer(s). +Reason: Attempted to redirect import to an absolute path. This is not allowed by the \\"browser\\" spec. + From: /root/project/bar.js + Import: foo-pkg + Attempted redirect: /otherroot/foo" +`); + }); + + test('resolves source extension candidates to relative paths', () => { + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + name: 'project', + browser: { + './beep.another-fake-ext': '../smth/beep.js', + }, + }), + '/root/project/beep.js': '', + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/foo.js', + sourceExts: ['fake-ext', 'another-fake-ext'], + }; + expect(Resolver.resolve(context, './beep', null)).toMatchInlineSnapshot(` Object { "filePath": "/root/smth/beep.js", "type": "sourceFile", } `); - expect(mockRedirectModulePath.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/root/smth/beep", - ], - Array [ - "/root/smth/beep.fake-ext", - ], - Array [ - "/root/smth/beep.another-fake-ext", - ], - ] - `); }); test('can resolve to empty from a candidate with an added source extension', () => { - mockRedirectModulePath.mockImplementation(filePath => - filePath.endsWith('.fake-ext') ? false : filePath, - ); - expect( - Resolver.resolve( - {...context, sourceExts: ['fake-ext', 'js']}, - '../smth/beep', - null, - ), - ).toMatchInlineSnapshot(` + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + name: 'project', + browser: { + './beep.fake-ext': false, + }, + }), + }; + const context = { + ...createResolutionContext(testFileMap), + originModulePath: '/root/project/foo.js', + sourceExts: ['fake-ext', 'js'], + }; + expect(Resolver.resolve(context, './beep', null)).toMatchInlineSnapshot(` Object { "type": "empty", } `); - expect(mockRedirectModulePath.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/root/smth/beep", - ], - Array [ - "/root/smth/beep.fake-ext", - ], - ] - `); - }); - - test('is not called redundantly for a candidate that does exist on disk', () => { - mockRedirectModulePath.mockImplementation(filePath => filePath); - expect(Resolver.resolve(context, './bar', null)).toMatchInlineSnapshot(` - Object { - "filePath": "/root/project/bar.js", - "type": "sourceFile", - } - `); - expect(mockRedirectModulePath.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "/root/project/bar", - ], - Array [ - "/root/project/bar.js", - ], - ] - `); }); }); @@ -643,9 +640,17 @@ describe('resolveRequest', () => { }); test('is called with the platform and non-redirected module path', () => { + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + browser: { + 'does-not-exist': './redirected', + }, + }), + }; const contextWithRedirect = { ...context, - redirectModulePath: (filePath: string) => filePath + '.redirected', + ...createResolutionContext(testFileMap), }; expect(Resolver.resolve(contextWithRedirect, 'does-not-exist', 'android')) .toMatchInlineSnapshot(` @@ -666,9 +671,17 @@ describe('resolveRequest', () => { type: 'sourceFile', filePath: '/some/fake/path', })); + const testFileMap = { + ...fileMap, + '/root/project/package.json': JSON.stringify({ + browser: { + 'does-not-exist': false, + }, + }), + }; const contextWithRedirect = { ...context, - redirectModulePath: (filePath: string) => false as const, + ...createResolutionContext(testFileMap), }; expect(Resolver.resolve(contextWithRedirect, 'does-not-exist', 'android')) .toMatchInlineSnapshot(` diff --git a/packages/metro-resolver/src/__tests__/package-exports-test.js b/packages/metro-resolver/src/__tests__/package-exports-test.js index a3df8b864f..cbd622da03 100644 --- a/packages/metro-resolver/src/__tests__/package-exports-test.js +++ b/packages/metro-resolver/src/__tests__/package-exports-test.js @@ -655,6 +655,45 @@ describe('with package exports resolution enabled', () => { }); }); + test.each([ + ['null', 'internal'], + ['no matching condition', 'server'], + ])( + 'exact subpath with %s target does not fall back to a pattern', + (_, subpath) => { + const logWarning = jest.fn(); + const context = { + ...createResolutionContext({ + '/root/src/main.js': '', + '/root/node_modules/test-pkg/package.json': JSON.stringify({ + name: 'test-pkg', + exports: { + './*': './lib/*.js', + './internal': null, + './server': {browser: './server-browser.js'}, + }, + }), + '/root/node_modules/test-pkg/lib/internal.js': '', + '/root/node_modules/test-pkg/lib/server.js': '', + '/root/node_modules/test-pkg/internal.js': '', + '/root/node_modules/test-pkg/server.js': '', + }), + originModulePath: '/root/src/main.js', + unstable_enablePackageExports: true, + unstable_logWarning: logWarning, + }; + + expect(Resolver.resolve(context, `test-pkg/${subpath}`, null)).toEqual({ + type: 'sourceFile', + filePath: `/root/node_modules/test-pkg/${subpath}.js`, + }); + expect(logWarning).toHaveBeenCalledTimes(1); + expect(logWarning.mock.calls[0][0]).toContain( + `"/root/node_modules/test-pkg/${subpath}" which is listed in the "exports"`, + ); + }, + ); + describe('package encapsulation', () => { test('[nonstrict] should fall back to "browser" spec resolution and log inaccessible import warning', () => { const logWarning = jest.fn(); @@ -1210,4 +1249,97 @@ describe('with package exports resolution enabled', () => { }); }); }); + + describe('self-referencing imports (PACKAGE_SELF_RESOLVE)', () => { + const packageJson = { + name: 'self-pkg', + main: 'index-main.js', + exports: { + '.': './index.js', + './sub': './lib/sub.js', + }, + }; + const baseContext = { + ...createResolutionContext({ + '/root/node_modules/self-pkg/package.json': JSON.stringify(packageJson), + '/root/node_modules/self-pkg/index.js': '', + '/root/node_modules/self-pkg/index-main.js': '', + '/root/node_modules/self-pkg/lib/sub.js': '', + '/root/node_modules/self-pkg/lib/internal.js': '', + }), + originModulePath: '/root/node_modules/self-pkg/index.js', + unstable_enablePackageExports: true, + }; + + test('should resolve own package name via its own "exports" field', () => { + expect(Resolver.resolve(baseContext, 'self-pkg', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/self-pkg/index.js', + }); + }); + + test('should resolve a subpath of the own package via "exports"', () => { + expect(Resolver.resolve(baseContext, 'self-pkg/sub', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/self-pkg/lib/sub.js', + }); + }); + + test('[nonstrict] should warn and fall back when self subpath is not in "exports"', () => { + const logWarning = jest.fn(); + // `./lib/internal.js` is not exported, but exists on disk. We log the + // out-of-exports access and fall through to the regular hierarchical + // lookup, which finds the file inside `node_modules/self-pkg/`. + expect( + Resolver.resolve( + {...baseContext, unstable_logWarning: logWarning}, + 'self-pkg/lib/internal', + null, + ), + ).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/self-pkg/lib/internal.js', + }); + expect(logWarning).toHaveBeenCalled(); + expect(logWarning.mock.calls[0][0]).toMatch( + /which is not listed in the "exports"/, + ); + }); + + test('should not self-resolve when origin package has no name', () => { + const noNameContext = { + ...baseContext, + ...createPackageAccessors({ + '/root/node_modules/self-pkg/package.json': { + main: 'index-main.js', + exports: {'.': './index.js'}, + }, + }), + }; + // With no `name`, falls through to hierarchical lookup (and finds the + // package in node_modules, going through "exports" as a normal import). + expect(Resolver.resolve(noNameContext, 'self-pkg', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/self-pkg/index.js', + }); + }); + + test('should not self-resolve when the package has no "exports" field', () => { + const noExportsContext = { + ...baseContext, + ...createPackageAccessors({ + '/root/node_modules/self-pkg/package.json': { + name: 'self-pkg', + main: 'index-main.js', + }, + }), + }; + // Without `exports`, self-resolve does not apply. Falls back to + // node_modules lookup, which resolves via "main". + expect(Resolver.resolve(noExportsContext, 'self-pkg', null)).toEqual({ + type: 'sourceFile', + filePath: '/root/node_modules/self-pkg/index-main.js', + }); + }); + }); }); diff --git a/packages/metro-resolver/src/__tests__/package-imports-test.js b/packages/metro-resolver/src/__tests__/package-imports-test.js index 4a08ceb122..c1559900ee 100644 --- a/packages/metro-resolver/src/__tests__/package-imports-test.js +++ b/packages/metro-resolver/src/__tests__/package-imports-test.js @@ -85,6 +85,31 @@ describe('import subpath patterns resolution support', () => { filePath: p('/root/node_modules/test-pkg/src/features/foo.js.js'), }); }); + + test('exact subpath with a null or unmatched target does not fall back to a pattern', () => { + const logWarning = jest.fn(); + const context = { + ...createResolutionContext({ + [p('/root/node_modules/test-pkg/package.json')]: JSON.stringify({ + name: 'test-pkg', + imports: { + '#features/*': './src/features/*.js', + '#features/foo': null, + '#features/bar': {browser: './src/features/bar.web.js'}, + }, + }), + [p('/root/node_modules/test-pkg/src/index.js')]: '', + [p('/root/node_modules/test-pkg/src/features/foo.js')]: '', + [p('/root/node_modules/test-pkg/src/features/bar.js')]: '', + }), + originModulePath: p('/root/node_modules/test-pkg/src/index.js'), + unstable_logWarning: logWarning, + }; + + expect(() => Resolver.resolve(context, '#features/foo', null)).toThrow(); + expect(() => Resolver.resolve(context, '#features/bar', null)).toThrow(); + expect(logWarning).toHaveBeenCalledTimes(2); + }); }); describe('import subpath conditional imports resolution', () => { diff --git a/packages/metro-resolver/src/resolve.js b/packages/metro-resolver/src/resolve.js index ebde8adf27..7ace085ae4 100644 --- a/packages/metro-resolver/src/resolve.js +++ b/packages/metro-resolver/src/resolve.js @@ -26,7 +26,11 @@ import PackageImportNotResolvedError from './errors/PackageImportNotResolvedErro import PackagePathNotExportedError from './errors/PackagePathNotExportedError'; import {resolvePackageTargetFromExports} from './PackageExportsResolve'; import {resolvePackageTargetFromImports} from './PackageImportsResolve'; -import {getPackageEntryPoint, redirectModulePath} from './PackageResolve'; +import { + getPackageEntryPoint, + matchSubpathFromMainFields, + redirectModulePath, +} from './PackageResolve'; import resolveAsset from './resolveAsset'; import isAssetFile from './utils/isAssetFile'; import {posixToSystemPath} from './utils/paths'; @@ -110,42 +114,67 @@ export default function resolve( } } - const redirectedSpecifier = redirectModulePath(context, specifier); + const {originModulePath} = context; + + const closestPackageToOrigin = context.getPackageForModule(originModulePath); + + const maybeRedirectedSpecifier = + closestPackageToOrigin != null + ? matchSubpathFromMainFields( + specifier, + closestPackageToOrigin.packageJson, + context.mainFields, + ) + : null; // exclude - if (redirectedSpecifier === false) { + if (maybeRedirectedSpecifier === false) { return {type: 'empty'}; } - const {originModulePath} = context; - - const isDirectImport = - isRelativeImport(redirectedSpecifier) || - path.isAbsolute(redirectedSpecifier); - - if (isDirectImport) { - // derive absolute path /.../node_modules/originModuleDir/redirectedSpecifier - const fromModuleParentIdx = - originModulePath.lastIndexOf('node_modules' + path.sep) + 13; - const originModuleDir = originModulePath.slice( - 0, - originModulePath.indexOf(path.sep, fromModuleParentIdx), + // If the specifier was redirected to a relative path + if ( + maybeRedirectedSpecifier != null && + closestPackageToOrigin != null && // Implied by maybeRedirectedSpecifier != null + isRelativeImport(maybeRedirectedSpecifier) + ) { + // Per the "browser" spec: "All paths for browser fields are relative to + // the package.json file location". `closestPackageToOrigin` is the package + // that provided the redirect, so join relative paths to its `rootPath`. + const absPath = path.resolve( + closestPackageToOrigin.rootPath, + maybeRedirectedSpecifier, ); - const absPath = path.join(originModuleDir, redirectedSpecifier); const result = resolveModulePath(context, absPath, platform); if (result.type === 'failed') { throw new FailedToResolvePathError(result.candidates); } return result.resolution; + } else if ( + maybeRedirectedSpecifier != null && + closestPackageToOrigin != null && // Implied by maybeRedirectedSpecifier != null + // An absolute path inside the file may not match the system's path separator + (path.posix.isAbsolute(maybeRedirectedSpecifier) || + path.win32.isAbsolute(maybeRedirectedSpecifier)) + ) { + throw new InvalidPackageConfigurationError({ + packagePath: closestPackageToOrigin.rootPath, + reason: + 'Attempted to redirect import to an absolute path. ' + + 'This is not allowed by the "browser" spec.' + + `\n From: ${originModulePath}` + + `\n Import: ${specifier}` + + `\n Attempted redirect: ${maybeRedirectedSpecifier}`, + }); } - /** - * At this point, redirectedSpecifier is not a "direct" (absolute or relative) - * import, so it's a bare specifier - for our purposes either Haste name - * or a package specifier. - */ - - const parsedSpecifier = parseBareSpecifier(redirectedSpecifier); + // At this point, maybeRedirectedSpecifier is either null (not redirected), + // and `specifier` is still bare, or `maybeRedirectedSpecifier` is a + // redirected bare specifier. Supported bare specifiers are either Haste + // names, package names, or (rarely) non-package modules under node_modules. + const parsedSpecifier = parseBareSpecifier( + maybeRedirectedSpecifier ?? specifier, + ); if (context.allowHaste) { if (parsedSpecifier.isSinglePart) { @@ -162,9 +191,57 @@ export default function resolve( } } - /** - * redirectedSpecifier is now a package specifier. - */ + // parsedSpecifier is now a non-Haste bare specifier. + + // PACKAGE_SELF_RESOLVE: if the origin lives inside a package whose `name` + // matches the bare specifier and which declares an `exports` field, resolve + // the request via that package's `exports`. Per Node.js ESM spec section 6.5 + // (https://nodejs.org/api/esm.html#resolution-algorithm-specification). + if ( + context.unstable_enablePackageExports && + closestPackageToOrigin != null && + closestPackageToOrigin.packageJson.exports != null && + closestPackageToOrigin.packageJson.name === parsedSpecifier.packageName + ) { + try { + const exportsField = closestPackageToOrigin.packageJson.exports; + const packageExportsResult = resolvePackageTargetFromExports( + context, + closestPackageToOrigin.rootPath, + path.join( + closestPackageToOrigin.rootPath, + parsedSpecifier.posixSubpath, + ), + // packageRelativePath for the requested subpath, stripping the leading + // './' so the empty string represents the package root (matching how + // `getPackageForModule` reports `packageRelativePath`). + parsedSpecifier.posixSubpath === '.' + ? '' + : parsedSpecifier.posixSubpath.slice(2), + exportsField, + platform, + ); + if (packageExportsResult != null) { + return packageExportsResult; + } + } catch (e) { + // NB: Falling back is a departure from the spec, but retained for + // backwards compatibility. Remove this in a breaking change. + if (e instanceof PackagePathNotExportedError) { + context.unstable_logWarning( + e.message + + ' Falling back to hierarchical resolution for backwards compatibility.', + ); + } else if (e instanceof InvalidPackageConfigurationError) { + context.unstable_logWarning( + e.message + + ' Falling back to hierarchical resolution for backwards compatibility.', + ); + } else { + throw e; + } + } + } const {disableHierarchicalLookup} = context; diff --git a/packages/metro-resolver/src/utils/matchSubpathFromExportsLike.js b/packages/metro-resolver/src/utils/matchSubpathFromExportsLike.js index 2caed7d08d..17e24f35e8 100644 --- a/packages/metro-resolver/src/utils/matchSubpathFromExportsLike.js +++ b/packages/metro-resolver/src/utils/matchSubpathFromExportsLike.js @@ -49,11 +49,16 @@ export function matchSubpathFromExportsLike( createConfigError, ); - let target = exportsLikeMapAfterConditions.get(subpath); + let target = null; let patternMatch = null; - // Attempt to match after expanding any subpath pattern keys - if (target == null) { + // Attempt to match subpath pattern keys only if there is no exact key. An + // exact key takes precedence even when its target is null. See step 2 of + // `PACKAGE_IMPORTS_EXPORTS_RESOLVE` in: + // https://nodejs.org/api/esm.html#resolution-algorithm-specification + if (exportsLikeMapAfterConditions.has(subpath)) { + target = exportsLikeMapAfterConditions.get(subpath); + } else { // Gather keys which are subpath patterns in descending order of specificity // For ordering, see `PATTERN_KEY_COMPARE` in: // https://nodejs.org/api/esm.html#resolution-algorithm-specification diff --git a/packages/metro-resolver/types/PackageResolve.d.ts b/packages/metro-resolver/types/PackageResolve.d.ts index 00327004b0..54a4a20ca7 100644 --- a/packages/metro-resolver/types/PackageResolve.d.ts +++ b/packages/metro-resolver/types/PackageResolve.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<7e880beb8b73f4a072dfd248ff41d7b0>> + * @generated SignedSource<<20b836efd67d20258d429521857d8eab>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-resolver/src/PackageResolve.js @@ -15,7 +15,7 @@ * yarn run build-ts-defs (OSS) */ -import type {PackageInfo, ResolutionContext} from './types'; +import type {PackageInfo, PackageJson, ResolutionContext} from './types'; /** * Resolve the main entry point subpath for a package. * @@ -45,3 +45,18 @@ export declare function redirectModulePath( }>, modulePath: string, ): string | false; +/** + * Get the mapped replacement for the given subpath defined by matching + * `mainFields` entries in the passed `package.json` + * (https://github.com/defunctzombie/package-browser-field-spec#replace-specific-files---advanced). + * + * Returns either: + * - A `string` with the matched replacement subpath. + * - `false`, indicating the module should be ignored. + * - `null` when there is no entry for the subpath. + */ +export declare function matchSubpathFromMainFields( + subpath: string | ReadonlyArray, + pkg: PackageJson, + mainFields: ReadonlyArray, +): string | false | null; diff --git a/packages/metro-runtime/package.json b/packages/metro-runtime/package.json index 45b7a06536..ee0fcef8c0 100644 --- a/packages/metro-runtime/package.json +++ b/packages/metro-runtime/package.json @@ -1,6 +1,6 @@ { "name": "metro-runtime", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Module required for evaluating Metro bundles.", "exports": { "./package.json": "./package.json", diff --git a/packages/metro-runtime/src/polyfills/__tests__/MetroFastRefreshMockRuntime.js b/packages/metro-runtime/src/polyfills/__tests__/MetroFastRefreshMockRuntime.js index d24841019f..2a313ba860 100644 --- a/packages/metro-runtime/src/polyfills/__tests__/MetroFastRefreshMockRuntime.js +++ b/packages/metro-runtime/src/polyfills/__tests__/MetroFastRefreshMockRuntime.js @@ -41,6 +41,17 @@ export class Runtime { */ metroRequire: RequireFn; + /** + * Registers a lazy segment module definer (see require.js + * `__registerSegment`). Used to model bundles that define modules lazily, + * such as the Buck "plain bundle with switch" output. + */ + registerSegment: ( + segmentId: number, + moduleDefiner: (moduleId: number) => void, + moduleIds?: ?ReadonlyArray, + ) => void; + // Special modules /** @@ -83,6 +94,7 @@ export class Runtime { createModuleSystem(this.#global, /* __DEV__ */ true, this.#globalPrefix); this.define = this.#global[this.#globalPrefix + '__d']; this.metroRequire = this.#global[this.#globalPrefix + '__r']; + this.registerSegment = this.#global.__registerSegment; // Set up Fast Refresh. Adapted from `setUpReactRefresh.js` in React Native. jest.isolateModules(() => { diff --git a/packages/metro-runtime/src/polyfills/__tests__/fast-refresh-integration-test.js b/packages/metro-runtime/src/polyfills/__tests__/fast-refresh-integration-test.js index 4ac612f7af..f5e878b197 100644 --- a/packages/metro-runtime/src/polyfills/__tests__/fast-refresh-integration-test.js +++ b/packages/metro-runtime/src/polyfills/__tests__/fast-refresh-integration-test.js @@ -128,4 +128,70 @@ describe('Fast Refresh integration with require()', () => { ['Fast Refresh - Invalidated boundary '], ]); }); + + test('handles a lazily-registered (unloaded) parent during Fast Refresh', async () => { + const {define, metroRequire, registerSegment, events} = new Runtime(); + + const ids = { + 'util.js': 1, + 'UnloadedScreen.js': 2, + }; + + // A parent module that imports util.js but is never required in this + // session. It is only reachable through a lazy segment definer, mirroring + // the Buck "plain bundle with switch" output: it is absent from the module + // registry until first required, whereas an eager bundle would have defined + // it (uninitialized) at startup. + registerSegment(0, moduleId => { + if (moduleId === ids['UnloadedScreen.js']) { + define( + (global, require, _2, _3, module) => { + require(ids['util.js']); + module.exports = {}; + }, + ids['UnloadedScreen.js'], + {0: ids['util.js']}, + 'UnloadedScreen.js', + ); + } + }); + + // util.js is a plain (non-component) module, loaded directly. + define( + (global, _1, _2, _3, module) => { + module.exports = {value: 'v1'}; + }, + ids['util.js'], + undefined, + 'util.js', + ); + expect(metroRequire(ids['util.js'])).toEqual({value: 'v1'}); + + // Edit util.js. Its inverse dependencies include UnloadedScreen.js, which + // has never been required and so is absent from the registry. Before + // materialising it on demand, the Fast Refresh dependency walk would throw + // "[Refresh] Expected to find the updated module." + expect(() => { + define( + (global, _1, _2, _3, module) => { + module.exports = {value: 'v2'}; + }, + ids['util.js'], + {}, + 'util.js', + // Inverse dependency map + { + [ids['util.js']]: [ids['UnloadedScreen.js']], + [ids['UnloadedScreen.js']]: [], + }, + ); + jest.runAllTimers(); + }).not.toThrow(); + + // Matches the behaviour of an eager bundle: no boundary is found up the + // chain of the (now materialised, uninitialized) unloaded parent, so we + // fall back to a full reload rather than crashing. + expect(events.onFullReload).toHaveBeenCalled(); + expect(events.onFastRefresh).not.toHaveBeenCalled(); + }); }); diff --git a/packages/metro-runtime/src/polyfills/require.js b/packages/metro-runtime/src/polyfills/require.js index 660444bae6..e263bad03e 100644 --- a/packages/metro-runtime/src/polyfills/require.js +++ b/packages/metro-runtime/src/polyfills/require.js @@ -577,6 +577,23 @@ if (__DEV__) { let reactRefreshTimeout: null | TimeoutID = null; + // When a module is defined lazily via a segment definer, modules that + // have not been required yet are absent from `modules`, and can't be walked + // by Fast Refresh. This materialises an absent module definition on demand + // by invoking its segment definer, which only *defines* the module + // (registers its factory) without executing it. No-op if already defined. + const ensureModuleRegistered = function (moduleId: ModuleID): void { + if (modules.has(moduleId) || moduleDefinersBySegmentID.length === 0) { + return; + } + const segmentId = definingSegmentByModuleID.get(moduleId) ?? 0; + const definer = moduleDefinersBySegmentID[segmentId]; + if (definer != null) { + definer(moduleId); + definingSegmentByModuleID.delete(moduleId); + } + }; + const metroHotUpdateModule = function ( id: ModuleID, factory: FactoryFn, @@ -650,6 +667,9 @@ if (__DEV__) { updatedModuleIDs = topologicalSort( [id], // Start with the changed module and go upwards pendingID => { + // Modules delivered by a lazy segment definer are not present in + // `modules` until first required - ensure they are defined. + ensureModuleRegistered(pendingID); const pendingModule = modules.get(pendingID); if (pendingModule == null) { // Nothing to do. @@ -778,6 +798,7 @@ if (__DEV__) { // Schedule all parent refresh boundaries to re-run in this loop. for (let j = 0; j < parentIDs.length; j++) { const parentID = parentIDs[j]; + ensureModuleRegistered(parentID); const parentMod = modules.get(parentID); if (parentMod == null) { throw new Error('[Refresh] Expected to find parent module.'); diff --git a/packages/metro-source-map/package.json b/packages/metro-source-map/package.json index 8faed421d0..60e547b545 100644 --- a/packages/metro-source-map/package.json +++ b/packages/metro-source-map/package.json @@ -1,6 +1,6 @@ { "name": "metro-source-map", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Source map generator for Metro.", "main": "src/source-map.js", "exports": { @@ -22,9 +22,9 @@ "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-symbolicate": "0.84.6", + "metro-symbolicate": "0.84.7", "nullthrows": "^1.1.1", - "ob1": "0.84.6", + "ob1": "0.84.7", "source-map": "^0.5.6", "vlq": "^1.0.0" }, diff --git a/packages/metro-source-map/src/Consumer/MappingsConsumer.js b/packages/metro-source-map/src/Consumer/MappingsConsumer.js index bae03c4234..54777ef14a 100644 --- a/packages/metro-source-map/src/Consumer/MappingsConsumer.js +++ b/packages/metro-source-map/src/Consumer/MappingsConsumer.js @@ -140,7 +140,6 @@ export default class MappingsConsumer originalColumnDelta, nameIndexDelta, ] = decodedVlqValues; - decodeVlq(mappingRaw); invariant(generatedColumnDelta != null, 'Invalid generated column delta'); generatedColumn = add(generatedColumn, generatedColumnDelta); const mapping: {...Mapping, ...} = { diff --git a/packages/metro-source-map/src/Consumer/SectionsConsumer.js b/packages/metro-source-map/src/Consumer/SectionsConsumer.js index 3eec4df156..37fa097567 100644 --- a/packages/metro-source-map/src/Consumer/SectionsConsumer.js +++ b/packages/metro-source-map/src/Consumer/SectionsConsumer.js @@ -69,8 +69,8 @@ export default class SectionsConsumer (get1(mapping.generatedLine) > 1 || get0(mapping.generatedColumn) > 0) ) { yield { - generatedLine: FIRST_LINE, - generatedColumn: FIRST_COLUMN, + generatedLine: add(FIRST_LINE, generatedOffset.lines), + generatedColumn: add(FIRST_COLUMN, generatedOffset.columns), source: null, name: null, originalLine: null, @@ -81,10 +81,10 @@ export default class SectionsConsumer yield { ...mapping, generatedLine: add(mapping.generatedLine, generatedOffset.lines), - generatedColumn: add( - mapping.generatedColumn, - generatedOffset.columns, - ), + generatedColumn: + mapping.generatedLine === FIRST_LINE + ? add(mapping.generatedColumn, generatedOffset.columns) + : mapping.generatedColumn, }; } } diff --git a/packages/metro-source-map/src/__tests__/Consumer-test.js b/packages/metro-source-map/src/__tests__/Consumer-test.js index c7b056bc76..9a5e2baf8c 100644 --- a/packages/metro-source-map/src/__tests__/Consumer-test.js +++ b/packages/metro-source-map/src/__tests__/Consumer-test.js @@ -527,6 +527,48 @@ describe('indexed (sectioned) maps', () => { }); }); + describe('generatedMappings()', () => { + test('applies the section offset to generated positions', () => { + const consumer = new Consumer({ + version: 3, + sections: [ + { + offset: {line: 0, column: 0}, + map: { + version: 3, + names: [], + sources: ['section0_source0'], + mappings: 'AAAA', + }, + }, + { + offset: {line: 0, column: 4}, + map: { + version: 3, + names: [], + sources: ['section1_source0'], + mappings: 'CAAA;AACA', + }, + }, + ], + }); + expect( + [...consumer.generatedMappings()].map(mapping => [ + mapping.generatedLine, + mapping.generatedColumn, + mapping.source, + ]), + ).toEqual([ + [1, 0, 'section0_source0'], + // Unmapped from the start of section 1 until its first mapping + [1, 4, null], + [1, 5, 'section1_source0'], + // The column offset only applies to the first line of a section + [2, 0, 'section1_source0'], + ]); + }); + }); + describe('sourceContentFor', () => { test('empty map', () => { const consumer = new Consumer({ diff --git a/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js b/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js index 5e90b5f8ed..1d161fe2fd 100644 --- a/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js +++ b/packages/metro-source-map/src/__tests__/composeSourceMaps-test.js @@ -331,6 +331,40 @@ describe('composeSourceMaps', () => { `); }); + test('composes a last map with multi-line sections at a column offset', () => { + const indexedMap: IndexMap = { + version: 3, + sections: [ + { + offset: {line: 0, column: 0}, + map: {version: 3, names: [], sources: ['a.js'], mappings: 'AAAA'}, + }, + { + offset: {line: 0, column: 4}, + map: { + version: 3, + names: [], + sources: ['b.js'], + mappings: 'CAAA;AACA', + }, + }, + ], + }; + const composed = new Consumer(composeSourceMaps([indexedMap])); + const indexed = new Consumer(indexedMap); + for (const [line, column] of [ + [1, 0], + [1, 4], + [1, 5], + [2, 0], + ]) { + const position = {line: add1(line - 1), column: add0(column)}; + expect(composed.originalPositionFor(position)).toEqual( + indexed.originalPositionFor(position), + ); + } + }); + test('Propagate x_hermes_function_offsets', () => { const map1 = { version: 3, diff --git a/packages/metro-symbolicate/package.json b/packages/metro-symbolicate/package.json index 10783abb18..936df36876 100644 --- a/packages/metro-symbolicate/package.json +++ b/packages/metro-symbolicate/package.json @@ -1,6 +1,6 @@ { "name": "metro-symbolicate", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 A tool to find the source location from JS bundles and stack traces.", "license": "MIT", "main": "./src/index.js", @@ -25,7 +25,7 @@ "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-source-map": "0.84.6", + "metro-source-map": "0.84.7", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" diff --git a/packages/metro-symbolicate/src/Symbolication.js b/packages/metro-symbolicate/src/Symbolication.js index 8e6ecd71cc..ec0b551ebd 100644 --- a/packages/metro-symbolicate/src/Symbolication.js +++ b/packages/metro-symbolicate/src/Symbolication.js @@ -163,7 +163,7 @@ class SymbolicationContext { // IOS: foo.js:57:foo, Android: bar.js:75:bar symbolicate(stackTrace: string): string { return stackTrace.replace( - /(?:([^@: \n(]+)(@|:))?(?:(?:([^@: \n(]+):)?(\d+):(\d+)|\[native code\])/g, + /(?:([^@: \n(]+)(@|:))?(?:(?:([^@: \n(]+):)?(\d+):(\d+)|\[native code\](?::\d+:\d+)?)/g, (match, func, delimiter, fileName, line, column) => { if (delimiter === ':' && func && !fileName) { fileName = func; @@ -692,10 +692,25 @@ class SingleMapSymbolicationContext extends SymbolicationContext (some message with no frame) +1 ??? 0x0 throws6 + 1 (thrower.min.js:1:161) +2 ??? 0x0 o + 1 (thrower.min.js:1:464) +3 ??? 0x0 ([native code]:0:0) +4 ??? 0x0 ([native code]:0:0) diff --git a/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap b/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap index e2b6639165..d2135b3829 100644 --- a/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap +++ b/packages/metro-symbolicate/src/__tests__/__snapshots__/symbolicate-test.js.snap @@ -216,6 +216,16 @@ Array [ ] `; +exports[`symbolicating a crash reporter stack trace with 0:0 native frames 1`] = ` +"Non-fatal Exception: JavaScriptError +0 ??? 0x0 (some message with no frame) +1 ??? 0x0 throws6 + 1 (thrower.js:18:null) +2 ??? 0x0 o + 1 (thrower.js:30:arguments) +3 ??? 0x0 (null:null:null) +4 ??? 0x0 (null:null:null) +" +`; + exports[`symbolicating a profiler map 1`] = ` "JS_0000_xxxxxxxxxxxxxxxxxxxxxx throws0::thrower.js:48:11 JS_0001_xxxxxxxxxxxxxxxxxxxxxx throws6::thrower.js:35:38 diff --git a/packages/metro-symbolicate/src/__tests__/symbolicate-test.js b/packages/metro-symbolicate/src/__tests__/symbolicate-test.js index 6512b4e731..4cce261147 100644 --- a/packages/metro-symbolicate/src/__tests__/symbolicate-test.js +++ b/packages/metro-symbolicate/src/__tests__/symbolicate-test.js @@ -215,6 +215,11 @@ test('symbolicating a stack trace in Node format', async () => execute([TESTFILE_MAP], read('testfile.node.stack')), ).resolves.toMatchSnapshot()); +test('symbolicating a crash reporter stack trace with 0:0 native frames', async () => + await expect( + execute([TESTFILE_MAP], read('testfile.crashreporter.stack')), + ).resolves.toMatchSnapshot()); + test('symbolicating a single entry', async () => await expect(execute([TESTFILE_MAP, '1', '161'])).resolves.toEqual( 'thrower.js:18:null\n', diff --git a/packages/metro-transform-plugins/package.json b/packages/metro-transform-plugins/package.json index 008f3e7ccb..7648be5fa5 100644 --- a/packages/metro-transform-plugins/package.json +++ b/packages/metro-transform-plugins/package.json @@ -1,6 +1,6 @@ { "name": "metro-transform-plugins", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Transform plugins for Metro.", "main": "src/index.js", "exports": { @@ -33,7 +33,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/types": "^7.29.0", "babel-plugin-tester": "^6.0.1", - "metro": "0.84.6" + "metro": "0.84.7" }, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" diff --git a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js index d6efe7b5c8..2995a584ec 100644 --- a/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/import-export-plugin-test.js @@ -17,6 +17,8 @@ const {compare, transformToAst} = require('../__mocks__/test-helpers'); const importExportPlugin = require('../import-export-plugin'); // $FlowFixMe[untyped-import] @babel/code-frame const {codeFrameColumns} = require('@babel/code-frame'); +const generate = require('@babel/generator').default; +const vm = require('vm'); const opts = { importAll: '_$$_IMPORT_ALL', @@ -156,8 +158,8 @@ test('exports members of another module directly from an import (as default)', ( var _foo = require('bar').foo; var _baz = require('bar').baz; - exports.default = _foo; exports.baz = _baz; + exports.default = _foo; `; compare([importExportPlugin], code, expected, opts); @@ -233,8 +235,8 @@ test('allows mixed esm and cjs exports', () => { exports.bar = 'bar'; module.exports.baz = 'baz'; class _default {} - exports.default = _default; exports.foo = foo; + exports.default = _default; `; compare([importExportPlugin], code, expected, opts); @@ -255,6 +257,36 @@ test('exports destructured named object members', () => { compare([importExportPlugin], code, expected, opts); }); +test('exports destructured renamed object members', () => { + const code = ` + export const {foo: bar, baz} = {foo: 'bar', baz: 'baz'}; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', {value: true}); + const {foo: bar,baz} = {foo: 'bar', baz: 'baz'}; + exports.bar = bar; + exports.baz = baz; + `; + + compare([importExportPlugin], code, expected, opts); +}); + +test('exports destructured object rest members', () => { + const code = ` + export const {foo, ...bar} = {foo: 'foo', bar: 'bar', baz: 'baz'}; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', {value: true}); + const {foo,...bar} = {foo: 'foo', bar: 'bar', baz: 'baz'}; + exports.foo = foo; + exports.bar = bar; + `; + + compare([importExportPlugin], code, expected, opts); +}); + test('exports destructured named array members', () => { const code = ` export const [foo,bar] = ['bar','baz']; @@ -270,6 +302,21 @@ test('exports destructured named array members', () => { compare([importExportPlugin], code, expected, opts); }); +test('exports destructured array rest members', () => { + const code = ` + export const [foo, ...bar] = ['foo','bar','baz']; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', {value: true}); + const [foo,...bar] = ['foo','bar','baz']; + exports.foo = foo; + exports.bar = bar; + `; + + compare([importExportPlugin], code, expected, opts); +}); + test('exports members of another module directly from an import (as all)', () => { const code = ` export * from 'bar'; @@ -278,9 +325,10 @@ test('exports members of another module directly from an import (as all)', () => const expected = ` Object.defineProperty(exports, '__esModule', {value: true}); - var _bar = require("bar"); + var _bar = require('bar'); for (var _key in _bar) { + if (_key === "default") continue; exports[_key] = _bar[_key]; } `; @@ -294,6 +342,196 @@ test('exports members of another module directly from an import (as all)', () => `); }); +test('exports members of another module directly from an import (as namespace)', () => { + const code = ` + export * as AppleIcons from 'apple-icons'; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', {value: true}); + + var _AppleIcons = _$$_IMPORT_ALL('apple-icons'); + exports.AppleIcons = _AppleIcons; + `; + + compare([importExportPlugin], code, expected, opts); + + expect(showTransformedDeps(code)).toMatchInlineSnapshot(` + " + > 2 | export * as AppleIcons from 'apple-icons'; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ dep #0 (apple-icons)" + `); +}); + +test('places export all above explicit exports', () => { + const code = ` + export * from 'foo'; + export {baz} from 'bar'; + const bax = 'bax'; + export default bax; + `; + + const expected = ` + Object.defineProperty(exports, '__esModule', {value: true}); + + var _foo = require('foo'); + + for (var _key in _foo) { + if (_key === "default") continue; + exports[_key] = _foo[_key]; + } + + var _baz = require('bar').baz; + const bax = 'bax'; + + var _default = bax; + + exports.baz = _baz; + exports.default = _default; + `; + + compare([importExportPlugin], code, expected, opts); +}); + +test('explicit exports override export all at runtime', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + ` + export * from 'foo'; + export const overridden = 'explicit named'; + export default 'explicit default'; + `, + opts, + ), + ).code; + const context = { + exports: {} as {[string]: unknown}, + require: (id: string) => { + if (id !== 'foo') { + throw new Error(`Unexpected module: ${id}`); + } + return { + default: 'star default', + overridden: 'star named', + sourceOnly: 'source only', + }; + }, + }; + + vm.runInNewContext(transformedCode, context); + + expect(context.exports.__esModule).toBe(true); + expect(context.exports.default).toBe('explicit default'); + expect(context.exports.overridden).toBe('explicit named'); + expect(context.exports.sourceOnly).toBe('source only'); +}); + +test('export all does not re-export the default of the source', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + ` + export * from 'foo'; + `, + opts, + ), + ).code; + const context = { + exports: {} as {[string]: unknown}, + require: (id: string) => { + if (id !== 'foo') { + throw new Error(`Unexpected module: ${id}`); + } + return { + default: 'star default', + named: 'star named', + }; + }, + }; + + vm.runInNewContext(transformedCode, context); + + // Per the ES spec (GetExportedNames) and Node.js, `export *` re-exports + // named exports but never the source module's default export. + expect(context.exports.named).toBe('star named'); + expect('default' in context.exports).toBe(false); +}); + +test('export all as namespace includes the default of the source', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + ` + export * as ns from 'foo'; + `, + opts, + ), + ).code; + const source = { + __esModule: true, + default: 'star default', + named: 'star named', + }; + // Faithful stand-in for metroImportAll: resolve the module via require, + // then expose an ES module's namespace as-is (default included). + const requireMock = (id: string) => { + if (id !== 'foo') { + throw new Error(`Unexpected module: ${id}`); + } + return source; + }; + const exportsObj: {[string]: unknown} = {}; + const context: {[string]: unknown} = { + exports: exportsObj, + require: requireMock, + }; + context[opts.importAll] = (id: string) => { + const mod = requireMock(id); + return mod.__esModule === true ? mod : {...mod, default: mod}; + }; + + vm.runInNewContext(transformedCode, context); + + // `export * as ns` (ExportNamespaceSpecifier) creates a namespace object, + // which per the ES spec DOES expose the source module's default export - + // unlike bare `export *`. + expect(exportsObj.ns).toEqual({ + __esModule: true, + default: 'star default', + named: 'star named', + }); +}); + +test('re-export dependencies evaluate before module body at runtime', () => { + const transformedCode = generate( + transformToAst( + [importExportPlugin], + ` + events.push('body'); + export {value} from 'foo'; + export * from 'bar'; + `, + opts, + ), + ).code; + const events = []; + const context = { + events, + exports: {} as {[string]: unknown}, + require: (id: string) => { + events.push(`require ${id}`); + return id === 'foo' ? {value: 'foo value'} : {star: 'bar star'}; + }, + }; + + vm.runInNewContext(transformedCode, context); + + expect(events).toEqual(['require foo', 'require bar', 'body']); + expect(context.exports.value).toBe('foo value'); + expect(context.exports.star).toBe('bar star'); +}); + test('enables module exporting when something is exported', () => { const code = ` foo(); diff --git a/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js b/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js index a1b1010354..fe2fb8d154 100644 --- a/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js +++ b/packages/metro-transform-plugins/src/__tests__/inline-plugin-test.js @@ -399,6 +399,17 @@ describe('inline constants', () => { }); }); + test('uses the last definition when Platform.select has duplicate keys', () => { + const code = ` + var value = Platform.select({ios: 1, ios: 2}); + `; + + compare([inlinePlugin], code, 'var value = 2;', { + inlinePlatform: true, + platform: 'ios', + }); + }); + test('inlines Platform.select in the code when using an ObjectMethod', () => { const code = ` function a() { @@ -709,6 +720,37 @@ describe('inline constants', () => { }); }); + test("doesn't replace Platform.OS in other write targets", () => { + const code = ` + Platform.OS++; + delete Platform.OS; + [Platform.OS] = values; + ({os: Platform.OS} = value); + for (Platform.OS in object) {} + for ([Platform.OS] in nestedObject) {} + for (Platform.OS of values) {} + for ([Platform.OS] of nestedValues) {} + `; + + compare([inlinePlugin], code, code, { + inlinePlatform: true, + platform: 'ios', + }); + }); + + test('replaces Platform.OS when it is read inside a write target', () => { + const code = ` + target[Platform.OS] = value; + [target[Platform.OS]] = values; + ({[Platform.OS]: target} = value); + `; + + compare([inlinePlugin], code, code.replaceAll('Platform.OS', '"ios"'), { + inlinePlatform: true, + platform: 'ios', + }); + }); + test('replaces Platform.OS in the code if Platform is the right hand side of an assignment expression', () => { const code = ` function a() { diff --git a/packages/metro-transform-plugins/src/import-export-plugin.js b/packages/metro-transform-plugins/src/import-export-plugin.js index f93e04aea6..fac55bcdd3 100644 --- a/packages/metro-transform-plugins/src/import-export-plugin.js +++ b/packages/metro-transform-plugins/src/import-export-plugin.js @@ -1,5 +1,5 @@ /** - * Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions 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. @@ -9,6 +9,8 @@ * @oncall react_native */ +// Portions Copyright (c) 2015-present 650 Industries, Inc. (aka Expo), under MIT. + import type {PluginObj} from '@babel/core'; import type {NodePath} from '@babel/traverse'; import type { @@ -85,6 +87,7 @@ const exportAllTemplate = template.statements(` var REQUIRED = require(FILE); for (var KEY in REQUIRED) { + if (KEY === "default") continue; exports[KEY] = REQUIRED[KEY]; } `); @@ -167,11 +170,23 @@ export default function importExportPlugin({ path: NodePath, state: State, ): void { + const loc = path.node.loc; + const file = path.node.source; + state.exportAll.push({ - file: path.node.source.value, - loc: path.node.loc, + file: file.value, + loc, }); + withLocation( + exportAllTemplate({ + FILE: resolvePath(t.cloneNode(file), state.opts.resolve), + REQUIRED: path.scope.generateUidIdentifier(file.value), + KEY: path.scope.generateUidIdentifier('key'), + }), + loc, + ).forEach(node => state.imports.push({node})); + path.remove(); }, @@ -222,40 +237,9 @@ export default function importExportPlugin({ if (declaration) { if (isVariableDeclaration(declaration)) { - declaration.declarations.forEach(d => { - switch (d.id.type) { - case 'ObjectPattern': - { - const properties = d.id.properties; - properties.forEach(p => { - // $FlowFixMe[incompatible-use] Flow error uncovered by typing Babel more strictly - // $FlowFixMe[prop-missing] - const name = p.key.name; - // $FlowFixMe[incompatible-type] - state.exportNamed.push({local: name, remote: name, loc}); - }); - } - break; - case 'ArrayPattern': - { - const elements = d.id.elements; - elements.forEach(e => { - // $FlowFixMe[incompatible-use] Flow error uncovered by typing Babel more strictly - // $FlowFixMe[prop-missing] - const name = e.name; - // $FlowFixMe[incompatible-type] - state.exportNamed.push({local: name, remote: name, loc}); - }); - } - break; - default: - { - const name = d.id.name; - // $FlowFixMe[incompatible-type] - state.exportNamed.push({local: name, remote: name, loc}); - } - break; - } + const bindings = t.getBindingIdentifiers(declaration); + Object.keys(bindings).forEach(name => { + state.exportNamed.push({local: name, remote: name, loc}); }); } else { const id = declaration.id || path.scope.generateUidIdentifier(); @@ -274,7 +258,6 @@ export default function importExportPlugin({ const specifiers = path.node.specifiers; if (specifiers) { specifiers.forEach(s => { - const local = s.local; const remote = s.exported; if (remote.type === 'StringLiteral') { @@ -284,6 +267,31 @@ export default function importExportPlugin({ ); } + if (s.type === 'ExportNamespaceSpecifier') { + const source = nullthrows(path.node.source); + const temp = path.scope.generateUidIdentifier(remote.name); + + state.imports.push({ + node: withLocation( + importTemplate({ + IMPORT: t.cloneNode(state.importAll), + FILE: resolvePath(t.cloneNode(source), state.opts.resolve), + LOCAL: temp, + }), + loc, + ), + }); + + state.exportNamed.push({ + local: temp.name, + remote: remote.name, + loc, + }); + return; + } + + const local = s.local; + if (path.node.source) { // $FlowFixMe[incompatible-use] const temp = path.scope.generateUidIdentifier(local.name); @@ -291,8 +299,8 @@ export default function importExportPlugin({ // $FlowFixMe[incompatible-type] // $FlowFixMe[incompatible-use] if (local.name === 'default') { - path.insertBefore( - withLocation( + state.imports.push({ + node: withLocation( importTemplate({ IMPORT: t.cloneNode(state.importDefault), FILE: resolvePath( @@ -303,7 +311,7 @@ export default function importExportPlugin({ }), loc, ), - ); + }); state.exportNamed.push({ local: temp.name, @@ -311,8 +319,8 @@ export default function importExportPlugin({ loc, }); } else if (remote.name === 'default') { - path.insertBefore( - withLocation( + state.imports.push({ + node: withLocation( importNamedTemplate({ FILE: resolvePath( t.cloneNode(nullthrows(path.node.source)), @@ -323,12 +331,12 @@ export default function importExportPlugin({ }), loc, ), - ); + }); state.exportDefault.push({local: temp.name, loc}); } else { - path.insertBefore( - withLocation( + state.imports.push({ + node: withLocation( importNamedTemplate({ FILE: resolvePath( t.cloneNode(nullthrows(path.node.source)), @@ -339,7 +347,7 @@ export default function importExportPlugin({ }), loc, ), - ); + }); state.exportNamed.push({ local: temp.name, @@ -527,32 +535,13 @@ export default function importExportPlugin({ body.unshift(e.node); }); - state.exportDefault.forEach( - (e: {local: string, loc: ?SourceLocation, ...}) => { + state.exportNamed.forEach( + (e: {local: string, remote: string, loc: ?SourceLocation, ...}) => { body.push( withLocation( exportTemplate({ LOCAL: t.identifier(e.local), - REMOTE: t.identifier('default'), - }), - e.loc, - ), - ); - }, - ); - - state.exportAll.forEach( - (e: {file: string, loc: ?SourceLocation, ...}) => { - body.push( - // $FlowFixMe[incompatible-call] - ...withLocation( - exportAllTemplate({ - FILE: resolvePath( - t.stringLiteral(e.file), - state.opts.resolve, - ), - REQUIRED: path.scope.generateUidIdentifier(e.file), - KEY: path.scope.generateUidIdentifier('key'), + REMOTE: t.identifier(e.remote), }), e.loc, ), @@ -560,13 +549,13 @@ export default function importExportPlugin({ }, ); - state.exportNamed.forEach( - (e: {local: string, remote: string, loc: ?SourceLocation, ...}) => { + state.exportDefault.forEach( + (e: {local: string, loc: ?SourceLocation, ...}) => { body.push( withLocation( exportTemplate({ LOCAL: t.identifier(e.local), - REMOTE: t.identifier(e.remote), + REMOTE: t.identifier('default'), }), e.loc, ), diff --git a/packages/metro-transform-plugins/src/inline-plugin.js b/packages/metro-transform-plugins/src/inline-plugin.js index 2cc055d260..4fc5dfb49d 100644 --- a/packages/metro-transform-plugins/src/inline-plugin.js +++ b/packages/metro-transform-plugins/src/inline-plugin.js @@ -44,7 +44,6 @@ export default function inlinePlugin( options: Options, ): PluginObj { const { - isAssignmentExpression, isIdentifier, isMemberExpression, isObjectExpression, @@ -69,10 +68,46 @@ export default function inlinePlugin( return !binding || isFlowDeclared(binding); } - const isLeftHandSideOfAssignmentExpression = ( - node: Node, - parent: Node, - ): boolean => isAssignmentExpression(parent) && parent.left === node; + function isWriteTarget(path: NodePath): boolean { + let child: Node = path.node; + let parentPath = path.parentPath; + + while (parentPath != null) { + const parent = parentPath.node; + if ( + (parent.type === 'AssignmentExpression' || + parent.type === 'ForInStatement' || + parent.type === 'ForOfStatement') && + parent.left === child + ) { + return true; + } + if (parent.type === 'UpdateExpression' && parent.argument === child) { + return true; + } + if ( + parent.type === 'UnaryExpression' && + parent.operator === 'delete' && + parent.argument === child + ) { + return true; + } + + const nestedWriteTarget = + parent.type === 'ArrayPattern' || + parent.type === 'ObjectPattern' || + (parent.type === 'ObjectProperty' && parent.value === child) || + (parent.type === 'RestElement' && parent.argument === child) || + (parent.type === 'AssignmentPattern' && parent.left === child); + if (!nestedWriteTarget) { + return false; + } + + child = parent; + parentPath = parentPath.parentPath; + } + return false; + } const isProcessEnvNodeEnv = (node: MemberExpression, scope: Scope): boolean => isIdentifier(node.property, nodeEnv) && @@ -90,9 +125,9 @@ export default function inlinePlugin( key: string, fallback: () => Node, ): Node { - let value = null; - - for (const p of objectExpression.properties) { + // Object literal evaluation keeps the last definition of a duplicate key. + for (let i = objectExpression.properties.length - 1; i >= 0; i--) { + const p = objectExpression.properties[i]; if (!isObjectProperty(p) && !isObjectMethod(p)) { continue; } @@ -101,16 +136,14 @@ export default function inlinePlugin( (isStringLiteral(p.key) && p.key.value === key) ) { if (isObjectProperty(p)) { - value = p.value; - break; + return p.value; } else if (isObjectMethod(p)) { - value = t.toExpression(p); - break; + return t.toExpression(p); } } } - return value ?? fallback(); + return fallback(); } function hasStaticProperties(objectExpression: ObjectExpression): boolean { @@ -138,7 +171,7 @@ export default function inlinePlugin( const scope = path.scope; const opts = state.opts; - if (!isLeftHandSideOfAssignmentExpression(node, path.parent)) { + if (!isWriteTarget(path)) { if ( opts.inlinePlatform && isPlatformNode(node, scope, !!opts.isWrapped) diff --git a/packages/metro-transform-plugins/types/import-export-plugin.d.ts b/packages/metro-transform-plugins/types/import-export-plugin.d.ts index 869c88655c..4506bdf8b7 100644 --- a/packages/metro-transform-plugins/types/import-export-plugin.d.ts +++ b/packages/metro-transform-plugins/types/import-export-plugin.d.ts @@ -1,12 +1,12 @@ /** - * Copyright (c) Meta Platforms, Inc. and affiliates. + * Portions 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. * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<587cbe7792cfd21ce4abb4bfb5bac2f8>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-transform-plugins/src/import-export-plugin.js diff --git a/packages/metro-transform-worker/package.json b/packages/metro-transform-worker/package.json index 44c06bac2f..df2a2532c7 100644 --- a/packages/metro-transform-worker/package.json +++ b/packages/metro-transform-worker/package.json @@ -1,6 +1,6 @@ { "name": "metro-transform-worker", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 Transform worker for Metro.", "main": "src/index.js", "exports": { @@ -24,18 +24,19 @@ "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", - "metro": "0.84.6", - "metro-babel-transformer": "0.84.6", - "metro-cache": "0.84.6", - "metro-cache-key": "0.84.6", - "metro-minify-terser": "0.84.6", - "metro-source-map": "0.84.6", - "metro-transform-plugins": "0.84.6", + "metro": "0.84.7", + "metro-babel-transformer": "0.84.7", + "metro-cache": "0.84.7", + "metro-cache-key": "0.84.7", + "metro-minify-terser": "0.84.7", + "metro-source-map": "0.84.7", + "metro-transform-plugins": "0.84.7", "nullthrows": "^1.1.1" }, "devDependencies": { "@react-native/metro-babel-transformer": "0.78.0", - "metro-memory-fs": "*" + "metro-memory-fs": "*", + "metro-resolver": "0.84.7" }, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" diff --git a/packages/metro-transform-worker/src/__tests__/index-test.js b/packages/metro-transform-worker/src/__tests__/index-test.js index 81aba4c9f0..7f98e5a6ba 100644 --- a/packages/metro-transform-worker/src/__tests__/index-test.js +++ b/packages/metro-transform-worker/src/__tests__/index-test.js @@ -136,6 +136,27 @@ test('transforms a simple module', async () => { expect(result.dependencies).toEqual([]); }); +test('uses the indexed watch folder path for asset URLs', async () => { + fs.mkdirSync('/root/external', {recursive: true}); + fs.writeFileSync('/root/external/test.mp4', 'asset data'); + + const result = await Transformer.transform( + baseConfig, + '/root', + 'external/test.mp4', + Buffer.from('asset data'), + { + ...baseTransformOptions, + type: 'asset', + }, + '[metro-watchFolders]/1/test.mp4', + ); + + expect(result.output[0].data.code).toContain( + '"httpServerLocation": "/assets/[metro-watchFolders]/1"', + ); +}); + test('transforms a module with dependencies', async () => { const contents = [ '"use strict";', diff --git a/packages/metro-transform-worker/src/index.js b/packages/metro-transform-worker/src/index.js index 84a0b4b143..4076554fd2 100644 --- a/packages/metro-transform-worker/src/index.js +++ b/packages/metro-transform-worker/src/index.js @@ -130,11 +130,12 @@ export type JsTransformOptions = Readonly<{ unstable_transformProfile: TransformProfile, }>; -opaque type Path = string; +opaque type AbsolutePath = string; +opaque type ProjectRelativePath = string; type BaseFile = Readonly<{ code: string, - filename: Path, + filename: ProjectRelativePath, inputFileSize: number, }>; @@ -159,8 +160,9 @@ type JSONFile = { }; type TransformationContext = Readonly<{ + assetUrlPath: ?string, config: JsTransformerConfig, - projectRoot: Path, + projectRoot: AbsolutePath, options: JsTransformOptions, }>; @@ -520,6 +522,7 @@ async function transformAsset( getBabelTransformArgs(file, context), assetRegistryPath, assetPlugins, + context.assetUrlPath ?? undefined, ); const jsFile = { @@ -542,7 +545,9 @@ async function transformJSWithBabel( ): Promise { const {babelTransformerPath} = context.config; // $FlowFixMe[unsupported-syntax] dynamic require - const transformer: BabelTransformer = require(babelTransformerPath); + const mod = require(babelTransformerPath); + const transformer: BabelTransformer = + mod.__esModule === true && 'default' in mod ? mod.default : mod; const transformResult = await transformer.transform( getBabelTransformArgs(file, context, [ @@ -628,13 +633,13 @@ async function transformJSON( } function getBabelTransformArgs( - file: Readonly<{filename: Path, code: string, ...}>, + file: Readonly<{filename: ProjectRelativePath, code: string, ...}>, {options, config, projectRoot}: TransformationContext, plugins?: Plugins = [], ): BabelTransformerArgs { const {inlineRequires: _, ...babelTransformerOptions} = options; return { - filename: file.filename, + filename: file.filename, // System-separated, project-root-relative options: { ...babelTransformerOptions, enableBabelRCLookup: config.enableBabelRCLookup, @@ -652,11 +657,17 @@ function getBabelTransformArgs( export const transform = async ( config: JsTransformerConfig, projectRoot: string, - filename: string, + projectRelativePath: string, data: Buffer, options: JsTransformOptions, + // The asset's path relative to the asset server root (`publicPath`), as + // clients will request it. Computed by Metro for assets only, and passed + // separately from the options so that it is not spread into + // BabelTransformerArgs. + assetUrlPath?: string, ): Promise => { const context: TransformationContext = { + assetUrlPath, config, options, projectRoot, @@ -685,10 +696,10 @@ export const transform = async ( } } - if (filename.endsWith('.json')) { + if (projectRelativePath.endsWith('.json')) { const jsonFile: JSONFile = { code: sourceCode, - filename, + filename: projectRelativePath, inputFileSize: data.length, type: options.type, }; @@ -699,7 +710,7 @@ export const transform = async ( if (options.type === 'asset') { const file: AssetFile = { code: sourceCode, - filename, + filename: projectRelativePath, inputFileSize: data.length, type: options.type, }; @@ -709,7 +720,7 @@ export const transform = async ( const file: JSFile = { code: sourceCode, - filename, + filename: projectRelativePath, functionMap: null, inputFileSize: data.length, type: options.type === 'script' ? 'js/script' : 'js/module', diff --git a/packages/metro-transform-worker/src/utils/assetTransformer.js b/packages/metro-transform-worker/src/utils/assetTransformer.js index 008adc90da..a314d59ad2 100644 --- a/packages/metro-transform-worker/src/utils/assetTransformer.js +++ b/packages/metro-transform-worker/src/utils/assetTransformer.js @@ -20,6 +20,7 @@ export async function transform( {filename, options, src}: BabelTransformerArgs, assetRegistryPath: string, assetDataPlugins: ReadonlyArray, + urlPath?: string, ): Promise<{ast: File, ...}> { options = options || { platform: '', @@ -32,7 +33,7 @@ export async function transform( const data = await getAssetData( absolutePath, - filename, + urlPath ?? filename, assetDataPlugins, options.platform, options.publicPath, diff --git a/packages/metro-transform-worker/src/utils/getMinifier.js b/packages/metro-transform-worker/src/utils/getMinifier.js index be0cdf44c3..1d2b2de6f9 100644 --- a/packages/metro-transform-worker/src/utils/getMinifier.js +++ b/packages/metro-transform-worker/src/utils/getMinifier.js @@ -17,7 +17,8 @@ export default function getMinifier(minifierPath: string): Minifier { // any entry point that accepts them... try { // $FlowFixMe[unsupported-syntax] TODO t0 cannot do require with literal - return require(minifierPath); + const mod = require(minifierPath); + return mod.__esModule === true && 'default' in mod ? mod.default : mod; } catch (e) { throw new Error( 'A problem occurred while trying to fetch the minifier. Path: "' + diff --git a/packages/metro-transform-worker/types/index.d.ts b/packages/metro-transform-worker/types/index.d.ts index 5878494cfe..8497a909a5 100644 --- a/packages/metro-transform-worker/types/index.d.ts +++ b/packages/metro-transform-worker/types/index.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<86e39ddf853e99cb0e4817de3f817a2c>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-transform-worker/src/index.js @@ -102,9 +102,10 @@ type TransformResponse = Readonly<{ export declare const transform: ( config: JsTransformerConfig, projectRoot: string, - filename: string, + projectRelativePath: string, data: Buffer, options: JsTransformOptions, + assetUrlPath?: string, ) => Promise; export declare type transform = typeof transform; export declare const getCacheKey: ( diff --git a/packages/metro-transform-worker/types/utils/assetTransformer.d.ts b/packages/metro-transform-worker/types/utils/assetTransformer.d.ts index 6f6e5fd4b0..e9ab8bf642 100644 --- a/packages/metro-transform-worker/types/utils/assetTransformer.d.ts +++ b/packages/metro-transform-worker/types/utils/assetTransformer.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro-transform-worker/src/utils/assetTransformer.js @@ -22,4 +22,5 @@ export declare function transform( $$PARAM_0$$: BabelTransformerArgs, assetRegistryPath: string, assetDataPlugins: ReadonlyArray, + urlPath?: string, ): Promise<{ast: File}>; diff --git a/packages/metro/package.json b/packages/metro/package.json index 5b19820705..35af8d95db 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -1,6 +1,6 @@ { "name": "metro", - "version": "0.84.6", + "version": "0.84.7", "description": "🚇 The JavaScript bundler for React Native.", "main": "src/index.js", "bin": "src/cli.js", @@ -27,7 +27,6 @@ "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", - "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", @@ -38,18 +37,18 @@ "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", - "metro-babel-transformer": "0.84.6", - "metro-cache": "0.84.6", - "metro-cache-key": "0.84.6", - "metro-config": "0.84.6", - "metro-core": "0.84.6", - "metro-file-map": "0.84.6", - "metro-resolver": "0.84.6", - "metro-runtime": "0.84.6", - "metro-source-map": "0.84.6", - "metro-symbolicate": "0.84.6", - "metro-transform-plugins": "0.84.6", - "metro-transform-worker": "0.84.6", + "metro-babel-transformer": "0.84.7", + "metro-cache": "0.84.7", + "metro-cache-key": "0.84.7", + "metro-config": "0.84.7", + "metro-core": "0.84.7", + "metro-file-map": "0.84.7", + "metro-resolver": "0.84.7", + "metro-runtime": "0.84.7", + "metro-source-map": "0.84.7", + "metro-symbolicate": "0.84.7", + "metro-transform-plugins": "0.84.7", + "metro-transform-worker": "0.84.7", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", @@ -70,7 +69,7 @@ "dedent": "^0.7.0", "jest-snapshot": "^29.7.0", "jest-snapshot-serializer-raw": "^1.2.0", - "metro-babel-register": "0.84.6", + "metro-babel-register": "0.84.7", "metro-memory-fs": "*", "mock-req": "^0.2.0", "mock-res": "^0.6.0", diff --git a/packages/metro/src/Assets.js b/packages/metro/src/Assets.js index 83c2460535..a53932b978 100644 --- a/packages/metro/src/Assets.js +++ b/packages/metro/src/Assets.js @@ -201,20 +201,26 @@ async function getAbsoluteAssetInfo( export async function getAssetData( assetPath: string, - localPath: string, + urlPath: string, assetDataPlugins: ReadonlyArray, platform: ?string, publicPath: string, ): Promise { - // If the path of the asset is outside of the projectRoot, we don't want to - // use `path.join` since this will generate an incorrect URL path. In that - // case we just concatenate the publicPath with the relative path. - let assetUrlPath = localPath.startsWith('..') - ? publicPath.replace(/\/$/, '') + '/' + path.dirname(localPath) - : path.join(publicPath, path.dirname(localPath)); + // urlPath is the asset's path relative to publicPath. Only its directory + // reaches the client, which composes the file name from the scale and + // platform it needs. + // + // Metro itself never passes a path starting with `..` here, since assets + // outside projectRoot are addressed by watch folder index. Custom + // transformers that call this with a project-relative path still can, and + // `path.join` would collapse the `..` against publicPath, so concatenate + // instead to preserve it. + let httpServerLocation = urlPath.startsWith('..') + ? publicPath.replace(/\/$/, '') + '/' + path.dirname(urlPath) + : path.join(publicPath, path.dirname(urlPath)); // On Windows, change backslashes to slashes to get proper URL path from file path. - assetUrlPath = normalizePathSeparatorsToPosix(assetUrlPath); + httpServerLocation = normalizePathSeparatorsToPosix(httpServerLocation); const {assetInfo, firstFileContent} = await getAbsoluteAssetInfo( assetPath, @@ -228,7 +234,7 @@ export async function getAssetData( const assetData = { __packager_asset: true, fileSystemLocation: path.dirname(assetPath), - httpServerLocation: assetUrlPath, + httpServerLocation, width: dimensions ? dimensions.width / scale : undefined, height: dimensions ? dimensions.height / scale : undefined, scales: assetInfo.scales, @@ -240,6 +246,56 @@ export async function getAssetData( return await applyAssetDataPlugins(assetDataPlugins, assetData); } +/** + * Returns the path used to identify an asset in its development server URL, + * relative to publicPath. Assets outside projectRoot use an indexed watch + * folder prefix so that the URL unambiguously identifies their configured + * root. + * + * Roots are resolved as Server does when it decodes these paths, so that the + * index here and the index it reads back refer to the same directory. The + * first root to contain the asset wins, in index order, so that appending a + * watch folder never changes the URL of an asset under an existing one. + */ +export function getAssetUrlPath( + assetPath: string, + projectRoot: string, + watchFolders: ReadonlyArray, +): string { + const projectRelativePath = path.relative( + path.resolve(projectRoot), + assetPath, + ); + if (isPathInsideRoot(projectRelativePath)) { + return normalizePathSeparatorsToPosix(projectRelativePath); + } + + for (let i = 0; i < watchFolders.length; i++) { + const watchFolderRelativePath = path.relative( + path.resolve(watchFolders[i]), + assetPath, + ); + if (isPathInsideRoot(watchFolderRelativePath)) { + return normalizePathSeparatorsToPosix( + path.join('[metro-watchFolders]', String(i), watchFolderRelativePath), + ); + } + } + + throw new Error( + `Asset '${assetPath}' is not within projectRoot '${projectRoot}' or any ` + + `watch folder (${watchFolders.join(', ')})`, + ); +} + +function isPathInsideRoot(relativePath: string): boolean { + return ( + relativePath !== '..' && + !relativePath.startsWith('..' + path.sep) && + !path.isAbsolute(relativePath) + ); +} + async function applyAssetDataPlugins( assetDataPlugins: ReadonlyArray, assetData: AssetData, @@ -250,7 +306,10 @@ async function applyAssetDataPlugins( const [currentAssetPlugin, ...remainingAssetPlugins] = assetDataPlugins; // $FlowFixMe[unsupported-syntax]: impossible to type a dynamic require. - const assetPluginFunction: AssetDataPlugin = require(currentAssetPlugin); + const mod = require(currentAssetPlugin); + const assetPluginFunction: AssetDataPlugin = + mod.__esModule === true && 'default' in mod ? mod.default : mod; + const resultAssetData = await assetPluginFunction(assetData); return await applyAssetDataPlugins(remainingAssetPlugins, resultAssetData); } diff --git a/packages/metro/src/Bundler.js b/packages/metro/src/Bundler.js index 0178235d6d..e00cec0ebd 100644 --- a/packages/metro/src/Bundler.js +++ b/packages/metro/src/Bundler.js @@ -16,6 +16,7 @@ import type {ConfigT} from 'metro-config'; import Transformer from './DeltaBundler/Transformer'; import DependencyGraph from './node-haste/DependencyGraph'; +import nullthrows from 'nullthrows'; export type BundlerOptions = Readonly<{ hasReducedPerformance?: boolean, @@ -25,7 +26,7 @@ export type BundlerOptions = Readonly<{ export default class Bundler { _depGraph: DependencyGraph; _initializedPromise: Promise; - _transformer: Transformer; + _transformer: ?Transformer; constructor(config: ConfigT, options?: BundlerOptions) { this._depGraph = new DependencyGraph(config, options); @@ -46,7 +47,12 @@ export default class Bundler { type: 'transformer_load_failed', error, }); + throw error; }); + + // Observe initialization failures immediately so callers can await the + // original promise later without triggering an unhandled rejection. + this._initializedPromise.catch(() => {}); } getWatcher(): EventEmitter { @@ -54,10 +60,15 @@ export default class Bundler { } async end(): Promise { - await this.ready(); + // Initialization errors are surfaced by ready() and the reporter, and + // shouldn't prevent teardown of whatever was started. + await this._initializedPromise.catch(() => {}); - await this._transformer.end(); - await this._depGraph.end(); + try { + await this._transformer?.end(); + } finally { + await this._depGraph.end(); + } } async getDependencyGraph(): Promise { @@ -76,7 +87,7 @@ export default class Bundler { // TODO: Remove this ugly hack! await this.ready(); - return this._transformer.transformFile( + return nullthrows(this._transformer).transformFile( filePath, transformOptions, fileBuffer, diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js index c13c148b66..08682ce876 100644 --- a/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/getAssets-test.js @@ -10,7 +10,7 @@ jest.mock('../../../Assets'); -import {getAssetData} from '../../../Assets'; +import {getAssetData, getAssetUrlPath} from '../../../Assets'; import getAssets from '../getAssets'; beforeEach(() => { @@ -18,6 +18,9 @@ beforeEach(() => { path, localPath, })); + getAssetUrlPath.mockImplementation( + jest.requireActual('../../../Assets').getAssetUrlPath, + ); }); test('should return the bundle assets', async () => { @@ -82,16 +85,32 @@ test('should return the bundle assets', async () => { ], }, ], + [ + '/external/6.png', + { + path: '/external/6.png', + output: [ + { + type: 'js/module/asset', + data: {code: '//', lineCount: 1, map: [], functionMap: null}, + }, + ], + }, + ], ]); expect( await getAssets(dependencies, { projectRoot: '/tmp', - watchFolders: ['/tmp'], + watchFolders: ['/tmp', '/external'], processModuleFilter: () => true, }), ).toEqual([ {path: '/tmp/3.png', localPath: '3.png'}, {path: '/tmp/5.mov', localPath: '5.mov'}, + { + path: '/external/6.png', + localPath: '[metro-watchFolders]/1/6.png', + }, ]); }); diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/getRamBundleInfo-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/getRamBundleInfo-test.js index c988afebe4..9e9678dbfd 100644 --- a/packages/metro/src/DeltaBundler/Serializers/__tests__/getRamBundleInfo-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/getRamBundleInfo-test.js @@ -76,74 +76,78 @@ const pre = [createModule('pre', [], 'js/script')[1]]; const getRunModuleStatement = (moduleId: string | number) => `require(${JSON.stringify(moduleId)});`; +const baseOptions = { + asyncRequireModulePath: '', + // $FlowFixMe[incompatible-type] createModuleId assumes numeric IDs - is this too strict? + createModuleId: (path: string): number => path, + dev: true, + excludeSource: false, + getRunModuleStatement, + getTransformOptions: async () => ({ + preloadedModules: {}, + ramGroups: [], + }), + globalPrefix: '', + includeAsyncPaths: false, + inlineSourceMap: false, + modulesOnly: false, + platform: null, + processModuleFilter: (module: Module<>) => true, + projectRoot: '/root', + runBeforeMainModule: [], + runModule: true, + serverRoot: '/root', + shouldAddToIgnoreList: () => false, + sourceMapUrl: 'http://localhost/bundle.map', + sourceUrl: null, + getSourceUrl: null, +}; + test('should return the RAM bundle info', async () => { expect( await getRamBundleInfo( '/root/entry.js', pre, {...graph, entryPoints: new Set(['/root/entry.js'])}, - { - asyncRequireModulePath: '', - // $FlowFixMe[incompatible-type] createModuleId assumes numeric IDs - is this too strict? - createModuleId: path => path, - dev: true, - excludeSource: false, - getRunModuleStatement, - getTransformOptions: async () => ({ - preloadedModules: {}, - ramGroups: [], - }), - globalPrefix: '', - includeAsyncPaths: false, - inlineSourceMap: false, - modulesOnly: false, - platform: null, - processModuleFilter: module => true, - projectRoot: '/root', - runBeforeMainModule: [], - runModule: true, - serverRoot: '/root', - shouldAddToIgnoreList: () => false, - sourceMapUrl: 'http://localhost/bundle.map', - sourceUrl: null, - getSourceUrl: null, - }, + baseOptions, ), ).toMatchSnapshot(); }); +test('passes a working transitive-dependency lookup to getTransformOptions', async () => { + let resolvedDeps: ?Array; + await getRamBundleInfo( + '/root/entry.js', + pre, + {...graph, entryPoints: new Set(['/root/entry.js'])}, + { + ...baseOptions, + getTransformOptions: async (entryPoints, opts, getDependenciesOf) => { + resolvedDeps = await getDependenciesOf('/root/foo.js'); + return {preloadedModules: {}, ramGroups: []}; + }, + }, + ); + // foo depends on bar, baz, qux — the callback must return those, not [undefined]. + if (resolvedDeps == null) { + throw new Error( + 'getTransformOptions was not called with a dependency lookup', + ); + } + expect([...resolvedDeps].sort()).toEqual([ + '/root/bar.js', + '/root/baz.js', + '/root/qux.js', + ]); +}); + test('emits x_google_ignoreList based on shouldAddToIgnoreList', async () => { expect( await getRamBundleInfo( '/root/entry.js', pre, {...graph, entryPoints: new Set(['/root/entry.js'])}, - { - asyncRequireModulePath: '', - // $FlowFixMe[incompatible-type] createModuleId assumes numeric IDs - is this too strict? - createModuleId: path => path, - dev: true, - excludeSource: false, - getRunModuleStatement, - getTransformOptions: async () => ({ - preloadedModules: {}, - ramGroups: [], - }), - globalPrefix: '', - includeAsyncPaths: false, - inlineSourceMap: false, - modulesOnly: false, - platform: null, - processModuleFilter: module => true, - projectRoot: '/root', - runBeforeMainModule: [], - runModule: true, - serverRoot: '/root', - shouldAddToIgnoreList: () => true, - sourceMapUrl: 'http://localhost/bundle.map', - sourceUrl: null, - getSourceUrl: null, - }, + {...baseOptions, shouldAddToIgnoreList: () => true}, ), ).toMatchSnapshot(); }); @@ -159,29 +163,11 @@ test('should use the preloadedModules and ramGroup configs to build a RAM bundle pre, {...graph, entryPoints: new Set(['/root/entry.js'])}, { - asyncRequireModulePath: '', - // $FlowFixMe[incompatible-type] createModuleId assumes numeric IDs - is this too strict? - createModuleId: path => path, - dev: true, - excludeSource: false, - getRunModuleStatement, + ...baseOptions, /* $FlowFixMe[incompatible-type] Natural Inference rollout. See * https://fburl.com/workplace/6291gfvu */ getTransformOptions, - globalPrefix: '', - includeAsyncPaths: false, inlineSourceMap: null, - modulesOnly: false, - platform: null, - processModuleFilter: module => true, - projectRoot: '/root', - runBeforeMainModule: [], - runModule: true, - serverRoot: '/root', - shouldAddToIgnoreList: () => false, - sourceMapUrl: 'http://localhost/bundle.map', - sourceUrl: null, - getSourceUrl: null, }, ); diff --git a/packages/metro/src/DeltaBundler/Serializers/getAssets.js b/packages/metro/src/DeltaBundler/Serializers/getAssets.js index 2c42caef1c..8d877cbfac 100644 --- a/packages/metro/src/DeltaBundler/Serializers/getAssets.js +++ b/packages/metro/src/DeltaBundler/Serializers/getAssets.js @@ -12,7 +12,7 @@ import type {AssetData} from '../../Assets'; import type {Module, ReadOnlyDependencies} from '../types'; -import {getAssetData} from '../../Assets'; +import {getAssetData, getAssetUrlPath} from '../../Assets'; import {getJsOutput, isJsModule} from './helpers/js'; import path from 'path'; @@ -22,6 +22,7 @@ type Options = { platform: ?string, projectRoot: string, publicPath: string, + watchFolders: ReadonlyArray, }; export default async function getAssets( @@ -41,7 +42,11 @@ export default async function getAssets( promises.push( getAssetData( module.path, - path.relative(options.projectRoot, module.path), + getAssetUrlPath( + module.path, + options.projectRoot, + options.watchFolders, + ), options.assetPlugins, options.platform, options.publicPath, diff --git a/packages/metro/src/DeltaBundler/Serializers/getRamBundleInfo.js b/packages/metro/src/DeltaBundler/Serializers/getRamBundleInfo.js index 9b8047ec08..9d1d613b2a 100644 --- a/packages/metro/src/DeltaBundler/Serializers/getRamBundleInfo.js +++ b/packages/metro/src/DeltaBundler/Serializers/getRamBundleInfo.js @@ -157,10 +157,7 @@ async function _getRamOptions( const {preloadedModules, ramGroups} = await getTransformOptions( [entryFile], {dev: options.dev, hot: true, platform: options.platform}, - /* $FlowFixMe[incompatible-type](>=0.99.0 site=react_native_fb) This comment suppresses an - * error found when Flow v0.99 was deployed. To see the error, delete this - * comment and run Flow. */ - async (x: string) => Array.from(getDependencies), + async (x: string) => Array.from(getDependencies(x)), ); return { diff --git a/packages/metro/src/DeltaBundler/Serializers/helpers/js.js b/packages/metro/src/DeltaBundler/Serializers/helpers/js.js index 75cde3fc4e..1d7aee4636 100644 --- a/packages/metro/src/DeltaBundler/Serializers/helpers/js.js +++ b/packages/metro/src/DeltaBundler/Serializers/helpers/js.js @@ -80,11 +80,13 @@ export function getModuleParams( ); paths[id] = '/' + - path.join( - // TODO: This is not the proper Metro URL encoding of a file path - path.dirname(bundlePath), - // Strip the file extension - path.basename(bundlePath, path.extname(bundlePath)), + normalizePathSeparatorsToPosix( + path.join( + // TODO: This is not the proper Metro URL encoding of a file path + path.dirname(bundlePath), + // Strip the file extension + path.basename(bundlePath, path.extname(bundlePath)), + ), ) + '.bundle?' + searchParams.toString(); diff --git a/packages/metro/src/DeltaBundler/Transformer.js b/packages/metro/src/DeltaBundler/Transformer.js index 28c4a502da..93845b2239 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'; +import {getAssetUrlPath} from '../Assets'; import {normalizePathSeparatorsToPosix} from '../lib/pathUtils'; import getTransformCacheKey from './getTransformCacheKey'; import WorkerFarm from './WorkerFarm'; @@ -61,8 +62,6 @@ export default class Transformer { transformerConfig, }; - this._workerFarm = new WorkerFarm(config, transformerOptions); - const globalCacheKey = this._cache.isDisabled ? '' : getTransformCacheKey({ @@ -74,6 +73,9 @@ export default class Transformer { const baseHashBuffer = stableHash([globalCacheKey]); this._baseHash = baseHashBuffer.toString('binary'); debug('Base hash: %s', baseHashBuffer.toString('hex')); + + // Start workers last - nothing can end them if the constructor throws. + this._workerFarm = new WorkerFarm(config, transformerOptions); } async transformFile( @@ -108,7 +110,21 @@ export default class Transformer { } } - const localPath = path.relative(this._config.projectRoot, filePath); + const projectRelativePath = path.relative( + this._config.projectRoot, + filePath, + ); + // Assets are the only modules whose output depends on watchFolders, via + // the URL path baked into them, so that dependency enters the cache key + // per asset here and not in the base hash. + const assetUrlPath = + type === 'asset' + ? getAssetUrlPath( + filePath, + this._config.projectRoot, + this._config.watchFolders, + ) + : null; const partialKey = stableHash([ // This is the hash related to the global Bundler config. @@ -117,7 +133,8 @@ export default class Transformer { // Project-relative, posix-separated path for portability. Necessary in // addition to content hash because transformers receive path as an // input, and may apply e.g. extension-based logic. - normalizePathSeparatorsToPosix(localPath), + normalizePathSeparatorsToPosix(projectRelativePath), + assetUrlPath, customTransformOptions, dev, experimentalImportSupport, @@ -166,9 +183,10 @@ export default class Transformer { }> = result ? {result, sha1} : await this._workerFarm.transform( - localPath, + projectRelativePath, transformerOptions, content, + assetUrlPath ?? undefined, ); // Only re-compute the full key if the SHA-1 changed. This is because diff --git a/packages/metro/src/DeltaBundler/Worker.flow.js b/packages/metro/src/DeltaBundler/Worker.flow.js index 9ee67f53f6..b00be2cc60 100644 --- a/packages/metro/src/DeltaBundler/Worker.flow.js +++ b/packages/metro/src/DeltaBundler/Worker.flow.js @@ -30,6 +30,7 @@ type TransformerInterface = { string, Buffer, JsTransformOptions, + ?string, ): Promise>, }; @@ -72,6 +73,7 @@ export const transform = ( projectRoot: string, transformerConfig: TransformerConfig, fileBuffer?: Buffer, + assetUrlPath?: string, ): Promise => { let data; @@ -87,6 +89,7 @@ export const transform = ( transformOptions, projectRoot, transformerConfig, + assetUrlPath, ); }; @@ -95,11 +98,12 @@ export type Worker = { }; async function transformFile( - filename: string, + projectRelativePath: string, data: Buffer, transformOptions: JsTransformOptions, projectRoot: string, transformerConfig: TransformerConfig, + assetUrlPath?: string, ): Promise { // eslint-disable-next-line no-useless-call const Transformer: TransformerInterface = require.call( @@ -110,7 +114,7 @@ async function transformFile( const transformFileStartLogEntry: LogEntry = { action_name: 'Transforming file', action_phase: 'start', - file_name: filename, + file_name: projectRelativePath, log_entry_label: 'Transforming file', start_timestamp: process.hrtime(), }; @@ -120,9 +124,10 @@ async function transformFile( const result = await Transformer.transform( transformerConfig.transformerConfig, projectRoot, - filename, + projectRelativePath, data, transformOptions, + assetUrlPath, ); // The babel cache caches scopes and pathes for already traversed AST nodes. @@ -135,7 +140,7 @@ async function transformFile( const transformFileEndLogEntry = getEndLogEntry( transformFileStartLogEntry, - filename, + projectRelativePath, ); return { diff --git a/packages/metro/src/DeltaBundler/WorkerFarm.js b/packages/metro/src/DeltaBundler/WorkerFarm.js index c0502139ab..5e211b9c15 100644 --- a/packages/metro/src/DeltaBundler/WorkerFarm.js +++ b/packages/metro/src/DeltaBundler/WorkerFarm.js @@ -76,6 +76,7 @@ export default class WorkerFarm { filename: string, options: TransformOptions, fileBuffer?: Buffer, + assetUrlPath?: string, ): Promise { try { const data = await this._worker.transform( @@ -84,6 +85,7 @@ export default class WorkerFarm { this._config.projectRoot, this._transformerConfig, fileBuffer, + assetUrlPath, ); Logger.log(data.transformFileStartLogEntry); diff --git a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js index 1a59581ac8..8fdae5e35f 100644 --- a/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/Transformer-test.js @@ -23,6 +23,7 @@ const Transformer = require('../Transformer').default; const fs = require('fs'); const {getDefaultValues} = require('metro-config').getDefaultConfig; const {mergeConfig} = require('metro-config/private/loadConfig'); +const path = require('node:path'); describe('Transformer', function () { let watchFolders; @@ -214,4 +215,57 @@ describe('Transformer', function () { expect(require('../getTransformCacheKey')).not.toBeCalled(); }); + + test('passes an indexed watch folder URL path to asset transforms', async () => { + const workerTransform = + require('../WorkerFarm').default.prototype.transform; + workerTransform.mockClear(); + workerTransform.mockReturnValue({ + sha1: 'abcdefabcdefabcdefabcdefabcdefabcdefabcd', + result: {}, + }); + fs.mkdirSync('/external', {recursive: true}); + + const transformerInstance = new Transformer( + { + ...commonOptions, + cacheStores: [], + watchFolders: ['/root', '/external'], + }, + {getOrComputeSha1}, + ); + + await transformerInstance.transformFile('/external/imgs/a.png', { + type: 'asset', + }); + + expect(workerTransform).toHaveBeenCalledWith( + path.join('..', 'external', 'imgs', 'a.png'), + {type: 'asset'}, + undefined, + '[metro-watchFolders]/1/imgs/a.png', + ); + }); + + test('does not start workers if the transformer cache key throws', () => { + const error = new Error("Cannot find module 'babel-preset-expo'"); + require('../getTransformCacheKey').mockImplementationOnce(() => { + throw error; + }); + const WorkerFarm = require('../WorkerFarm').default; + WorkerFarm.mockClear(); + + expect( + () => + new Transformer( + { + ...commonOptions, + cacheStores: [{get: jest.fn(), set: jest.fn()}], + watchFolders, + }, + {getOrComputeSha1}, + ), + ).toThrow(error); + expect(WorkerFarm).not.toBeCalled(); + }); }); diff --git a/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js b/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js index c35c84d576..2511dd4161 100644 --- a/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js +++ b/packages/metro/src/DeltaBundler/__tests__/WorkerFarm-test.js @@ -76,6 +76,30 @@ describe('Worker Farm', function () { config.projectRoot, transformerConfig, undefined, + undefined, + ); + }); + + test('passes the asset URL path through to the worker', async () => { + const transformerConfig = { + transformerPath: config.transformerPath, + transformerConfig: config.transformer, + }; + + await new WorkerFarm(config, transformerConfig).transform( + fileName, + {type: 'asset'}, + undefined, + '[metro-watchFolders]/1/imgs/a.png', + ); + + expect(api.transform).toBeCalledWith( + fileName, + {type: 'asset'}, + config.projectRoot, + transformerConfig, + undefined, + '[metro-watchFolders]/1/imgs/a.png', ); }); @@ -97,6 +121,7 @@ describe('Worker Farm', function () { '/foo', transformerConfig, undefined, + undefined, ); await farm.kill(); @@ -113,6 +138,7 @@ describe('Worker Farm', function () { '/bar', transformerConfig, undefined, + undefined, ); }); diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 6aa06a2dc1..671e50855a 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -441,8 +441,9 @@ export default class Server { processModuleFilter: this._config.serializer.processModuleFilter, assetPlugins: this._config.transformer.assetPlugins, platform, - projectRoot: this._getServerRootDir(), + projectRoot: this._config.projectRoot, publicPath: this._config.transformer.publicPath, + watchFolders: this._config.watchFolders, }); } @@ -563,9 +564,12 @@ export default class Server { try { const depGraph = await this._bundler.getBundler().getDependencyGraph(); + const resolvedAssetPath = this._resolveWatchFolderPrefix( + './' + assetPath, + ); const data = await getAsset( - assetPath, - this._config.projectRoot, + resolvedAssetPath?.filePath ?? assetPath, + resolvedAssetPath?.rootDir ?? this._config.projectRoot, this._config.watchFolders, urlObj.searchParams.get('platform'), this._config.resolver.assetExts, @@ -579,7 +583,7 @@ export default class Server { if (process.env.REACT_NATIVE_ENABLE_ASSET_CACHING === true) { res.setHeader('Cache-Control', 'max-age=31536000'); } - res.setHeader('Content-Type', mime.lookup(path.basename(assetPath))); + res.setHeader('Content-Type', mime.contentType(path.basename(assetPath))); res.end(this._rangeRequestMiddleware(req, res, data, assetPath)); process.nextTick(() => { log(createActionEndEntry(processingAssetRequestLogEntry)); @@ -746,7 +750,7 @@ export default class Server { res.end(); return; } - const mimeType = mime.lookup(path.basename(relativeFilePathname)); + const mimeType = mime.contentType(path.basename(relativeFilePathname)); res.setHeader('Content-Type', mimeType); const stream = fs.createReadStream(filePath); stream.pipe(res); @@ -1353,13 +1357,10 @@ export default class Server { {onProgress, shallow: false, lazy: false}, ); - return await getAssets(dependencies, { - processModuleFilter: this._config.serializer.processModuleFilter, - assetPlugins: this._config.transformer.assetPlugins, - platform: transformOptions.platform, - publicPath: this._config.transformer.publicPath, - projectRoot: this._config.projectRoot, - }); + return await this._getAssetsFromDependencies( + dependencies, + transformOptions.platform, + ); }, finish({mres, result}) { mres.setHeader('Content-Type', 'application/json'); diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index 47d7e6f954..c67340ef29 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -30,6 +30,9 @@ import MockResponse from 'mock-res'; const { getDefaultConfig: {getDefaultValues}, } = require('metro-config'); +const { + posixToSystemPath: p, +} = require('metro-resolver/private/__tests__/utils'); const path = require('path'); jest @@ -63,6 +66,7 @@ describe('processRequest', () => { let getTransformFn; let getResolveDependencyFn; let getAsset; + let getAssetsSerializer; beforeEach(() => { jest.resetModules(); @@ -76,6 +80,7 @@ describe('processRequest', () => { getTransformFn = jest.fn(); getResolveDependencyFn = jest.fn(); getAsset = jest.fn(); + getAssetsSerializer = jest.fn().mockResolvedValue([]); let i = 0; jest.doMock('crypto', () => ({ @@ -111,6 +116,11 @@ describe('processRequest', () => { .mockImplementation(buildGraph); jest.spyOn(DeltaBundler.prototype, 'getDelta').mockImplementation(getDelta); + jest.doMock('../../DeltaBundler/Serializers/getAssets', () => ({ + __esModule: true, + default: getAssetsSerializer, + })); + Server = require('../../Server').default; }); @@ -312,6 +322,7 @@ describe('processRequest', () => { load: jest.fn(() => Promise.resolve()), getWatcher: jest.fn(() => ({})), doesFileExist: jest.fn().mockReturnValue(true), + getOrComputeSha1: jest.fn(() => Promise.resolve({sha1: 'abcdef'})), }), ); @@ -844,6 +855,42 @@ describe('processRequest', () => { ); }); + test('should resolve an indexed watch folder asset path', async () => { + getAsset.mockResolvedValue(Promise.resolve('i am image')); + + const response = await makeRequest( + '/assets/[metro-watchFolders]/0/imgs/a.png?platform=ios', + ); + expect(response._getString()).toBe('i am image'); + + expect(getAsset).toBeCalledWith( + p('./imgs/a.png'), + p('/root'), + [p('/root')], + 'ios', + expect.any(Array), + expect.any(Function), + ); + }); + + test('should resolve a URL-encoded indexed watch folder asset path', async () => { + getAsset.mockResolvedValue(Promise.resolve('i am image')); + + const response = await makeRequest( + '/assets/%5Bmetro-watchFolders%5D/0/imgs/a.png?platform=ios', + ); + expect(response._getString()).toBe('i am image'); + + expect(getAsset).toBeCalledWith( + p('./imgs/a.png'), + p('/root'), + [p('/root')], + 'ios', + expect.any(Array), + expect.any(Function), + ); + }); + test('should serve range request', async () => { const mockData = 'i am image'; getAsset.mockResolvedValue(mockData); @@ -901,6 +948,17 @@ describe('processRequest', () => { ); }); + test('should return a charset in the content-type header for a text asset', async () => { + const mockData = 'i am html'; + getAsset.mockResolvedValue(mockData); + + const response = await makeRequest('/assets/docs/a.html?platform=ios'); + + expect(response.getHeader('content-type')).toBe( + 'text/html; charset=utf-8', + ); + }); + test("should serve assets files's name contain non-latin letter", async () => { getAsset.mockResolvedValue('i am image'); @@ -964,6 +1022,29 @@ describe('processRequest', () => { }); }); + describe('source requests', () => { + beforeEach(() => { + fs.mkdirSync('/root'); + fs.writeFileSync('/root/foo.js', '// \u3053\u3093\u306b\u3061\u306f\n'); + fs.writeFileSync('/root/logo.png', 'not really a png'); + }); + + test('serves a source file with a utf-8 charset', async () => { + const response = await makeRequest('/[metro-project]/foo.js'); + + expect(response.getHeader('content-type')).toBe( + 'text/javascript; charset=utf-8', + ); + expect(response._getString()).toBe('// \u3053\u3093\u306b\u3061\u306f\n'); + }); + + test('does not add a charset to a binary file', async () => { + const response = await makeRequest('/[metro-project]/logo.png'); + + expect(response.getHeader('content-type')).toBe('image/png'); + }); + }); + describe('build(options)', () => { test('Calls the delta bundler with the correct args', async () => { await server.build({ @@ -1437,6 +1518,27 @@ describe('processRequest', () => { ); }); + describe('asset URL roots', () => { + test('anchors asset URLs on projectRoot, not unstable_serverRoot', async () => { + // $FlowFixMe[unclear-type] - reaching for a private method under test. + const serverRootServer: any = new Server( + mergeConfig(config, { + server: {unstable_serverRoot: '/'}, + } as InputConfigT), + ); + + await serverRootServer._getAssetsFromDependencies(new Map(), 'ios'); + + expect(getAssetsSerializer).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + projectRoot: '/root', + watchFolders: ['/root'], + }), + ); + }); + }); + describe('watchFolder prefix resolution', () => { let watchFolderServer: $FlowFixMe; @@ -1518,5 +1620,32 @@ describe('processRequest', () => { '/project/mybundle', ); }); + + test.each([ + [p('/project/imgs/a.png')], + [p('/project/nested/deep/b.png')], + [p('/external/packages/imgs/c.png')], + [p('/external/packages/d.png')], + ])( + 'asset URL path for %s round-trips back to the same file', + absolutePath => { + const {getAssetUrlPath} = require('../../Assets'); + const urlPath = getAssetUrlPath(absolutePath, p('/project'), [ + p('/project'), + p('/external/packages'), + ]); + + // Mirrors how _processSingleAssetRequest resolves an incoming URL. + const resolved = watchFolderServer._resolveWatchFolderPrefix( + './' + urlPath, + ); + expect( + path.resolve( + resolved?.rootDir ?? p('/project'), + resolved?.filePath ?? urlPath, + ), + ).toBe(absolutePath); + }, + ); }); }); diff --git a/packages/metro/src/__tests__/Assets-test.js b/packages/metro/src/__tests__/Assets-test.js index 93f8a2c591..f071b2d652 100644 --- a/packages/metro/src/__tests__/Assets-test.js +++ b/packages/metro/src/__tests__/Assets-test.js @@ -20,7 +20,12 @@ jest.mock('../lib/imageSize', () => ({ jest.useRealTimers(); -const {getAsset, getAssetData, getAssetSize} = require('../Assets'); +const { + getAsset, + getAssetData, + getAssetSize, + getAssetUrlPath, +} = require('../Assets'); const getImageDimensions = require('../lib/imageSize').getImageDimensions; const crypto = require('crypto'); const fs = require('fs'); @@ -29,6 +34,28 @@ const path = require('path'); const mockImageWidth = 300; const mockImageHeight = 200; +describe('getAssetUrlPath', () => { + test('uses a project-relative path for assets within projectRoot', () => { + expect( + getAssetUrlPath('/root/imgs/a.png', '/root', ['/root', '/external']), + ).toBe('imgs/a.png'); + }); + + test('uses an indexed path for assets within a watch folder', () => { + expect( + getAssetUrlPath('/external/imgs/a.png', '/root', ['/root', '/external']), + ).toBe('[metro-watchFolders]/1/imgs/a.png'); + }); + + test('throws for an asset outside every configured root', () => { + expect(() => + getAssetUrlPath('/other/imgs/a.png', '/root', ['/root', '/external']), + ).toThrow( + "Asset '/other/imgs/a.png' is not within projectRoot '/root' or any watch folder (/root, /external)", + ); + }); +}); + describe('getAssetSize', () => { test('returns null for non-image assets', () => { expect(getAssetSize('mp4', Buffer.from('video'), '/root/video.mp4')).toBe( diff --git a/packages/metro/src/__tests__/Bundler-test.js b/packages/metro/src/__tests__/Bundler-test.js new file mode 100644 index 0000000000..14c32f8990 --- /dev/null +++ b/packages/metro/src/__tests__/Bundler-test.js @@ -0,0 +1,138 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {ConfigT} from 'metro-config'; + +import Bundler from '../Bundler'; +import Transformer from '../DeltaBundler/Transformer'; +import DependencyGraph from '../node-haste/DependencyGraph'; +import {getDefaultConfig} from 'metro-config'; + +jest.mock('../DeltaBundler/Transformer'); +jest.mock('../node-haste/DependencyGraph'); + +type ClassMock = JestMockFn, unknown>; + +// $FlowFixMe[incompatible-type] Jest automocks the default export +const MockTransformer: ClassMock = Transformer; +// $FlowFixMe[incompatible-type] Jest automocks the default export +const MockDependencyGraph: ClassMock = DependencyGraph; + +describe('Bundler', () => { + let config: ConfigT; + let reporter; + let depGraphEnd; + + beforeEach(() => { + reporter = {update: jest.fn()}; + config = {...getDefaultConfig.getDefaultValues('/'), reporter}; + + depGraphEnd = jest.fn().mockResolvedValue(); + MockDependencyGraph.mockImplementation(() => ({ + ready: jest.fn().mockResolvedValue(), + end: depGraphEnd, + })); + + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test.each([ + ['ready', (bundler: Bundler) => bundler.ready()], + [ + 'transformFile', + // $FlowFixMe[incompatible-type] Transform options are unused before initialization fails + (bundler: Bundler) => bundler.transformFile('/entry.js', {}), + ], + ])( + 'propagates Transformer initialization errors from %s', + async (_method, invoke) => { + const error = new Error('Transformer initialization failed'); + MockTransformer.mockImplementation(() => { + throw error; + }); + + const bundler = new Bundler(config); + + await expect(invoke(bundler)).rejects.toBe(error); + expect(reporter.update).toHaveBeenCalledWith({ + type: 'transformer_load_failed', + error, + }); + }, + ); + + test('ends the dependency graph when Transformer initialization fails', async () => { + MockTransformer.mockImplementation(() => { + throw new Error('Transformer initialization failed'); + }); + + const bundler = new Bundler(config); + + await expect(bundler.end()).resolves.toBeUndefined(); + expect(depGraphEnd).toHaveBeenCalledTimes(1); + }); + + test('ends the Transformer and dependency graph after initialization', async () => { + const transformerEnd = jest.fn().mockResolvedValue(); + MockTransformer.mockImplementation(() => ({end: transformerEnd})); + + const bundler = new Bundler(config); + await bundler.ready(); + await bundler.end(); + + expect(transformerEnd).toHaveBeenCalledTimes(1); + expect(depGraphEnd).toHaveBeenCalledTimes(1); + }); + + test('ends the dependency graph when ending the Transformer fails', async () => { + const error = new Error('Farm is ended'); + MockTransformer.mockImplementation(() => ({ + end: jest.fn().mockRejectedValue(error), + })); + + const bundler = new Bundler(config); + await bundler.ready(); + + await expect(bundler.end()).rejects.toBe(error); + expect(depGraphEnd).toHaveBeenCalledTimes(1); + }); + + test('does not emit an unhandled rejection before ready is called', async () => { + jest.useRealTimers(); + + const error = new Error('Transformer initialization failed'); + const unhandledRejections: Array = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + + MockTransformer.mockImplementation(() => { + throw error; + }); + process.on('unhandledRejection', onUnhandledRejection); + + try { + const bundler = new Bundler(config); + + await new Promise(resolve => setImmediate(resolve)); + + await expect(bundler.ready()).rejects.toBe(error); + expect(unhandledRejections).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandledRejection); + jest.useFakeTimers(); + } + }); +}); diff --git a/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap b/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap index c7ef3f9cb1..40e4368fad 100644 --- a/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap +++ b/packages/metro/src/integration_tests/__tests__/__snapshots__/import-export-test.js.snap @@ -16,6 +16,17 @@ Object { }, "default": "export-4: FOO", "extraData": Object { + "arrayFirst": "export-destructuring: ARRAY_FIRST", + "arrayRest": Array [ + "export-destructuring: ARRAY_REST", + ], + "exportStarDefault": "export-star-overrides: DEFAULT", + "exportStarEvaluationOrder": Array [ + "source module", + "barrel body", + ], + "exportStarOverridden": "export-star-overrides: OVERRIDDEN", + "exportStarSourceOnly": "export-star-source: SOURCE_ONLY", "foo": "export-null: FOO", "importStar": Object { "default": "export-2: DEFAULT", @@ -25,8 +36,14 @@ Object { "myDefault": "export-1: DEFAULT", "myFoo": "export-1: FOO", "myFunction": "export-1: MY_FUNCTION", + "namespaceReExportDefault": "export-2: DEFAULT", + "namespaceReExportFoo": "export-2: FOO", + "objectRest": Object { + "remaining": "export-destructuring: OBJECT_REST", + }, "primitiveDefault": "export-primitive-default: DEFAULT", "primitiveFoo": "export-primitive-default: FOO", + "renamedObject": "export-destructuring: RENAMED_OBJECT", }, "namedDefaultExported": "export-3: DEFAULT", } diff --git a/packages/metro/src/integration_tests/__tests__/build-errors-test.js b/packages/metro/src/integration_tests/__tests__/build-errors-test.js index a94a3326d4..533bc205c7 100644 --- a/packages/metro/src/integration_tests/__tests__/build-errors-test.js +++ b/packages/metro/src/integration_tests/__tests__/build-errors-test.js @@ -113,6 +113,40 @@ describe('formatting edge cases', () => { await expect(buildPromise).rejects.toMatchSnapshot(); }); + test('reports resolution errors on locations past the end of the source', async () => { + // A Babel plugin appends an unresolvable import whose source location is + // past the end of the file on disk. The resolution error must survive: + // before, building the code frame threw a TypeError that replaced it. + const config = await Metro.loadConfig({ + config: require.resolve('../metro.config.js'), + }); + + const buildPromise = Metro.runBuild( + { + ...config, + transformer: { + ...config.transformer, + babelTransformerPath: require.resolve( + '../injectImportPastEndOfFileTransformer', + ), + }, + }, + {entry: 'build-errors/transform-injected-import.js'}, + ); + + // Deliberately not a snapshot: the resolver's list of candidate paths + // varies between versions, and what matters here is only that the + // resolution error survives with a code frame of the lines that do exist. + const error = await buildPromise.then( + () => null, + (e: Error) => e, + ); + expect(error?.message).toMatch( + /^Unable to resolve module \.\/does-not-exist/, + ); + expect(error?.message).toContain('13 | global.x = 1;'); + }); + test('reports resolution errors with embedded comment after the specifier', async () => { const config = await Metro.loadConfig({ config: require.resolve('../metro.config.js'), diff --git a/packages/metro/src/integration_tests/__tests__/import-export-test.js b/packages/metro/src/integration_tests/__tests__/import-export-test.js index e384dba72f..3ed3cc79b2 100644 --- a/packages/metro/src/integration_tests/__tests__/import-export-test.js +++ b/packages/metro/src/integration_tests/__tests__/import-export-test.js @@ -27,6 +27,31 @@ test('builds a simple bundle', async () => { const object = execBundle(result.code); const cjs = await object.asyncImportCJS; + expect(object.extraData.renamedObject).toBe( + 'export-destructuring: RENAMED_OBJECT', + ); + expect(object.extraData.objectRest).toEqual({ + remaining: 'export-destructuring: OBJECT_REST', + }); + expect(object.extraData.arrayFirst).toBe('export-destructuring: ARRAY_FIRST'); + expect(object.extraData.arrayRest).toEqual([ + 'export-destructuring: ARRAY_REST', + ]); + expect(object.extraData.namespaceReExportDefault).toBe('export-2: DEFAULT'); + expect(object.extraData.namespaceReExportFoo).toBe('export-2: FOO'); + expect(object.extraData.exportStarDefault).toBe( + 'export-star-overrides: DEFAULT', + ); + expect(object.extraData.exportStarEvaluationOrder).toEqual([ + 'source module', + 'barrel body', + ]); + expect(object.extraData.exportStarOverridden).toBe( + 'export-star-overrides: OVERRIDDEN', + ); + expect(object.extraData.exportStarSourceOnly).toBe( + 'export-star-source: SOURCE_ONLY', + ); expect(object).toMatchSnapshot(); expect(cjs).toEqual(expect.objectContaining(cjs.default)); diff --git a/packages/metro/src/integration_tests/basic_bundle/build-errors/transform-injected-import.js b/packages/metro/src/integration_tests/basic_bundle/build-errors/transform-injected-import.js new file mode 100644 index 0000000000..0df51fd906 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/build-errors/transform-injected-import.js @@ -0,0 +1,13 @@ +/** + * 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 + */ + +// The unresolvable import in this module is added by a Babel plugin, not +// written here - see injectImportPastEndOfFileTransformer.js. +global.x = 1; diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/export-destructuring.js b/packages/metro/src/integration_tests/basic_bundle/import-export/export-destructuring.js new file mode 100644 index 0000000000..8d36febb73 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/export-destructuring.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 + */ + +'use strict'; + +const objectSource = { + original: 'export-destructuring: RENAMED_OBJECT', + remaining: 'export-destructuring: OBJECT_REST', +}; + +export const {original: renamedObject, ...objectRest} = objectSource; + +const arraySource = [ + 'export-destructuring: ARRAY_FIRST', + 'export-destructuring: ARRAY_REST', +]; + +// $FlowFixMe[invalid-exported-annotation] Flow can't infer an exported annotation for an array rest binding +export const [arrayFirst, ...arrayRest] = arraySource; diff --git a/flow-typed/npm/ci-info_v2.x.x.js b/packages/metro/src/integration_tests/basic_bundle/import-export/export-namespace.js similarity index 55% rename from flow-typed/npm/ci-info_v2.x.x.js rename to packages/metro/src/integration_tests/basic_bundle/import-export/export-namespace.js index ae8f285367..a510a10938 100644 --- a/flow-typed/npm/ci-info_v2.x.x.js +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/export-namespace.js @@ -4,16 +4,10 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict * @format - * @oncall react_native + * @flow strict */ -// $FlowFixMe[unsupported-syntax] -declare module 'ci-info' { - declare module.exports: { - isCI: boolean, - name: string, - isPR: boolean, - }; -} +'use strict'; + +export * as namespaceReExport from './export-2'; diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-overrides.js b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-overrides.js new file mode 100644 index 0000000000..00319cde38 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-overrides.js @@ -0,0 +1,23 @@ +/** + * 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 + */ + +'use strict'; + +export * from './export-star-source'; + +(global.__exportStarEvents || (global.__exportStarEvents = [])).push( + 'barrel body', +); + +export const evaluationOrder = global.__exportStarEvents; + +export const overridden = 'export-star-overrides: OVERRIDDEN'; + +export default 'export-star-overrides: DEFAULT'; diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-source.js b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-source.js new file mode 100644 index 0000000000..2d3a39fbe3 --- /dev/null +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/export-star-source.js @@ -0,0 +1,21 @@ +/** + * 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 + */ + +'use strict'; + +(global.__exportStarEvents || (global.__exportStarEvents = [])).push( + 'source module', +); + +export default 'export-star-source: DEFAULT'; + +export const overridden = 'export-star-source: OVERRIDDEN'; + +export const sourceOnly = 'export-star-source: SOURCE_ONLY'; diff --git a/packages/metro/src/integration_tests/basic_bundle/import-export/index.js b/packages/metro/src/integration_tests/basic_bundle/import-export/index.js index 654a2e9b65..0c331a5c62 100644 --- a/packages/metro/src/integration_tests/basic_bundle/import-export/index.js +++ b/packages/metro/src/integration_tests/basic_bundle/import-export/index.js @@ -14,10 +14,22 @@ import type {RequireWithUnstableImportMaybeSync} from './utils'; import {default as myDefault, foo as myFoo, myFunction} from './export-1'; import * as importStar from './export-2'; +import { + arrayFirst, + arrayRest, + objectRest, + renamedObject, +} from './export-destructuring'; +import {namespaceReExport} from './export-namespace'; import {foo} from './export-null'; import primitiveDefault, { foo as primitiveFoo, } from './export-primitive-default'; +import exportStarDefault, { + evaluationOrder as exportStarEvaluationOrder, + overridden as exportStarOverridden, + sourceOnly as exportStarSourceOnly, +} from './export-star-overrides'; declare var require: RequireWithUnstableImportMaybeSync; @@ -25,13 +37,23 @@ export {default as namedDefaultExported} from './export-3'; export {foo as default} from './export-4'; export const extraData = { + arrayFirst, + arrayRest, + exportStarDefault, + exportStarEvaluationOrder, + exportStarOverridden, + exportStarSourceOnly, foo, importStar, myDefault, myFoo, myFunction: myFunction() as string, + namespaceReExportDefault: namespaceReExport.default, + namespaceReExportFoo: namespaceReExport.foo, + objectRest, primitiveDefault, primitiveFoo, + renamedObject, }; export const asyncImportCJS = import('./export-5'); diff --git a/packages/metro/src/integration_tests/injectImportPastEndOfFileTransformer.js b/packages/metro/src/integration_tests/injectImportPastEndOfFileTransformer.js new file mode 100644 index 0000000000..e75b85391a --- /dev/null +++ b/packages/metro/src/integration_tests/injectImportPastEndOfFileTransformer.js @@ -0,0 +1,67 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + * @oncall react_native + */ + +'use strict'; + +import type {PluginObj} from '@babel/core'; +import type { + BabelTransformer, + BabelTransformerArgs, +} from 'metro-babel-transformer'; + +const {parse} = require('@babel/parser'); +const baseTransformer = require('@react-native/metro-babel-transformer'); + +const TARGET_FILE = 'transform-injected-import.js'; +const INJECTED_SPECIFIER = './does-not-exist'; + +/** + * Returns a Babel plugin that appends `import './does-not-exist';` to the + * module, parsed at `startLine` - which is what a plugin that generates and + * appends code does, so that the generated code's locations do not overlap the + * author's. + * + * Given a `startLine` past the end of the file, the dependency Metro collects + * reports a source location that has no counterpart in the file on disk, which + * is what the resolution error's code frame has to tolerate. + */ +function injectImportAt(startLine: number): () => PluginObj<> { + return () => ({ + name: 'metro-test-inject-import-past-end-of-file', + visitor: { + Program(path) { + const generated = parse(`import '${INJECTED_SPECIFIER}';`, { + sourceType: 'module', + startLine, + }); + path.node.body.push(...generated.program.body); + }, + }, + }); +} + +function transform(args: BabelTransformerArgs) { + if (!args.filename.endsWith(TARGET_FILE)) { + return baseTransformer.transform(args); + } + // One line past the last line of the source, so the injected import's + // location does not exist in the file on disk. + const startLine = args.src.split('\n').length + 1; + return baseTransformer.transform({ + ...args, + plugins: [...(args.plugins ?? []), injectImportAt(startLine)], + }); +} + +module.exports = { + transform, + getCacheKey: baseTransformer.getCacheKey, +} as BabelTransformer; diff --git a/packages/metro/src/lib/__tests__/imageSize-test.js b/packages/metro/src/lib/__tests__/imageSize-test.js index 02e4e62975..af382640ce 100644 --- a/packages/metro/src/lib/__tests__/imageSize-test.js +++ b/packages/metro/src/lib/__tests__/imageSize-test.js @@ -53,6 +53,32 @@ describe('getImageDimensions', () => { ).toEqual({width: WIDTH, height: HEIGHT}); }); + test.each([ + [ + 'stroke-width after width and height', + ``, + ], + [ + 'stroke-width with only a viewBox', + ``, + ], + [ + 'other hyphenated attributes', + ``, + ], + [ + 'a namespaced attribute after width and height', + ``, + ], + ])( + 'ignores SVG attributes that only end in a dimension name: %s', + (_, svg) => { + expect( + getImageDimensions('svg', Buffer.from(svg), '/root/icon.svg'), + ).toEqual({width: WIDTH, height: HEIGHT}); + }, + ); + test('rejects unsupported SVG units', () => { expect(() => getImageDimensions( diff --git a/packages/metro/src/lib/imageSize.js b/packages/metro/src/lib/imageSize.js index 46ede3a06f..fc623dd309 100644 --- a/packages/metro/src/lib/imageSize.js +++ b/packages/metro/src/lib/imageSize.js @@ -312,7 +312,7 @@ function parseSvg(content: Buffer): ?Dimensions { const root = header.slice(rootStart, rootEnd + 1); const attributes: {[string]: string} = {}; const attributePattern = - /\b(width|height|viewBox)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi; + /(? { - await this.ready(); + // A failed build is surfaced by ready(), and shouldn't prevent teardown. + await this._initializedPromise.catch(() => {}); await this._haste.end(); } diff --git a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js index dbe99c0714..b61b9cad58 100644 --- a/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js +++ b/packages/metro/src/node-haste/DependencyGraph/ModuleResolution.js @@ -365,6 +365,11 @@ function refineDependencyLocation( // Note that module names may not always be found in the source code verbatim, // whether because of escaping or because of exotic dependency APIs. for (let line = loc.end.line - 1; line >= loc.start.line - 1; line--) { + // The error occurred in code that was added by a transform, then it + // won't be present in the original source code. + if (lines[line] == null) { + continue; + } const maxColumn = line === loc.end.line ? loc.end.column + 2 : lines[line].length; const minColumn = line === loc.start.line ? loc.start.column - 1 : 0; diff --git a/packages/metro/src/node-haste/DependencyGraph/createFileMap.js b/packages/metro/src/node-haste/DependencyGraph/createFileMap.js index c3c311153f..1fd5a4f68f 100644 --- a/packages/metro/src/node-haste/DependencyGraph/createFileMap.js +++ b/packages/metro/src/node-haste/DependencyGraph/createFileMap.js @@ -12,7 +12,6 @@ import type {ConfigT} from 'metro-config'; import type {HasteMap, InputFileMapPlugin} from 'metro-file-map'; -import ci from 'ci-info'; import MetroFileMap, { DependencyPlugin, DiskCacheManager, @@ -57,6 +56,11 @@ function getIgnorePattern(config: ConfigT): RegExp { return ignorePattern; } +function isCIEnv() { + const CI = process.env.CI; + return typeof CI === 'string' && CI !== '' && CI !== '0' && CI !== 'false'; +} + export default function createFileMap( config: ConfigT, options?: Readonly<{ @@ -70,7 +74,7 @@ export default function createFileMap( hasteMap: HasteMap, dependencyPlugin: ?DependencyPlugin, } { - const watch = options?.watch == null ? !ci.isCI : options.watch; + const watch = options?.watch ?? !isCIEnv(); const {enabled: autoSaveEnabled, ...autoSaveOpts} = config.watcher.unstable_autoSaveCache ?? {}; const autoSave = watch && autoSaveEnabled ? autoSaveOpts : false; diff --git a/packages/metro/src/node-haste/__tests__/DependencyGraph-test.js b/packages/metro/src/node-haste/__tests__/DependencyGraph-test.js new file mode 100644 index 0000000000..15f950dec0 --- /dev/null +++ b/packages/metro/src/node-haste/__tests__/DependencyGraph-test.js @@ -0,0 +1,56 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {ConfigT} from 'metro-config'; + +import DependencyGraph from '../DependencyGraph'; +import createFileMap from '../DependencyGraph/createFileMap'; +import {getDefaultConfig} from 'metro-config'; + +jest.mock('../DependencyGraph/createFileMap'); + +type FunctionMock = JestMockFn, unknown>; + +// $FlowFixMe[incompatible-type] Jest automocks the default export +const mockCreateFileMap: FunctionMock = createFileMap; + +describe('DependencyGraph', () => { + let config: ConfigT; + let fileMapEnd; + + beforeEach(() => { + config = { + ...getDefaultConfig.getDefaultValues('/'), + reporter: {update: jest.fn()}, + }; + fileMapEnd = jest.fn().mockResolvedValue(); + }); + + test('ends the file map when the build fails', async () => { + const error = new Error('Crawl failed'); + mockCreateFileMap.mockReturnValue({ + fileMap: { + build: jest.fn().mockRejectedValue(error), + end: fileMapEnd, + on: jest.fn(), + setMaxListeners: jest.fn(), + }, + hasteMap: {}, + dependencyPlugin: null, + }); + + const depGraph = new DependencyGraph(config); + + await expect(depGraph.ready()).rejects.toBe(error); + await expect(depGraph.end()).resolves.toBeUndefined(); + expect(fileMapEnd).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/metro/types/Assets.d.ts b/packages/metro/types/Assets.d.ts index eb77e92a9d..eb97805907 100644 --- a/packages/metro/types/Assets.d.ts +++ b/packages/metro/types/Assets.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<66a96f3882bcba8f8bd890828453052a>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/Assets.js @@ -57,11 +57,27 @@ export type AssetDataPlugin = ( ) => AssetData | Promise; export declare function getAssetData( assetPath: string, - localPath: string, + urlPath: string, assetDataPlugins: ReadonlyArray, platform: null | undefined | string, publicPath: string, ): Promise; +/** + * Returns the path used to identify an asset in its development server URL, + * relative to publicPath. Assets outside projectRoot use an indexed watch + * folder prefix so that the URL unambiguously identifies their configured + * root. + * + * Roots are resolved as Server does when it decodes these paths, so that the + * index here and the index it reads back refer to the same directory. The + * first root to contain the asset wins, in index order, so that appending a + * watch folder never changes the URL of an asset under an existing one. + */ +export declare function getAssetUrlPath( + assetPath: string, + projectRoot: string, + watchFolders: ReadonlyArray, +): string; /** * Returns all the associated files (for different resolutions) of an asset. **/ diff --git a/packages/metro/types/Bundler.d.ts b/packages/metro/types/Bundler.d.ts index 86d8279403..7c4fd5a151 100644 --- a/packages/metro/types/Bundler.d.ts +++ b/packages/metro/types/Bundler.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<3d5664309abdece0f247fcd0c53c3aaf>> + * @generated SignedSource<<0bcc56baaa3cab86e03f6ac13b6ecb9a>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/Bundler.js @@ -30,7 +30,7 @@ export type BundlerOptions = Readonly<{ declare class Bundler { _depGraph: DependencyGraph; _initializedPromise: Promise; - _transformer: Transformer; + _transformer: null | undefined | Transformer; constructor(config: ConfigT, options?: BundlerOptions); getWatcher(): EventEmitter; end(): Promise; diff --git a/packages/metro/types/DeltaBundler/Serializers/getAssets.d.ts b/packages/metro/types/DeltaBundler/Serializers/getAssets.d.ts index c2ccffec0f..e9aa6fbe1b 100644 --- a/packages/metro/types/DeltaBundler/Serializers/getAssets.d.ts +++ b/packages/metro/types/DeltaBundler/Serializers/getAssets.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<0a49d828c4a80d52ccab4d4766b84c86>> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/DeltaBundler/Serializers/getAssets.js @@ -24,6 +24,7 @@ type Options = { platform: null | undefined | string; projectRoot: string; publicPath: string; + watchFolders: ReadonlyArray; }; declare function getAssets( dependencies: ReadOnlyDependencies, diff --git a/packages/metro/types/DeltaBundler/Worker.d.ts b/packages/metro/types/DeltaBundler/Worker.d.ts index ec48d676ff..f21429a8bb 100644 --- a/packages/metro/types/DeltaBundler/Worker.d.ts +++ b/packages/metro/types/DeltaBundler/Worker.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<<36640ae81894592fbdc160fac081bdbf>> + * @generated SignedSource<> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/DeltaBundler/Worker.flow.js @@ -39,6 +39,7 @@ export declare const transform: ( projectRoot: string, transformerConfig: TransformerConfig, fileBuffer?: Buffer, + assetUrlPath?: string, ) => Promise; export declare type transform = typeof transform; export type Worker = {readonly transform: typeof transform}; diff --git a/packages/metro/types/DeltaBundler/WorkerFarm.d.ts b/packages/metro/types/DeltaBundler/WorkerFarm.d.ts index ec857eb608..45a47125b3 100644 --- a/packages/metro/types/DeltaBundler/WorkerFarm.d.ts +++ b/packages/metro/types/DeltaBundler/WorkerFarm.d.ts @@ -6,7 +6,7 @@ * * @noformat * @oncall react_native - * @generated SignedSource<> + * @generated SignedSource<<5839102968c6c59d8eba72a262122453>> * * This file was translated from Flow by scripts/generateTypeScriptDefinitions.js * Original file: packages/metro/src/DeltaBundler/WorkerFarm.js @@ -45,6 +45,7 @@ declare class WorkerFarm { filename: string, options: TransformOptions, fileBuffer?: Buffer, + assetUrlPath?: string, ): Promise; _makeFarm( absoluteWorkerPath: string, diff --git a/packages/ob1/package.json b/packages/ob1/package.json index 80598f4f59..9203bef842 100644 --- a/packages/ob1/package.json +++ b/packages/ob1/package.json @@ -1,6 +1,6 @@ { "name": "ob1", - "version": "0.84.6", + "version": "0.84.7", "description": "A small library for working with 0- and 1-based offsets in a type-checked way.", "main": "src/ob1.js", "exports": { diff --git a/scripts/build.js b/scripts/build.js index e4116336e3..ada3102f03 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -15,7 +15,10 @@ * script to build (transpile) files. * By default it transpiles all files for all packages and writes them * into `build/` directory. - * Non-js or files matching IGNORE_PATTERN will be copied without transpiling. + * + * Files matching IGNORE_PATTERNS will not be copied to the build directory. + * + * Non-js will be copied without transpiling. * * Example: * node ./scripts/build.js @@ -37,7 +40,13 @@ const SRC_DIR = 'src'; const TYPES_DIR = 'types'; const BUILD_DIR = 'build'; const JS_FILES_PATTERN = '**/*.js'; -const IGNORE_PATTERN = '**/__tests__/**'; +const IGNORE_PATTERNS = [ + '**/__tests__/**', + '**/__fixtures__/**', + '**/__flowtests__/**', + '**/__mocks__/**', + '**/integration_tests/**', +]; const PACKAGES_DIR = path.resolve(__dirname, '../packages'); const fixedWidth = function(str/*: string*/) { @@ -91,7 +100,7 @@ async function buildFile(file /*: string */, silent /*: number | boolean */) { const destPath = getBuildPath(file, BUILD_DIR); fs.mkdirSync(path.dirname(destPath), {recursive: true}); - if (micromatch.isMatch(file, IGNORE_PATTERN)) { + if (micromatch.isMatch(file, IGNORE_PATTERNS)) { silent || process.stdout.write( chalk.dim(' \u2022 ') + diff --git a/scripts/support/generateBabelTypesFlowLibraryDefinition.js b/scripts/support/generateBabelTypesFlowLibraryDefinition.js index 6bdab43ef8..48164b69ad 100644 --- a/scripts/support/generateBabelTypesFlowLibraryDefinition.js +++ b/scripts/support/generateBabelTypesFlowLibraryDefinition.js @@ -221,9 +221,9 @@ function main() { // retrievers/ // eslint-disable-next-line max-len - `declare export function getBindingIdentifiers(node: ${NODE_PREFIX}, duplicates: boolean, outerOnly?: boolean): { [key: string]: ${NODE_PREFIX}Identifier | Array<${NODE_PREFIX}Identifier> }`, + `declare export function getBindingIdentifiers(node: ${NODE_PREFIX}, duplicates?: boolean, outerOnly?: boolean): { [key: string]: ${NODE_PREFIX}Identifier | Array<${NODE_PREFIX}Identifier> }`, // eslint-disable-next-line max-len - `declare export function getOuterBindingIdentifiers(node: Node, duplicates: boolean): { [key: string]: ${NODE_PREFIX}Identifier | Array<${NODE_PREFIX}Identifier> }`, + `declare export function getOuterBindingIdentifiers(node: Node, duplicates?: boolean): { [key: string]: ${NODE_PREFIX}Identifier | Array<${NODE_PREFIX}Identifier> }`, // traverse/ `declare export type TraversalAncestors = Array<{ diff --git a/website/yarn.lock b/website/yarn.lock index b81cd63697..c381d424d6 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -167,6 +167,15 @@ js-tokens "^4.0.0" picocolors "^1.0.0" +"@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": + version "7.29.0" + resolved "https://registry.facebook.net/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.26.5", "@babel/compat-data@^7.26.8": version "7.26.8" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.8.tgz#821c1d35641c355284d4a870b8a4a7b0c141e367" @@ -204,6 +213,17 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^3.0.2" +"@babel/generator@^7.29.0": + version "7.29.1" + resolved "https://registry.facebook.net/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50" + integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== + dependencies: + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz#d8eac4d2dc0d7b6e11fa6e535332e0d3184f06b4" @@ -255,6 +275,11 @@ lodash.debounce "^4.0.8" resolve "^1.14.2" +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.facebook.net/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + "@babel/helper-member-expression-to-functions@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz#9dfffe46f727005a5ea29051ac835fb735e4c1a3" @@ -271,6 +296,14 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" +"@babel/helper-module-imports@^7.28.6": + version "7.28.6" + resolved "https://registry.facebook.net/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c" + integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== + dependencies: + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" + "@babel/helper-module-transforms@^7.25.9", "@babel/helper-module-transforms@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae" @@ -280,6 +313,15 @@ "@babel/helper-validator-identifier" "^7.25.9" "@babel/traverse" "^7.25.9" +"@babel/helper-module-transforms@^7.28.6": + version "7.28.6" + resolved "https://registry.facebook.net/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" + integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== + dependencies: + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.6" + "@babel/helper-optimise-call-expression@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz#3324ae50bae7e2ab3c33f60c9a877b6a0146b54e" @@ -292,6 +334,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz#18580d00c9934117ad719392c4f6585c9333cc35" integrity sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg== +"@babel/helper-plugin-utils@^7.28.6": + version "7.28.6" + resolved "https://registry.facebook.net/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== + "@babel/helper-remap-async-to-generator@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz#e53956ab3d5b9fb88be04b3e2f31b523afd34b92" @@ -323,11 +370,21 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.facebook.net/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + "@babel/helper-validator-identifier@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.facebook.net/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + "@babel/helper-validator-option@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72" @@ -357,6 +414,13 @@ dependencies: "@babel/types" "^7.26.9" +"@babel/parser@^7.28.6", "@babel/parser@^7.29.0": + version "7.29.3" + resolved "https://registry.facebook.net/@babel/parser/-/parser-7.29.3.tgz#116f70a77958307fceac27747573032f8a62f88e" + integrity sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA== + dependencies: + "@babel/types" "^7.29.0" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz#cc2e53ebf0a0340777fff5ed521943e253b4d8fe" @@ -632,14 +696,14 @@ "@babel/helper-plugin-utils" "^7.25.9" "@babel/plugin-transform-modules-systemjs@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz#8bd1b43836269e3d33307151a114bcf3ba6793f8" - integrity sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA== + version "7.29.4" + resolved "https://registry.facebook.net/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz#f621105da99919c15cf4bde6fcc7346ef95e7b20" + integrity sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w== dependencies: - "@babel/helper-module-transforms" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" - "@babel/helper-validator-identifier" "^7.25.9" - "@babel/traverse" "^7.25.9" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.29.0" "@babel/plugin-transform-modules-umd@^7.25.9": version "7.25.9" @@ -1025,6 +1089,15 @@ "@babel/parser" "^7.26.9" "@babel/types" "^7.26.9" +"@babel/template@^7.28.6": + version "7.28.6" + resolved "https://registry.facebook.net/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + "@babel/traverse@^7.25.9", "@babel/traverse@^7.26.5", "@babel/traverse@^7.26.8", "@babel/traverse@^7.26.9": version "7.26.9" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.9.tgz#4398f2394ba66d05d988b2ad13c219a2c857461a" @@ -1038,6 +1111,19 @@ debug "^4.3.1" globals "^11.1.0" +"@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": + version "7.29.0" + resolved "https://registry.facebook.net/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" + integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/types" "^7.29.0" + debug "^4.3.1" + "@babel/types@^7.21.3", "@babel/types@^7.25.9", "@babel/types@^7.26.9", "@babel/types@^7.4.4": version "7.26.9" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.9.tgz#08b43dec79ee8e682c2ac631c010bdcac54a21ce" @@ -1046,6 +1132,14 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" +"@babel/types@^7.28.6", "@babel/types@^7.29.0": + version "7.29.0" + resolved "https://registry.facebook.net/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" + integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + "@braintree/sanitize-url@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783" @@ -1914,6 +2008,14 @@ "@types/yargs" "^17.0.8" chalk "^4.0.0" +"@jridgewell/gen-mapping@^0.3.12": + version "0.3.13" + resolved "https://registry.facebook.net/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + "@jridgewell/gen-mapping@^0.3.5": version "0.3.8" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz#4f0e06362e01362f823d348f1872b08f666d8142" @@ -1946,6 +2048,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== +"@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.facebook.net/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" @@ -1954,6 +2061,14 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.facebook.net/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.5" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" diff --git a/yarn.lock b/yarn.lock index c7c366c50f..c14dd6e48b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2177,11 +2177,6 @@ char-regex@^2.0.0: resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-2.0.1.tgz#6dafdb25f9d3349914079f010ba8d0e6ff9cd01e" integrity sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw== -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== - ci-info@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.0.tgz#b4ed1fb6818dea4803a55c623041f9165d2066b2" @@ -5738,7 +5733,7 @@ vlq@^1.0.0: resolved "https://registry.yarnpkg.com/vlq/-/vlq-1.0.1.tgz#c003f6e7c0b4c1edd623fd6ee50bbc0d6a1de468" integrity sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w== -walker@^1.0.7, walker@^1.0.8: +walker@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==