Skip to content

fix(builder): watch the files the federation build actually tracked - #96

Merged
Aukevanoost merged 4 commits into
native-federation:mainfrom
arifsisman:fix/watch-federation-tracked-sources
Aug 3, 2026
Merged

fix(builder): watch the files the federation build actually tracked#96
Aukevanoost merged 4 commits into
native-federation:mainfrom
arifsisman:fix/watch-federation-tracked-sources

Conversation

@arifsisman

@arifsisman arifsisman commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Fixes #94.

Problem

While the dev server runs, edits to shared-mapping libraries and exposed sources never reach the emitted federation bundles. The dev server keeps serving stale code until it is restarted; a hard browser reload does not help.

Root cause

syncNfFileWatcher decides what to watch from the bundler cache:

const files = [...bundlerCache.keys()].filter((k) => !k.includes('node_modules'));

bundlerCache is Angular's SourceFileCache. That class extends Map, but where a tracked file is recorded depends on the compilation mode:

  • On the parallel-TS path the type checker runs in a worker that builds its own SourceFileCache (parallel-compilation.ts never receives the outer one), so the outer Map stays empty.
  • With in-process type checking (NG_BUILD_PARALLEL_TS=0, applied before anything imports @angular/build) augmentHostWithCaching does populate the outer Map with parsed .ts sources.
  • On both paths, emitted .ts output lands in typeScriptFileCache, and templates/styles are listed only in referencedFiles (SourceFileCache.invalidate() itself consults referencedFiles via extraWatchFiles).

So reading only keys() always misses templates and styles, and on the parallel path misses everything.

Which path was the instrumented run on? The parallel one — confirmed with the diagnostics this PR now logs at verbose level (NG_BUILD_PARALLEL_TS at builder start plus the observed cache shape after each watcher sync). On the Nx workspace below the dev server prints:

NG_BUILD_PARALLEL_TS=0
SourceFileCache tracked files: outer=0, typeScript=182, referenced=1743

setup-builder-env-variables.ts did set NG_BUILD_PARALLEL_TS=0, but useParallelTs is a module-level const in @angular/build, and under Nx something imports @angular/build before the builder module does — so the env assignment comes too late and the parallel path runs anyway. That makes part 1 load-bearing for Nx users regardless of the intended default, and it is a second bug worth filing separately (the env var silently not taking effect). useParallelTs is not re-exported through @angular/build/private, so the effective path cannot be read directly; the env value + cache shape pair in the logs is the reliable signal.

There is a second gap on the same path (load-bearing on either compilation path): the rebuild loop is only woken for npm-linked dirs (if (isUnderLinkedDir(p)) notifyChange()). Shared mappings and exposes are externals for the app build, so Angular's own rebuild iterator never emits for them — the "ride the next Angular-driven rebuild" fallback never arrives.

Fix

  1. Feed syncNfFileWatcher a key view over the union of the outer keys(), typeScriptFileCache and referencedFiles — covers both compilation paths. The helper lives in src/utils/federation-source-files.ts, is typed against SourceFileCache (a future Angular rename now breaks the build instead of silently regressing to no watching) and is covered by a spec.
  2. Wake the rebuild loop for those tracked sources as well, not only for linked dirs.
  3. Skip the Angular-driven federation rebuild when a watcher-driven rebuild already completed since the last consumed Angular output and the dirty buffer is empty. Without this, an ordinary save pays twice: the watcher wakes the loop first and rebuilds, then Angular's output for the same save triggers a second full pass — another rebuildDelay plus a re-link that rewrites identical outputs.
  4. Filter watch events through an mtime check before they reach the dirty buffer (src/utils/stale-watch-event-filter.ts, with spec — see below for why this is required, not optional, once the watch list is this wide).

Points 1, 2 and 4 apply to both build/builder.ts and remote/builder.ts; the remote builder has no Angular-output race, so point 3 only applies to build.

Found while verifying: FSEvents replay turns the wide watch list into an infinite rebuild loop

Live-testing this branch on the workspace below surfaced a failure mode the original submission missed. On macOS, FSEvents re-delivers "changed" events for recently modified files on a ~30 s cadence even when nothing touched them again — mtime and ctime stay put. Instrumenting the raw fs.watch callbacks showed the pattern clearly:

21:35:38.896Z change libs/platform/logging/src/index.ts
21:36:09.007Z change libs/platform/logging/src/index.ts   (+30.1 s, no edit)
21:36:39.121Z change libs/platform/logging/src/index.ts   (+30.1 s, no edit)

With every federation-tracked source watched (~1,900 files here), one recently-edited file is enough: the replay wakes the loop, the rebuild resyncs the watcher, the next replay fires again — federation rebuilds run forever and pin a core until the dev server is stopped. The original code never hit this because it effectively watched nothing.

The filter drops any event whose mtime matches the last value seen for that path; a real save advances mtime and passes, and a failed stat (deletion) passes. Watch-list entries are seeded with their current mtime at sync time, so a replay never triggers even one spurious rebuild.

Verification

Original round, measured on a real Nx + Angular 22.0.8 workspace (host + 3 remotes) by writing a marker into a source file while the dev server is running, then checking whether it reaches the emitted bundle:

case before after
shared-mapping .ts edit stale — bundle unchanged, marker absent marker present
exposed screen .html edit stale — rebuild runs, chunk never contains the marker marker present in the exposed chunk

The .html case is the variant described in the issue comment (triggered through exposes rather than sharedMappings). Both go through the same mapping-or-exposed context, so a single fix closes both.

Review round, re-verified end-to-end with the built artifact (all four changes combined) dropped into the consuming workspace's node_modules:

  • A shared-mapping .ts save produced exactly one federation rebuild: the marker reached the emitted _apex_platform_logging.js and the rewritten remoteEntry.json one second after the save, and was served by the dev middleware. No second cycle followed the same save's Angular output (point 3 above at work).
  • With the save leaving the file "recently modified" — the exact FSEvents replay trigger — an 80 s watch window recorded zero further rebuild cycles; before the filter, the same setup rebuilt every ~30 s indefinitely.
  • The diagnostics print the path fingerprint quoted above at builder start.

npm run typecheck, npm run lint (0 errors; warning count identical to the unmodified baseline) and vitest run (16 files / 111 tests) all pass.

syncNfFileWatcher derived its watch list from `bundlerCache.keys()`. That
cache is Angular's SourceFileCache, which extends Map but keeps tracked
files in `typeScriptFileCache` (.ts) and `referencedFiles` (templates and
styles) rather than in the outer Map. Instrumented on a running dev server:
outerMap.size 0, typeScriptFileCache.size 197, referencedFiles.length 3119.

Reading only `keys()` therefore watched nothing, so shared-mapping and
exposed sources never invalidated the cache and the dev server kept serving
stale bundles until restart.

Read the two properties that actually hold the tracked files, and wake the
rebuild loop for them too — they are externals for the app build, so
Angular's own rebuild iterator never emits for them.

Fixes native-federation#94.
@arifsisman
arifsisman force-pushed the fix/watch-federation-tracked-sources branch from d59d16a to c3cde70 Compare July 28, 2026 22:07
@Aukevanoost

Aukevanoost commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Thanks for PR! Have a look at these feedback points though.

1. The root cause holds only on the parallel-TS path — please pin down which one you were on.

Upstream (22.0.x) confirms both halves. With NG_BUILD_PARALLEL_TS=0 the compilation is in-process (private.ts:48-65factory.ts:23, parallel: boolean = useParallelTs; environment-options.ts:155) and its host is wrapped by augmentHostWithCaching (angular-host.ts:54-73, called at 233-235), which does cache.set(fileName, sourceFile) on the outer Map — so there keys() is populated. On the parallel path the worker builds its own SourceFileCache and sourceFileCache is never passed to it (parallel-compilation.ts:48-57), so the outer Map really does stay empty.

useParallelTs is a module-level const, so setup-builder-env-variables.ts only wins if it runs before anything else imports @angular/build. builders.json points straight at src/builders/build/builder, whose first statement is that import — but in an Nx workspace something may well have loaded @angular/build first, which would put you on the parallel path and explain your outerMap.size = 0. Could you log process.env['NG_BUILD_PARALLEL_TS'] and the compilation type at builder start? If that's what happened, part 1 is genuinely load-bearing for Nx users and we have a second bug to file about the env var not taking effect. Either way the flat "the outer Map stays empty" in the description and the code comment needs rewording, since it's only true for one of the two paths.

Worth noting what's load-bearing regardless of path: referencedFiles (templates/styles were never watched → your .html case) and the wake-up change (the shared-mapping .ts case).

2. The widened wake-up doubles dev-feedback latency on common saves.

federationWatchedFiles ends up holding roughly every non-node_modules file the federation compilation touched, so notifyChange() now fires for ordinary app sources too — normal for a sharedMapping the host itself imports. When the watcher wins the race with Angular's output (the usual case, given the 100 ms watcher debounce):

  1. the watcher wakes the loop, runFederationRebuild snapshots the dirty buffer and clears it (builder.ts:605-607);
  2. that rebuild is interrupted only by changeSignal, not by Angular's output, so it runs in full — including the 2000 ms default rebuildDelay;
  3. Angular's output is then consumed and triggers a second runFederationRebuild, with changedFiles = [] and another full rebuildDelay.

The second pass isn't a cold rebuild — invalidate(new Set([])) (angular-esbuild-adapter.ts:141) leaves the TS output intact, so it's an esbuild re-link plus a rewrite of every federation output and the remoteEntry/import map — but it's pure waste, and the two rebuildDelays roughly double the wait after a save. (In the reverse race order the watcher branch finds an empty buffer and no-ops, so no double pass.) That's why the original code woke the loop only for linked dirs.

Can we narrow the wake set to sources reachable from exposes/sharedMappings entry points, or skip the Angular-driven rebuild when the buffer is empty and a federation rebuild already ran since the last Angular output?

