From ee18210050c33c8adafa46d18a3bbd9f28eb2494 Mon Sep 17 00:00:00 2001 From: Rob Hogan <2590098+robhogan@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:44:49 +0100 Subject: [PATCH] Read package.json contents through a lazy file map plugin Resolution reads every `package.json` it needs with `readFileSync` on the main thread, on every process start. This adds `PackageJsonPlugin`, a lazy file map plugin holding each manifest's parsed contents. A manifest is read and parsed the first time the resolver asks for it, and not again until it changes. Its contents are saved with the file map, so a warm start reads and parses none. `PackageCache` reads through the plugin via its existing `readPackageJson` option. The plugin reads through `getOrComputeFileData`, and `readPackageJson` takes `observations` to pass through, so every read can be recorded once resolutions collect observations. The stored object is returned as-is, so it's stable while the file is unchanged and shared by every reader, which `PackageJson` being read-only already requires. For a manifest that isn't valid JSON, the plugin stores the `SyntaxError` from parsing it and throws one with the same message, without reading the file again. This also exports the `Observations` type from `metro-file-map`, and adds `Error` to its `V8Serializable` type, since `v8.serialize` keeps an error's class, name and message. Changelog: [Performance] Cache `package.json` contents with the file map, so a warm start reads none from disk --- packages/metro-file-map/API.md | 5 + packages/metro-file-map/src/flow-types.js | 5 +- packages/metro-file-map/src/index.js | 1 + .../metro/src/node-haste/DependencyGraph.js | 11 +- .../DependencyGraph/PackageJsonPlugin.js | 70 ++++++++++ .../__tests__/PackageJsonPlugin-test.js | 129 ++++++++++++++++++ .../DependencyGraph/createFileMap.js | 7 +- .../DependencyGraph/packageJsonWorker.js | 30 ++++ .../__tests__/DependencyGraph-test.js | 1 + 9 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 packages/metro/src/node-haste/DependencyGraph/PackageJsonPlugin.js create mode 100644 packages/metro/src/node-haste/DependencyGraph/__tests__/PackageJsonPlugin-test.js create mode 100644 packages/metro/src/node-haste/DependencyGraph/packageJsonWorker.js diff --git a/packages/metro-file-map/API.md b/packages/metro-file-map/API.md index de3591faea..58754d45a8 100644 --- a/packages/metro-file-map/API.md +++ b/packages/metro-file-map/API.md @@ -296,6 +296,11 @@ export class NoopCacheManager implements CacheManager { write(): Promise; } +export interface Observations { + readonly content: Set; + readonly existence: Set; +} + export type WatcherStatus = | { type: 'watchman_slow_command'; diff --git a/packages/metro-file-map/src/flow-types.js b/packages/metro-file-map/src/flow-types.js index cfb4f35338..46457df102 100644 --- a/packages/metro-file-map/src/flow-types.js +++ b/packages/metro-file-map/src/flow-types.js @@ -295,7 +295,10 @@ type V8SerializableCollection = | ReadonlyMap | 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, diff --git a/packages/metro-file-map/src/index.js b/packages/metro-file-map/src/index.js index 2f1c5da828..d72cf6b02f 100644 --- a/packages/metro-file-map/src/index.js +++ b/packages/metro-file-map/src/index.js @@ -166,6 +166,7 @@ export type { DependencyExtractor, FileDataResult, FileMapRoot, + Observations, WatcherStatus, } from './flow-types'; diff --git a/packages/metro/src/node-haste/DependencyGraph.js b/packages/metro/src/node-haste/DependencyGraph.js index ee4a63f708..88f935d6e6 100644 --- a/packages/metro/src/node-haste/DependencyGraph.js +++ b/packages/metro/src/node-haste/DependencyGraph.js @@ -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. @@ -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(); }); diff --git a/packages/metro/src/node-haste/DependencyGraph/PackageJsonPlugin.js b/packages/metro/src/node-haste/DependencyGraph/PackageJsonPlugin.js new file mode 100644 index 0000000000..a205f0f713 --- /dev/null +++ b/packages/metro/src/node-haste/DependencyGraph/PackageJsonPlugin.js @@ -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; + } +} diff --git a/packages/metro/src/node-haste/DependencyGraph/__tests__/PackageJsonPlugin-test.js b/packages/metro/src/node-haste/DependencyGraph/__tests__/PackageJsonPlugin-test.js new file mode 100644 index 0000000000..2e6d19cdd1 --- /dev/null +++ b/packages/metro/src/node-haste/DependencyGraph/__tests__/PackageJsonPlugin-test.js @@ -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, + >; + let getOrComputeFileData: JestMockFn< + [string, ?Observations], + FileDataResult, + >; + 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(), + content: new Set(), + }; + 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); + }); +}); diff --git a/packages/metro/src/node-haste/DependencyGraph/createFileMap.js b/packages/metro/src/node-haste/DependencyGraph/createFileMap.js index 9a3729307a..59c345fcfa 100644 --- a/packages/metro/src/node-haste/DependencyGraph/createFileMap.js +++ b/packages/metro/src/node-haste/DependencyGraph/createFileMap.js @@ -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, @@ -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} = @@ -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 ?? @@ -127,5 +132,5 @@ export default function createFileMap( watch, watchmanDeferStates: config.watcher.watchman.deferStates, }); - return {fileMap, hasteMap, dependencyPlugin}; + return {fileMap, hasteMap, dependencyPlugin, packageJsonPlugin}; } diff --git a/packages/metro/src/node-haste/DependencyGraph/packageJsonWorker.js b/packages/metro/src/node-haste/DependencyGraph/packageJsonWorker.js new file mode 100644 index 0000000000..6b7f7fff38 --- /dev/null +++ b/packages/metro/src/node-haste/DependencyGraph/packageJsonWorker.js @@ -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; + } + } +}; diff --git a/packages/metro/src/node-haste/__tests__/DependencyGraph-test.js b/packages/metro/src/node-haste/__tests__/DependencyGraph-test.js index 15f950dec0..96205ab3cc 100644 --- a/packages/metro/src/node-haste/__tests__/DependencyGraph-test.js +++ b/packages/metro/src/node-haste/__tests__/DependencyGraph-test.js @@ -45,6 +45,7 @@ describe('DependencyGraph', () => { }, hasteMap: {}, dependencyPlugin: null, + packageJsonPlugin: {}, }); const depGraph = new DependencyGraph(config);