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.
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 -
WatchmanWatcherasks for a freshclockand subscribessincethat, 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.
runServerresolves before the file map is built, and anything that generates or copies files alongsidemetro start(codegen,tsc --watch, asset copying viaconcurrently) is racing it. I think that's what the original report in #404 was: assets copied by a--watchscript 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:On a ~2,900 file project, times in ms from
t0, crawl and watcher start fromDEBUG=Metro:Watcher*:(The Watchman gap runs ~50ms past "watcher start" because that's logged before the
clock+subscriberound 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
sincethe 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 anaddOrModifywith fresher metadata.@expo/metro-file-maphas the same ordering and the same fresh-clock subscription, so a fix will want porting there too.