3. Type it against SourceFileCache instead of a structural shape.

Both files already import SourceFileCache from @angular/build/private, typeScriptFileCache and referencedFiles are both in its public .d.ts, and the generic already infers that type from createFederationCache(cachePath, new SourceFileCache(cachePath)) — I checked that annotating it and dropping the as cast in remote/builder.ts typechecks. As written, the optional members buy nothing and turn a future Angular rename into a silent regression back to no watching rather than a compile error. And yes please to your offer of pulling federationSourceFiles into src/utils/ with a spec — it also removes the duplicated copy between the two builders.

You can merge main to receive the fixes for the audit

- Extract the duplicated federationSourceFiles helper into src/utils,
  typed against SourceFileCache so a future Angular rename fails the
  build instead of silently regressing to no watching; cover it with a
  spec.
- Reword the path-dependent claim: the outer Map is only guaranteed
  empty on the parallel-TS path — with in-process type checking
  (NG_BUILD_PARALLEL_TS=0) augmentHostWithCaching fills it, while
  templates/styles only ever live in referencedFiles. Log
  NG_BUILD_PARALLEL_TS and the observed cache shape at verbose level so
  the active compilation path is diagnosable at builder start.
- Skip the Angular-driven federation rebuild when a watcher-driven
  rebuild already completed since the last consumed Angular output and
  the dirty buffer is empty: an ordinary save no longer pays a second
  rebuildDelay plus a redundant re-link of identical outputs.
Verifying the widened watch list on a real Nx workspace (macOS) surfaced
a self-sustaining rebuild loop: FSEvents re-delivers "changed" events
for recently modified files on a ~30s cadence with mtime and ctime
untouched. With every federation-tracked source watched, one such file
wakes the rebuild loop, the rebuild resyncs the watcher, the replay
fires again — federation rebuilds run forever and pin a core until the
dev server is stopped.

Filter events through an mtime check before they reach the dirty
buffer: a same-mtime event is a replay and is dropped; a real save
advances mtime and passes; a failed stat (deletion) passes. Watch-list
entries are seeded with their current mtime at sync time so a replay
never triggers even one spurious rebuild. Applied to both builders.
@arifsisman

Copy link
Copy Markdown
Contributor Author

Follow-up with the end-to-end results promised in the description, plus the three review points. Main is merged in.

1. Compilation path pinned down — your Nx load-order hypothesis was right. The builders now log the diagnostics at verbose level, and on the Nx workspace they print:

NG_BUILD_PARALLEL_TS=0
SourceFileCache tracked files: outer=0, typeScript=182, referenced=1743

Parallel path despite the env value: setup-builder-env-variables.ts runs too late under Nx, so part 1 is load-bearing there regardless of the intended default. Description and code comments are reworded to be path-specific, and I'll file the env-var issue separately. (useParallelTs isn't re-exported through @angular/build/private, so the env value + cache shape pair in the logs is the practical way to tell which path ran.)

2. Double rebuild eliminated. A completed watcher-driven rebuild now passes the same save's subsequent Angular output straight through when the dirty buffer is empty (federationFresh in build/builder.ts). Measured live: a shared-mapping .ts save at 02:21:03 produced exactly one federation rebuild — marker present in the emitted _apex_platform_logging.js and the rewritten remoteEntry.json at 02:21:04, served by the dev middleware — with no second cycle afterwards. The remote builder has no Angular-output race, so it only gets the shared helper.

3. Helper extracted and typed. src/utils/federation-source-files.ts, parameter typed as SourceFileCache, structural shape and the as cast in remote/builder.ts dropped, spec added.

New finding from the live verification — the widened watch list needs an event filter. On macOS, FSEvents re-delivers "changed" events for recently modified files on a ~30 s cadence with mtime and ctime untouched. Watching every federation-tracked source turned that into a self-sustaining loop: replay wakes the loop, the rebuild resyncs the watcher, the next replay fires — federation rebuilds ran forever and pinned a core. Raw fs.watch capture:

21:35:38.896Z change libs/platform/logging/src/index.ts
21:36:09.007Z change libs/platform/logging/src/index.ts   (+30.1 s, no edit)
21:36:39.121Z change libs/platform/logging/src/index.ts   (+30.1 s, no edit)

Fixed in the latest commit: events are dropped unless the file's mtime actually advanced (baselines seeded at watcher-sync time; failed stats — deletions — pass through). With the filter, an 80 s watch window right after a save — the exact replay trigger condition — recorded zero spurious rebuilds; both builders are covered and the filter has its own spec.

typecheck, lint (0 errors, warning count identical to baseline) and vitest (16 files / 111 tests) all pass.

@Aukevanoost

Copy link
Copy Markdown
Contributor

Thanks for this!

@Aukevanoost
Aukevanoost merged commit 1f54bd4 into native-federation:main Aug 3, 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.

[native-federation] Dev server serves stale shared-mapping bundles until restart (workspace lib edits never trigger federation rebuild)

2 participants