react / react/metro

Files changed during startup can be missed until restart

Open
#1,954 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
5.6k
Forks
696
Avg merge
8m
Merged PRs (30d)
7

Description

A file created or modified while Metro is starting up can be missed entirely - it doesn't exist as far as Metro is concerned until the next restart. This happens with both Watchman and the native watcher.

FileMap.build() crawls, applies the delta, persists the cache and only then starts the watcher:

https://github.com/react/metro/blob/6dbe97956/packages/metro-file-map/src/index.js#L440-L501

Anything that changes after the crawl has read it but before the watcher is listening isn't seen by either.

With Watchman we get a clock from the crawl, but we don't pass it to the watcher - WatchmanWatcher asks for a fresh clock and subscribes since that, so the gap is crawl end to subscription:

https://github.com/react/metro/blob/6dbe97956/packages/metro-file-map/src/watchers/WatchmanWatcher.js#L120-L141

The native watcher has no clock at all, and only reports events from when it starts.

It doesn't take anything exotic to hit this. runServer resolves before the file map is built, and anything that generates or copies files alongside metro start (codegen, tsc --watch, asset copying via concurrently) is racing it. I think that's what the original report in #404 was: assets copied by a --watch script started at the same time as the dev server, fixed by starting them separately.

Repro on metro@0.87.1, Node 22, macOS - write a new file every 5ms for the first 300ms, then request each one:

const Metro = require('metro');
const fs = require('fs');
const {getDefaultConfig, mergeConfig} = require('metro-config');

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

(async () => {
  fs.rmSync('late', {recursive: true, force: true});
  fs.mkdirSync('late');
  const config = mergeConfig(await getDefaultConfig(__dirname), {
    watchFolders: [__dirname],
    resetCache: true,
    resolver: {useWatchman: process.env.WATCHMAN === '1'},
    server: {port: 18404},
  });

  // Write a new file every 5ms for the first 300ms of startup
  const t0 = Date.now();
  const written = [];
  const timer = setInterval(() => {
    const t = Date.now() - t0;
    if (t > 300) return clearInterval(timer);
    fs.writeFileSync(`late/m${t}.js`, 'module.exports = 1;\n');
    written.push(t);
  }, 5);
  const {httpServer} = await Metro.runServer(config);
  await sleep(3000);

  const missed = [];
  for (const t of written) {
    fs.writeFileSync('entry.js', `require('./late/m${t}.js');\n`);
    await sleep(300);
    const res = await fetch('http://localhost:18404/entry.bundle?platform=ios');
    await res.text();
    if (!res.ok) missed.push(t);
  }
  console.log(`t0=${t0} written=${written.length} missed=${missed.join(' ') || 'none'}`);
  httpServer.close();
  process.exit(0);
})();

On a ~2,900 file project, times in ms from t0, crawl and watcher start from DEBUG=Metro:Watcher*:

native    crawl 9-30,  watcher 35   missed: 23 28
native    crawl 11-32, watcher 37   missed: 18
native    crawl 12-31, watcher 36   missed: 24 29 36
watchman  crawl 76-166, watcher 171  missed: 149 154 167 172 179 185 191 198 203 209 215 222
watchman  crawl 76-166, watcher 171  missed: 155 168 173 179 184 190 196 202 208 213 220
watchman  crawl 86-180, watcher 185  missed: 168 181 185 191 196 203 209 214 221 227 232 239 245

(The Watchman gap runs ~50ms past "watcher start" because that's logged before the clock + subscribe round trip.)

The windows are small here, but they scale with the crawl and with Watchman latency, and there's nothing to tell you a file was missed.

For Watchman the fix should be straightforward - subscribe since the crawl's clock, which #watch() already has. The native watcher is harder without a clock - I think we want to start it before the crawl, buffer its events, and replay them once the crawl result is applied. Replaying an event for a file the crawl already saw is harmless, since it's just an addOrModify with fresher metadata.

@expo/metro-file-map has the same ordering and the same fresh-clock subscription, so a fix will want porting there too.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/metro-file-map/src/index.js at FileMap.build(), then read WatchmanWatcher.js around the linked watcher setup and inspect the native watcher startup path. Reproduce the race with the provided script using both watcher implementations. Done means files changed during the crawl-to-watcher gap are detected without restarting, including the corresponding @expo/metro-file-map behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.