Skip to content

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

Open
arifsisman wants to merge 1 commit into
native-federation:mainfrom
arifsisman:fix/watch-federation-tracked-sources
Open

fix(builder): watch the files the federation build actually tracked#96
arifsisman wants to merge 1 commit into
native-federation:mainfrom
arifsisman:fix/watch-federation-tracked-sources

Conversation

@arifsisman

Copy link
Copy Markdown

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 it does not keep tracked files in the outer Map — it keeps them in two separate properties:

class SourceFileCache extends Map {
  typeScriptFileCache = new Map();  // .ts paths
  referencedFiles;                   // templates/styles the compiler tracked

SourceFileCache.invalidate() itself consults referencedFiles via extraWatchFiles. So keys() reads the one container that stays empty.

Instrumented on a running dev server (Angular 22.0.8, Nx workspace, host + 3 remotes):

outerMap.size = 0    typeScriptFileCache.size = 197    referencedFiles.length = 3119

197 .ts files and 3119 referenced files (275 of them workspace sources) never enter the watcher. Nothing lands in the dirty buffer, SourceFileCache is never invalidated, and the rebuild reuses stale TS output.

There is a second gap on the same 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 typeScriptFileCache + referencedFiles. The outer keys() is still included, so cache implementations that do populate it keep working.
  2. Wake the rebuild loop for those tracked sources as well, not only for linked dirs.

Applied to both build/builder.ts and remote/builder.ts.

Verification

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.

Verification was also repeated with the built artifact dropped into the consuming workspace's node_modules, not only against source.

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

Note on tests

The changed code lives inside the builder generators and is not unit-testable as-is without extracting a helper module. Happy to pull federationSourceFiles into its own file with a spec if you would prefer that shape.

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

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