Skip to content

feat(builders): make watching npm-linked shared deps opt-in - #131

Merged
Aukevanoost merged 6 commits into
mainfrom
issues/130
Aug 29, 2026
Merged

feat(builders): make watching npm-linked shared deps opt-in#131
Aukevanoost merged 6 commits into
mainfrom
issues/130

Conversation

@Aukevanoost

@Aukevanoost Aukevanoost commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Plumbs core's new watchLinkedDeps option through both builders, and replaces core's built-in poll with watchpack.

Refs #130 (already closed by the core-side fix — this is the adapter half).

Why

The npm link support added in 4.3.2 decided a shared dependency was a live dev checkout by asking whether node_modules/<pkg> was a symlink. Under pnpm's default linker every dependency is one, so the whole graph was treated as linked and ng serve never finished starting.

Core 4.5.0 fixes the detection (a symlink counts only when its real path sits outside every node_modules tree) and, separately, makes the watch opt-in via a new watchLinkedDeps federation option that defaults to false. Neither builder built that field into its options object, so linkedSharedDirs returned [] and nobody could turn the watch back on.

What's in here

  • watchLinkedDeps on both buildersschema.json + schema.d.ts for :build and :remote, passed into normalizeFederationOptions beside watch. Defaults to false.
  • A watchpack-backed WatchPort (src/utils/watchpack-watch.ts), wired into createNfWatcher in build/builder.ts and remote/change-watcher.ts. Core's poll is dependency-free but re-walks the tree every 300 ms; watchpack is event-driven, and unlike a raw fs.watch it survives having a watched directory swapped out — which is exactly what ng build <lib> does to dist, and why core polls rather than using fs.watch itself. It also picks up a directory that only appears after the watch starts, which fs.watch cannot — the case where you start ng serve before the library's first build.
  • README — the "Developing npm linked shared libraries" section now covers the new detection rule, the opt-in, and that preserveSymlinks is not the switch for this.

What the option actually gates

Worth being precise, because the obvious reading is too strong in both directions.

watchLinkedDeps: false does not mean "edits to the linked library are ignored". A library's .d.ts is a TypeScript program input, and the adapter resolves it to its real path, which by definition lies outside node_modules — so it is already in the watch set through federationSourceFiles, flag or no flag. An ng-packagr rebuild rewrites dist/*.d.ts alongside the .js, that lands in modifiedFiles, and rebuildAffectedExternals matches it against the package dir and re-bundles. In practice most linked-library workflows keep live reload with the flag off. What the flag adds is coverage of changes that touch no watched TypeScript input — a .js-only edit, a rebuild that leaves the emitted types byte-identical.

Correctness is not gated, but "the next build" means a cold one. linkedContentSignals runs unconditionally inside bundleShared, so a fresh ng build always re-bundles a changed linked library. Inside a running ng serve it is weaker: rebuildAffectedExternals returns early when no modified file falls under the package dir, before bundleShared is ever reached. So with the flag off, a .js-only change to a linked library can stay stale in the dev server across arbitrarily many unrelated rebuilds, until the server restarts or a cold build runs. That is core's shape, not something this PR introduces, and it is exactly the hole the flag closes — but it is not "never served from a stale cache".

The recursive: false and poll decisions

opts.recursive: false is honoured through watchpack's ignored predicate, but only in what it reports, not in how far it descends. Watchpack watches the subtree either way; the predicate collapses a deep change onto its depth-1 ancestor. Probing a depth-3 modification that left every parent directory's mtime untouched:

watchpackWatch, recursive: false  ->  [ 'a' ]
fs.watch,       recursive: false  ->  []

This is safe as things stand, because core's only non-recursive watch is the file-directory watch, which filters every event through trackedFiles — a collapsed ancestor path is simply dropped. It is not, however, an fs.watch-equivalent cap, and a future core change that used a non-recursive directory watch would see the collapsed path. Flagging it rather than burying it.

opts.poll is honoured rather than ignored. The CPU win comes from the source-file watches, which arrive with no poll hint; linked dirs are small, and core's supersede() assumes a polled directory watch really is inode-replacement-safe, so dropping the hint there would be the one place a missed rename-replace never gets re-covered.

One consequence of the seam worth knowing: watchpackWatch never throws, including on a path that does not exist. Core's try/catch around watch() and its watchFailed() warning are therefore unreachable on this path — on inotify exhaustion watchpack prints its own Watchpack Error (watcher): … and treats the directory as removed, so a watch dies with a different message than before. No unhandled-error-event risk: DirectoryWatcher.onWatcherError absorbs it.

Behaviour change

This turns off live-reload for the subset of linked-library changes that touch no watched TypeScript input — most visibly a .js-only edit — which shipped on in 22.0.6. Nothing breaks and no types change, but anyone relying on it will find it stopped until they set watchLinkedDeps: true. Worth an explicit release note.

Verification

6 new tests in src/utils/watchpack-watch.spec.ts cover the adapter, including the directory-swap case. Full suite: 188 passing. tsc --noEmit, eslint and knip clean.

Also run end to end against angular-examples/angular/simple on pnpm's default isolated linker (27 of 27 dependencies symlinked — the exact shape that regressed in #130), with the local dist/ installed through a file: override so core 4.5.0-next.1 and watchpack 2.5.2 resolve for real. The linked dependency is a package carrying an ordinary semver range in package.json and a hand-made symlink in node_modules whose real path resolves outside every node_modules tree; its build script writes a staging directory and mvs it over dist/, reproducing ng-packagr's inode replacement rather than rewriting in place.

Check Result
ng serve on pnpm-default, flag off Ready in 14.1 s; idle 0.0% of one core over 30 s
ng serve, flag on Ready in 15.2 s; idle 0.0% of one core over 20 s
Flag off, .js-only edit in the linked dist No rebuild — the intended new default
Flag off, full ng-packagr-shaped rebuild (.d.ts + .js) Still rebuilds and re-bundles, via the .d.ts TypeScript input
Flag off, .js-only edit, then an unrelated source edit Rebuild runs but does not re-bundle; dev server keeps serving the old chunk
Flag off, then a cold ng build Picks the change up — correctness is not gated
Flag on, .js-only edit in the linked dist Re-bundles; importmap repoints; served chunk carries the new content
Flag on, two consecutive dist directory swaps Both detected, and the watch is still live after each
Flag on, touch an unrelated registry dependency No rebuild — the watch set stays bounded to the real checkout
Ordinary source edit Rebuild in 0.7–0.8 s via the watchpack seam
watchLinkedDeps on :build and on :serve; ng build mfe1 Accepted, builds clean

Not covered: the :remote builder has no end-to-end exercise here (this workspace has no :remote targets), so it rests on the shared watchpackWatch unit tests. The watchpack-versus-core-poll CPU comparison was not A/B'd against main either — the case for the swap is the directory-swap survival above, which is measured.

Core 4.5.0 gates linkedSharedDirs behind a new watchLinkedDeps federation
option that defaults to false. Under pnpm's default linker every dependency
is a symlink, so the npm-link watch added in 4.3.2 treated the whole graph as
linked: startup stalled on checksum walks and an idle ng serve kept polling.
Neither builder built that field into its options object, so linkedSharedDirs
returned [] and nobody could turn the watch back on.

Correctness never depended on the watch -- linked content signals still run
unconditionally, so a rebuilt linked library re-bundles on the next build
regardless. The option only buys live reload.

Also supply watchpack as the watch implementation. Core's built-in poll is
dependency-free but re-walks the tree every 300ms; watchpack is event-driven
and survives having a watched directory swapped out, which is what
`ng build <lib>` does to dist and what a raw fs.watch silently dies on.

Refs #130
Detection now keys off a symlink whose real path resolves outside every
node_modules tree, so a package manager's own symlinks no longer count as
linked, and watching is opt-in via watchLinkedDeps. Also spell out that the
option does not gate correctness, and that preserveSymlinks is not the switch
for it -- it changes esbuild's resolution and invites duplicate singletons.

Refs #130
The port's JSDoc claimed watchpack replaces a per-interval tree walk, but
core always passes `{ poll: true }` for linked dirs and watchpack then does
the same readdir+lstat sweep per directory. Its real advantage is surviving
the `dist` swap `ng build <lib>` does, which is what the comment now says.

`aggregateTimeout: 0` only gated the `aggregated` event, which nothing
subscribes to; `change`/`remove` fire synchronously either way. Dropped.

Documented why `poll` must be forwarded (without it the swap is reported
once and the watch dies on the deleted inode) and why `followSymlinks`
stays false while Angular threads `preserveSymlinks` into it.

The README claimed `preserveSymlinks` is unrelated to this. Angular in fact
uses it as exactly this switch, skipping its `**/node_modules/**` ignore so
`npm link` keeps working; it overloads one flag because its resolver picks
the path esbuild sees. Keep the guidance, fix the reason - and note that
turning it on would shrink the watch set, since paths with a node_modules
segment are skipped.
Three spots claimed more than the code does.

The depth-1 `ignored` predicate does not stop watchpack descending — it only
collapses a deeper change onto its depth-1 ancestor, which core's file watch
then drops as untracked. fs.watch reports nothing there; this reports the
ancestor. The test asserted only that no nested path appeared, so it passed
while the ancestor event fired unremarked; it now pins that behaviour from two
levels down, where a parent mtime cannot explain it.

"Correctness is not affected" held for a cold build but not inside ng serve:
rebuildAffectedExternals returns before bundleShared when no modified file
falls under the package dir, so a JavaScript-only change can stay stale until
a restart. And off is not "no reload" either — a library's .d.ts is a
TypeScript input resolved outside node_modules, so an ng-packagr rebuild is
already picked up without the option.
watchLinkedDeps defaults to false, so anyone who npm-links a shared library
loses the reload that shipped in 22.0.6 with nothing said. That reads as a
broken build rather than a default, since a linked library and a registry one
look identical from the host.

A watching build now names what it found once at startup. linkedSharedDirs
returns [] when the option is off, so ask it again with the option forced on:
the walk behind it is findDepPackageJson + realpath + lstat per shared key,
not the recursive content walk that made #130 slow. Silent when nothing is
linked, when the option is already on, and for a package manager's own
symlinks -- a pnpm tree stays quiet.
The hint lived here and was called from both builders, which meant every other
adapter had to reimplement it. Core now emits it from buildForFederation, so the
builders drop their call and this workspace keeps no copy of the logic.

The message loses "on this target" with the move; core has no notion of targets.
@Aukevanoost
Aukevanoost merged commit dc8a5af into main Aug 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant