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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/metro-file-map/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ export class NoopCacheManager implements CacheManager {
write(): Promise<void>;
}

export interface Observations {
readonly content: Set<CanonicalPath>;
readonly existence: Set<CanonicalPath>;
}

export type WatcherStatus =
| {
type: 'watchman_slow_command';
Expand Down
5 changes: 4 additions & 1 deletion packages/metro-file-map/src/flow-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,10 @@ type V8SerializableCollection =
| ReadonlyMap<string, V8Serializable>
| Readonly<{[key: string]: V8Serializable}>;

export type V8Serializable = V8SerializablePrimitive | V8SerializableCollection;
// `v8.serialize` keeps an error's class (for the built-in error types), name
// and message.
export type V8Serializable =
V8SerializablePrimitive | V8SerializableCollection | Error;

export interface FileMapPlugin<
in SerializableState extends void | V8Serializable = void | V8Serializable,
Expand Down
1 change: 1 addition & 0 deletions packages/metro-file-map/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export type {
DependencyExtractor,
FileDataResult,
FileMapRoot,
Observations,
WatcherStatus,
} from './flow-types';

Expand Down
11 changes: 7 additions & 4 deletions packages/metro/src/node-haste/DependencyGraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,11 @@ export default class DependencyGraph extends EventEmitter {
type: 'dep_graph_loading',
hasReducedPerformance: !!hasReducedPerformance,
});
const {fileMap, hasteMap, dependencyPlugin} = createFileMap(config, {
throwOnModuleCollision: false,
watch,
});
const {fileMap, hasteMap, dependencyPlugin, packageJsonPlugin} =
createFileMap(config, {
throwOnModuleCollision: false,
watch,
});

// We can have a lot of graphs listening to Haste for changes.
// Bump this up to silence the max listeners EventEmitter warning.
Expand All @@ -137,6 +138,8 @@ export default class DependencyGraph extends EventEmitter {
this.#packageCache = new PackageCache({
getClosestPackage: absoluteModulePath =>
this._getClosestPackage(absoluteModulePath),
readPackageJson: absolutePackageJsonPath =>
packageJsonPlugin.readPackageJson(absolutePackageJsonPath),
});
this._createModuleResolver();
});
Expand Down
70 changes: 70 additions & 0 deletions packages/metro/src/node-haste/DependencyGraph/PackageJsonPlugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* 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 {Observations} from 'metro-file-map';
import type {PackageJson} from 'metro-resolver/private/types';

import {FileDataPlugin} from 'metro-file-map';

/**
* Holds the parsed contents of each `package.json` the resolver reads, as file
* map plugin data. A manifest is read from disk the first time it is asked
* for, and not again until it changes. Its contents are saved with the file
* map, so a later process with a warm cache does not read it at all.
*
* The object returned for a manifest is the stored one, shared by every reader
* and saved with the file map, and stable while the file is unchanged.
* `PackageJson` is read-only, so readers must not mutate it.
*/
export default class PackageJsonPlugin extends FileDataPlugin<> {
constructor() {
super({
name: 'package-json',
cacheKey: 'package-json-parsed-1',
worker: {
modulePath: require.resolve('./packageJsonWorker.js'),
setupArgs: {},
},
// Not consulted for a lazy plugin
filter: () => false,
lazy: true,
});
}

/**
* The parsed `package.json` at the given absolute path, which must be a
* regular file in the file map. Throws the `SyntaxError` from parsing it if
* it is not valid JSON.
*
* If `observations` is given, records what the result depends upon, on
* every call.
*/
readPackageJson(
absolutePackageJsonPath: string,
observations?: ?Observations,
): PackageJson {
const result = this.getOrComputeFileData(
absolutePackageJsonPath,
observations,
);
if (!result.exists || result.type !== 'f') {
throw new Error(
`${absolutePackageJsonPath} is not a regular file in the file map`,
);
}
const data = result.data;
if (data instanceof SyntaxError) {
throw new SyntaxError(data.message);
}
// The worker stores parsed JSON, which Flow sees only as V8Serializable.
return data as $FlowFixMe;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* 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 {FileDataResult, Observations} from 'metro-file-map';

import PackageJsonPlugin from '../PackageJsonPlugin';

const PackageJsonWorker = require('../packageJsonWorker');

describe('PackageJsonPlugin', () => {
let fileMapEntries: Map<
string,
FileDataResult<?{readonly name: string} | SyntaxError>,
>;
let getOrComputeFileData: JestMockFn<
[string, ?Observations],
FileDataResult<?{readonly name: string} | SyntaxError>,
>;
let plugin: PackageJsonPlugin;

const setData = (filePath: string, data: ?{readonly name: string}) =>
fileMapEntries.set(filePath, {
exists: true,
type: 'f',
realPath: filePath,
data,
});

beforeEach(async () => {
fileMapEntries = new Map();
getOrComputeFileData = jest.fn(
mixedPath => fileMapEntries.get(mixedPath) ?? {exists: false},
);
plugin = new PackageJsonPlugin();
await plugin.initialize({
files: {
fileIterator: () => [],
lookup: () => ({exists: false}),
},
pluginState: null,
getOrComputeFileData,
});
});

test('is lazy, with a portable cache key', () => {
expect(plugin.getWorker().lazy).toBe(true);
// The cache key must not depend on where Metro or the project lives
expect(plugin.getCacheKey()).not.toMatch(/[\\/]/);
});

test('returns the stored object, and asks the file map each time', () => {
const stored = {name: 'stored'};
setData('/project/package.json', stored);
const observations = {
existence: new Set<string>(),
content: new Set<string>(),
};
expect(plugin.readPackageJson('/project/package.json', observations)).toBe(
stored,
);
expect(plugin.readPackageJson('/project/package.json', observations)).toBe(
stored,
);
expect(getOrComputeFileData.mock.calls).toEqual([
['/project/package.json', observations],
['/project/package.json', observations],
]);
});

test('throws the stored error for a manifest that is not valid JSON', () => {
fileMapEntries.set('/project/package.json', {
exists: true,
type: 'f',
realPath: '/project/package.json',
data: new SyntaxError('Unexpected end of JSON input'),
});
expect(() => plugin.readPackageJson('/project/package.json')).toThrow(
new SyntaxError('Unexpected end of JSON input'),
);
});

test('throws for a path that is not a regular file in the file map', () => {
fileMapEntries.set('/project/dir/package.json', {exists: true, type: 'd'});
expect(() =>
plugin.readPackageJson('/project/missing/package.json'),
).toThrow(
'/project/missing/package.json is not a regular file in the file map',
);
expect(() => plugin.readPackageJson('/project/dir/package.json')).toThrow(
'/project/dir/package.json is not a regular file in the file map',
);
});
});

describe('packageJsonWorker', () => {
test('returns the parsed contents of the file', () => {
const worker = new PackageJsonWorker();
expect(
worker.processFile(
{filePath: '/project/package.json'},
{getContent: () => Buffer.from('{"name": "café"}\n')},
),
).toEqual({name: 'café'});
});

test('returns the SyntaxError for invalid JSON', () => {
const worker = new PackageJsonWorker();
const result = worker.processFile(
{filePath: '/project/package.json'},
{getContent: () => Buffer.from('{"name": ')},
);
let expected;
try {
JSON.parse('{"name": ');
} catch (error) {
expected = error;
}
expect(result).toBeInstanceOf(SyntaxError);
expect(result).toEqual(expected);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import type {ConfigT} from 'metro-config';
import type {HasteMap, InputFileMapPlugin} from 'metro-file-map';

import PackageJsonPlugin from './PackageJsonPlugin';
import MetroFileMap, {
DependencyPlugin,
DiskCacheManager,
Expand Down Expand Up @@ -59,6 +60,7 @@ export default function createFileMap(
fileMap: MetroFileMap,
hasteMap: HasteMap,
dependencyPlugin: ?DependencyPlugin,
packageJsonPlugin: PackageJsonPlugin,
} {
const watch = options?.watch ?? !isCIEnv();
const {enabled: autoSaveEnabled, ...autoSaveOpts} =
Expand Down Expand Up @@ -95,6 +97,9 @@ export default function createFileMap(

plugins.push(hasteMap);

const packageJsonPlugin = new PackageJsonPlugin();
plugins.push(packageJsonPlugin);

const fileMap = new MetroFileMap({
cacheManagerFactory:
config?.unstable_fileMapCacheManagerFactory ??
Expand Down Expand Up @@ -127,5 +132,5 @@ export default function createFileMap(
watch,
watchmanDeferStates: config.watcher.watchman.deferStates,
});
return {fileMap, hasteMap, dependencyPlugin};
return {fileMap, hasteMap, dependencyPlugin, packageJsonPlugin};
}
30 changes: 30 additions & 0 deletions packages/metro/src/node-haste/DependencyGraph/packageJsonWorker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* 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
* @format
* @oncall react_native
*/

/* eslint-disable import/no-commonjs */

'use strict';

// A file map plugin worker whose data for a `package.json` is its parsed
// contents, or the `SyntaxError` from parsing it if it isn't valid JSON, so
// that the reader can throw it without reading the file again.
module.exports = class PackageJsonWorker {
processFile(
_data /*: Readonly<{filePath: string, ...}> */,
utils /*: Readonly<{getContent: () => Buffer}> */,
) /*: unknown */ {
try {
return JSON.parse(utils.getContent().toString('utf8'));
} catch (error) {
return error;
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe('DependencyGraph', () => {
},
hasteMap: {},
dependencyPlugin: null,
packageJsonPlugin: {},
});

const depGraph = new DependencyGraph(config);
Expand Down
Loading