We turned Watchman off, in a place Metro does not read
A build config line that sets a flag the bundler never looks at fails silently. How a misplaced Watchman setting left iOS builds waiting at the bundle step, and the test we wrote so a generated config proves its own effect.
Some iOS builds on Ubriot stopped at the bundle step and never moved on. The install had finished, the native project was ready, and Metro was waiting. The cause was one line in a config file we generate, and the line looked correct.
Why we replace the Metro config at all
For monorepo apps whose Metro config would not resolve workspace packages on a clean build machine, the worker writes its own metro.config.js into the scoped build copy. It lists the products and packages folders as watch folders and maps a handful of workspace packages straight to their source. The generated file also turns Watchman off. A build sandbox is a poor place for a file-watching daemon: it is a short-lived copy of the repository, nobody is editing files, and a process that has to start and index before the bundler can continue is one more thing that can stall.
The line that did nothing
The generated file contained this:
config.watchman = false;
Metro does not read that key. The bundler takes the setting from the resolver section of its config, so the file kept every other default and Watchman stayed on. Nothing failed. A setting in the wrong place is not an error, it is a statement the program never hears, and the only symptom was a bundle step that waited.
The fix is the same line in the right place:
config.resolver.useWatchman = false;
Builds for the apps that were hanging now get past the bundle step. We are being careful about what that proves: it is the cause we found and removed, and we are not claiming it is the only reason a bundle step can stall.
It is worth saying why this one was easy to miss. A bundle step that waits looks like a slow machine, a big dependency graph or a network stall, and each of those has a more familiar explanation than a config key. We only connected it to Watchman by reading the generated file next to what Metro actually defaults to, and noticing that the default for the setting we thought we had changed was still on.
Why the old code could not have caught it
The config used to be built inline in the iOS build runner, as a long array of template strings joined into one file. You can read a string like that for a week and never notice that it names the wrong key, because the only judge of a config key is the program that loads the config. A test that checks the string contains the word Watchman would have passed on the broken version too.
So we moved the generator into its own function, and the test now does what Metro does. It takes the generated source, runs it as a module with a stand-in for the default Expo config that has Watchman switched on, and reads the value back from the place Metro reads it. If someone moves the line again, the test fails on the value, not on the spelling. A second test confirms that the watch folders and the workspace package mappings are still there, since the function is now the only place they are defined.
What we would check in any generated config
If a tool writes a configuration file for another tool, three habits are worth the small effort. Load the file the way the consuming tool does and assert on the effective value, not on the text. Keep the generator in one function so there is a single place to test. And when you disable something to make builds reliable, verify the disabled state from the consumer's side, because the flag you set and the flag that is read can be different flags.
If a bundle step hangs on a machine you do not sit at, print the resolved Metro config in the build log before the bundle starts. Seeing the final value of each setting you care about takes a minute to add, and it turns a guess about a slow machine into a plain fact about what the bundler was told to do.
If your own Metro config for CI sets Watchman, check which section it sits in. In the Metro version we build against it belongs under resolver.