-
Notifications
You must be signed in to change notification settings - Fork 693
Environment
| -- | -- metro / metro-resolver | 0.84.3 react-native | 0.86.0 (New Architecture / Fabric) expo / @expo/metro-config | SDK 57.0.4 Package manager | Bun 1.3.14, isolated linker (linker = "isolated") Watchman | 2025年02月17日.00 (installed & used) OS | macOS (Apple Silicon)Summary
In a monorepo, an app consumes an internal workspace package that is symlinked into the app's node_modules and imported through its built output (dist). When that package's files change on disk (a rebuild), the running Metro server does not reflect the change: Fast Refresh doesn't fire, manual reload (r) re-serves the stale bundle, and only killing + restarting the Metro process picks it up.
We understand r doesn't force a filesystem re-crawl by design — the concern is that Metro's watcher/invalidation never registers the change at all, so nothing short of a restart works.
Setup
apps/mobile/ # Metro project root
node_modules/@scope/ui -> ../../../packages/ui # single symlink
packages/ui/
src/... ; dist/components/<name>/index.js # app imports the dist build
package.json exports: "./components/*" -> "./dist/components/*/index.js"
- Import resolves (via symlink) to real path
packages/ui/dist/components/<name>/index.js. watchFoldersincludespackages/ui(Expo'sgetDefaultConfigauto-adds every workspace pkg — verified).- Symlink resolution works; app runs fine on cold start.
Repro
- Start Metro, load app. 2. Rebuild
packages/ui(changesdist/.../index.js; in our casetsdown, batch writes). 3. Fast Refresh and/orr→still runs the old package code. 4. Kill + restart Metro → change appears.
Expected
A change to a file inside a watchFolder — even reached through a node_modules symlink to a workspace package — should invalidate the module and trigger HMR / be served on next reload, without a full restart.
Ruled out
watchFolders (package dir is watched) · Watchman missing (installed/healthy) · symlink resolution (works cold).
Hypotheses for maintainers
- Symlink path-identity mismatch— Watchman reports events under a path spelling that doesn't match the graph's registered (canonicalized real) path, so the delta is dropped.
- Batch / atomic-rename writesthrough the symlink aren't handled by the since-clock/invalidation path.
- Interaction of the two.
Isolation suggestion: with the same setup, do a single plain editor save (no build tool) to a file the app imports. Fails too → pure symlink watch bug; succeeds → the atomic/batch-write-through-symlink path is the trigger. Happy to share a minimal Expo repo.
Workaround
Dev-only resolver.resolveRequest rewriting @scope/* from dist to the absolute packages/ui/src/... path (no node_modules segment) — after which edits hot-reload reliably.
Two notes:
- I kept the reportgeneric(
@scope/ui,tsdown-agnostic wording) so it's a clean upstream repro, not tied to your package names. - I was deliberatelyhonest about the uncertainty— I labeled the mechanism as hypotheses and included an isolation experiment, because I haven't instrumented Metro internals to confirm which of symlink-identity vs. atomic-writes is the culprit. Maintainers respond better to "here's the repro + what I ruled out + a way to narrow it" than to a confident wrong diagnosis. If you run that single-editor-save experiment, tell me the result and I'll tighten the report to a definitive cause.
All reactions
Replies: 1 comment
Thank you for the incredibly detailed and well-isolated bug report! Reports of this quality make investigating edge cases significantly easier.
Based on Metro's historical behavior with Watchman and symlinks, your hypotheses are spot on. Let's break down exactly what is likely happening here:
1. The "Atomic Directory Replacement" Issue (Hypothesis 2)
This is almost certainly the primary culprit. Build tools like tsdown, tsup, and tsc usually clear the dist directory (rm -rf dist) before writing the new bundle.
When a directory is deleted and recreated rapidly, Watchman correctly fires deletion and creation events. However, Metro's internal jest-haste-map (which powers the watcher and dependency graph) has historically struggled with atomic directory replacements. When dist is wiped, Metro invalidates the graph. When the new dist appears milliseconds later, the watcher doesn't always successfully re-bind or map the newly created files back to the symlinked path in the active module cache. Consequently, the delta is dropped, and Metro keeps serving the stale memory cache until a hard restart.
2. The Symlink Path-Identity Mismatch (Hypothesis 1)
This exacerbates the first issue. Watchman watches the real path (packages/ui/dist/...). Metro has to translate that Watchman event back through the symlink to determine if any active modules in apps/mobile/node_modules/... need invalidation. If the directory replacement breaks the canonicalization chain during the split second it doesn't exist, Metro silently ignores the file changes.
The "Single Save" Isolation Experiment
Your suggested experiment is exactly the right next step. If you open packages/ui/dist/components/X/index.js in a text editor and make a single manual edit and hit save, I highly suspect Fast Refresh will work.
If it does, it definitively proves that the symlink resolution is working, and the issue is Metro dropping the invalidation pipeline when tsdown atomically replaces the directory.
The Workaround is Actually the Recommended Pattern
While this is a valid bug in Metro's file-watching pipeline, your workaround—using resolver.resolveRequest (or the exports field combined with a custom conditionNames for dev) to point the bundler directly at packages/ui/src/...—is actually the officially recommended pattern for React Native monorepos.
By bypassing the dist build step entirely during development:
- You get instant HMR without waiting for
tsdownto rebuild the package. - You avoid this atomic-write watcher bug entirely.
- You get much better stack traces because Metro is transforming the raw TypeScript directly.
We would love to see the results of your single-editor-save experiment. If you confirm it works on a single save, we can track this specifically as a jest-haste-map directory replacement bug through symlinks!