Skip to content

Navigation Menu

Sign in
Sign up

Changes to a symlinked workspace package aren't picked up until a full Metro restart (Fast Refresh and manual reload both serve stale) #1780

Unanswered
Alvi24 asked this question in Q&A
Discussion options

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 pathpackages/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

  1. Start Metro, load app. 2. Rebuildpackages/ui(changesdist/.../index.js; in our casetsdown, batch writes). 3. Fast Refresh and/orrstill 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

  1. 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.
  2. Batch / atomic-rename writesthrough the symlink aren't handled by the since-clock/invalidation path.
  3. 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.
You must be logged in to vote

Replies: 1 comment

Comment options

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:

  1. You get instant HMR without waiting for tsdown to rebuild the package.
  2. You avoid this atomic-write watcher bug entirely.
  3. 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!

You must be logged in to vote
0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
Q&A
Labels
None yet

AltStyle によって変換されたページ (->オリジナル) /