Skip to content

Cache package.json contents in the file map - #1612

Open
robhogan wants to merge 1 commit into
mainfrom
export-D84417547
Open

robhogan wants to merge 1 commit into
mainfrom
export-D84417547

Conversation

@robhogan

@robhogan robhogan commented Nov 10, 2025 •

Copy link
Copy Markdown
Collaborator

Currently, Metro reads every package.json the resolver needs with readFileSync on the main thread, on every process start:

const readPackageJsonSync: ReadPackageJsonFn = absolutePackageJsonPath =>
JSON.parse(readFileSync(absolutePackageJsonPath, 'utf8'));

This adds PackageJsonPlugin, a lazy file map plugin holding each manifest's parsed contents. A manifest is parsed the first time the resolver asks for it, and saved with the file map, so a warm start reads none. PackageCache reads through it via its existing readPackageJson option.

Reads go through getOrComputeFileData (#1951), so once resolutions collect observations every manifest read is recorded as content. The stored object is returned as-is - stable while the file is unchanged, and PackageJson is already read-only. An invalid manifest stores its SyntaxError and throws it on read, without reading the file again.

Storing parsed contents rather than text also leaves room for a trimmed, resolver-shaped projection later, though that's a breaking change because context.getPackage hands the whole manifest to custom resolvers.

This also exports Observations from metro-file-map, and adds Error to V8Serializable.

Benchmark

On a warm start of an RN 0.87.1 app (6,935 resolutions), this takes package.json reads from 93 to 0 and the first resolver pass down ~3%. Deserialising the stored manifests costs about the same at startup, so it's roughly even end to end today.

AI-driven benchmark:

main This PR delta (95% CI)
package.json reads from disk, first pass 93 0
First resolver pass (ms, median) 169.1 163.3 -3.4% (-5.2% to -1.4%)
File map startup (ms, median) 391.0 396.9 +1.5% (+1.0% to +7.4%)
File map cache size 1.42 MB 1.88 MB +460 KB
JS heap after first pass 15.40 MB 15.51 MB +0.11 MB
Cache read / write (ms, median) 6.6 / 7.5 8.0 / 8.5

Changelog

 - **[Performance]**: Cache `package.json` contents with the file map, so a warm start reads none from disk

Test plan

New unit tests.

Resolver output is byte-identical to main across all 6,935 resolutions of the benchmark app. In watch mode, retargeting, removing and restoring a browser redirect in a package.json takes effect on the next resolution.

Benchmark app (AI-driven harness): RN 0.87.1 with a 400-module app and a 600-module workspace package outside the project root, replaying a recorded resolution workload through a watch-mode DependencyGraph after priming the file map cache. 15 interleaved rounds per variant on an M4 Pro, Node 26.11.1. The machine was loaded by other work, so timings are from runs at a load average below 10 (8-9 per variant), with medians and bootstrap 95% CIs on the difference.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 10, 2025
@meta-codesync

meta-codesync Bot commented Nov 10, 2025

Copy link
Copy Markdown
Contributor

@robhogan has exported this pull request. If you are a Meta employee, you can view the originating Diff in D84417547.

robhogan added a commit that referenced this pull request Sep 24, 2026
## Summary
A file map plugin with a worker has it run on every file its `filter` accepts, as files are crawled and as they change. That's right for something like dependency extraction, where nearly every file is needed, but it rules out plugins where only a small, unpredictable subset of files is ever used. Parsing `package.json` is the motivating case - on our benchmark app a build reads about 9% of the `package.json` files in the file map (93 of 1,045).

This adds `lazy` to a plugin's worker description. A lazy plugin's worker isn't run at crawl time or on change, and its `filter` isn't consulted. Instead the plugin is given `processFile(mixedPath)` when it's initialised, which runs its worker on that one file - synchronously, in-band - stores the result as the file's plugin data, and returns it. It also emits `metadata`, as lazy SHA-1 does, so the cache knows there's something to save and processed files stay processed across restarts.

The plugin decides when to call it. Plugin data is `undefined` for a file until a worker has run on it, and is `undefined` again once the file changes. `null` is a result like any other - it's a non-Haste file's Haste name, for example - so what a worker returns is stored with `undefined` replaced by `null`.

Making that hold needed one fix. The crawlers and the watcher created file metadata with the first plugin slot already set to `null`, a relic of when that slot was the Haste name. So an unprocessed file was `null` to the first plugin and `undefined` to any other. They now leave plugin slots unset.

On-demand processing bypasses two rules that apply to batches - the plugin's filter, and the exclusion of plugin workers from files in `node_modules` - because the plugin asked for this file. It writes only that plugin's slot, and doesn't mark the file as visited, since the other workers haven't seen it.

`FileDataPlugin` takes `lazy`, gains `processFile`, and is now exported, so that a plugin can be defined outside `metro-file-map` without reaching into private paths.

This revives the lazy mode from #1612, on top of the plugin machinery that has landed since.

## Test plan
```
yarn jest packages/metro-file-map packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests
yarn flow check
yarn typecheck-ts
yarn verify-api-snapshots
```
 - `FileProcessor-test.js`: a lazy plugin is left out of a batch and its filter isn't called, a file matched only by a lazy plugin isn't processed at all, and `processFileForPlugin` runs just that plugin in-band for a `node_modules` file, writes only its slot, and stores `null` for `undefined`.
 - `index-test.js`: end to end with a real worker - plugin data is `undefined` after the crawl, `processFile` stores it and emits `metadata`, and a change to the file leaves it `undefined` again.
 - `FileDataPlugin-test.js`: `lazy` reaches the worker description, and `processFile` throws before initialisation and delegates after.
 - The crawler tests now expect fresh metadata without a pre-filled plugin slot. Metadata for files already visited keeps its `null`, which is a real result there.

Changelog: Internal
robhogan added a commit that referenced this pull request Sep 24, 2026
## Summary
A file map plugin with a worker has it run on every file its `filter` accepts, as files are crawled and as they change. That's right for something like dependency extraction, where nearly every file is needed, but it rules out plugins where only a small, unpredictable subset of files is ever used. Parsing `package.json` is the motivating case - on our benchmark app a build reads about 9% of the `package.json` files in the file map (93 of 1,045).

This adds `lazy` to a plugin's worker description. A lazy plugin's worker isn't run at crawl time or on change, and its `filter` isn't consulted. Instead the plugin is given `processFile(mixedPath)` when it's initialised, which runs its worker on that one file - synchronously, in-band - stores the result as the file's plugin data, and returns it. It also emits `metadata`, as lazy SHA-1 does, so the cache knows there's something to save and processed files stay processed across restarts.

The plugin decides when to call it. Plugin data is `undefined` for a file until a worker has run on it, and is `undefined` again once the file changes. `null` is a result like any other - it's a non-Haste file's Haste name, for example - so what a worker returns is stored with `undefined` replaced by `null`.

Making that hold needed one fix. The crawlers and the watcher created file metadata with the first plugin slot already set to `null`, a relic of when that slot was the Haste name. So an unprocessed file was `null` to the first plugin and `undefined` to any other. They now leave plugin slots unset.

On-demand processing bypasses two rules that apply to batches - the plugin's filter, and the exclusion of plugin workers from files in `node_modules` - because the plugin asked for this file. It writes only that plugin's slot, and doesn't mark the file as visited, since the other workers haven't seen it.

`FileDataPlugin` takes `lazy`, gains `processFile`, and is now exported, so that a plugin can be defined outside `metro-file-map` without reaching into private paths.

This revives the lazy mode from #1612, on top of the plugin machinery that has landed since.

## Test plan
```
yarn jest packages/metro-file-map packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests
yarn flow check
yarn typecheck-ts
yarn verify-api-snapshots
```
 - `FileProcessor-test.js`: a lazy plugin is left out of a batch and its filter isn't called, a file matched only by a lazy plugin isn't processed at all, and `processFileForPlugin` runs just that plugin in-band for a `node_modules` file, writes only its slot, and stores `null` for `undefined`.
 - `index-test.js`: end to end with a real worker - plugin data is `undefined` after the crawl, `processFile` stores it and emits `metadata`, and a change to the file leaves it `undefined` again.
 - `FileDataPlugin-test.js`: `lazy` reaches the worker description, and `processFile` throws before initialisation and delegates after.
 - The crawler tests now expect fresh metadata without a pre-filled plugin slot. Metadata for files already visited keeps its `null`, which is a real result there.

Changelog: Internal
robhogan added a commit that referenced this pull request Sep 24, 2026
## Summary
A file map plugin with a worker has it run on every file its `filter` accepts, as files are crawled and as they change. That's right for something like dependency extraction, where nearly every file is needed, but it rules out plugins where only a small, unpredictable subset of files is ever used. Parsing `package.json` is the motivating case - on our benchmark app a build reads about 9% of the `package.json` files in the file map (93 of 1,045).

This adds `lazy` to a plugin's worker description. A lazy plugin's worker isn't run at crawl time or on change, and its `filter` isn't consulted. Instead the plugin is given `processFile(mixedPath)` when it's initialised, which runs its worker on that one file - synchronously, in-band - stores the result as the file's plugin data, and returns it. It also emits `metadata`, as lazy SHA-1 does, so the cache knows there's something to save and processed files stay processed across restarts.

The plugin decides when to call it. Plugin data is `undefined` for a file until a worker has run on it, and is `undefined` again once the file changes. `null` is a result like any other - it's a non-Haste file's Haste name, for example - so what a worker returns is stored with `undefined` replaced by `null`.

Making that hold needed one fix. The crawlers and the watcher created file metadata with the first plugin slot already set to `null`, a relic of when that slot was the Haste name. So an unprocessed file was `null` to the first plugin and `undefined` to any other. They now leave plugin slots unset.

On-demand processing bypasses two rules that apply to batches - the plugin's filter, and the exclusion of plugin workers from files in `node_modules` - because the plugin asked for this file. It writes only that plugin's slot, and doesn't mark the file as visited, since the other workers haven't seen it.

`FileDataPlugin` takes `lazy`, gains `processFile`, and is now exported, so that a plugin can be defined outside `metro-file-map` without reaching into private paths.

This revives the lazy mode from #1612, on top of the plugin machinery that has landed since.

## Test plan
```
yarn jest packages/metro-file-map packages/metro/src/node-haste packages/metro/src/DeltaBundler packages/metro/src/integration_tests
yarn flow check
yarn typecheck-ts
yarn verify-api-snapshots
```
 - `FileProcessor-test.js`: a lazy plugin is left out of a batch and its filter isn't called, a file matched only by a lazy plugin isn't processed at all, and `processFileForPlugin` runs just that plugin in-band for a `node_modules` file, writes only its slot, and stores `null` for `undefined`.
 - `index-test.js`: end to end with a real worker - plugin data is `undefined` after the crawl, `processFile` stores it and emits `metadata`, and a change to the file leaves it `undefined` again.
 - `FileDataPlugin-test.js`: `lazy` reaches the worker description, and `processFile` throws before initialisation and delegates after.
 - The crawler tests now expect fresh metadata without a pre-filled plugin slot. Metadata for files already visited keeps its `null`, which is a real result there.

Changelog: Internal
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
@robhogan robhogan changed the title PackageJsonWorker Cache package.json contents in the file map Oct 16, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. fb-exported meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant