From c3cde703d5f407487ef21abd8ec94f070b254edf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Arif=20=C5=9Ei=C5=9Fman?= Date: Wed, 29 Jul 2026 01:05:37 +0300 Subject: [PATCH 1/3] fix(builder): watch the files the federation build actually tracked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #94. --- src/builders/build/builder.ts | 60 ++++++++++++++++++++++++---------- src/builders/remote/builder.ts | 32 +++++++++++++----- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 0ed503e..81eb22f 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -425,6 +425,38 @@ export async function* runBuilder( const isUnderLinkedDir = (p: string): boolean => linkedDirs.some((d) => p === d || p.startsWith(d + path.sep)); + // Angular's SourceFileCache extends Map but keeps what it actually tracked in + // `typeScriptFileCache` (.ts) and `referencedFiles` (templates/styles); the + // outer Map stays empty. Reading only `keys()` therefore watches NOTHING, so + // shared-mapping and exposed sources never invalidate and the dev server keeps + // serving stale bundles until it is restarted. + const federationSourceFiles = (cache: { + keys(): IterableIterator; + typeScriptFileCache?: Map; + referencedFiles?: readonly string[]; + }): string[] => + [ + ...new Set([ + ...cache.keys(), + ...(cache.typeScriptFileCache?.keys() ?? []), + ...(cache.referencedFiles ?? []), + ]), + ].filter((file) => !file.includes("node_modules")); + + const federationWatchedFiles = new Set(); + const syncFederationWatcher = (): void => { + if (!nfWatcher) return; + const files = federationSourceFiles( + normalized.options.federationCache.bundlerCache, + ); + for (const file of files) federationWatchedFiles.add(path.normalize(file)); + syncNfFileWatcher( + nfWatcher, + { keys: () => files[Symbol.iterator]() }, + linkedDirs, + ); + }; + // watcherRef lets onChange reach the watcher without a const self-reference. const watcherRef: { current?: NfFileWatcher } = {}; const nfWatcher: NfFileWatcher | undefined = watch @@ -433,10 +465,16 @@ export async function* runBuilder( debounceMs: 100, onChange: (p) => { // Core stops filling the dirty buffer once onChange is set, so refill it - // here (Set.add stays idempotent if core is later fixed). Only wake the - // loop for linked edits; others ride the next Angular-driven rebuild. + // here (Set.add stays idempotent if core is later fixed). Wake the loop + // for edits the Angular-driven rebuild will NOT cover: linked dirs and + // the federation's own tracked sources, which are externals to the app + // build and so never reach Angular's rebuild iterator. watcherRef.current?.mutate((s) => s.add(p)); - if (isUnderLinkedDir(p)) notifyChange(); + if ( + isUnderLinkedDir(p) || + federationWatchedFiles.has(path.normalize(p)) + ) + notifyChange(); }, }) : undefined; @@ -474,13 +512,7 @@ export async function* runBuilder( await adapter.dispose("mapping-or-exposed").catch(() => undefined); } - if (nfWatcher) { - syncNfFileWatcher( - nfWatcher, - normalized.options.federationCache.bundlerCache, - linkedDirs, - ); - } + syncFederationWatcher(); const hasLocales = i18n?.locales && Object.keys(i18n.locales).length > 0; if (hasLocales && localeFilter) { @@ -582,13 +614,7 @@ export async function* runBuilder( signal, ); - if (nfWatcher) { - syncNfFileWatcher( - nfWatcher, - normalized.options.federationCache.bundlerCache, - linkedDirs, - ); - } + syncFederationWatcher(); if (signal?.aborted) { throw new AbortedError("[builder] After federation build."); diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index 482e267..f72c4f3 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -135,13 +135,33 @@ export async function* runRemoteBuilder( await copyAllAssets(assetEntries, absoluteBrowserOutput, context.workspaceRoot); - if (changeWatcher) { + // Angular's SourceFileCache extends Map but keeps what it actually tracked in + // `typeScriptFileCache` (.ts) and `referencedFiles` (templates/styles); the + // outer Map stays empty. Reading only `keys()` therefore watches NOTHING, so + // shared-mapping and exposed sources never invalidate and the dev server keeps + // serving stale bundles until it is restarted. + const syncFederationWatcher = (): void => { + if (!changeWatcher) return; + const cache = normalized.options.federationCache.bundlerCache as { + keys(): IterableIterator; + typeScriptFileCache?: Map; + referencedFiles?: readonly string[]; + }; + const files = [ + ...new Set([ + ...cache.keys(), + ...(cache.typeScriptFileCache?.keys() ?? []), + ...(cache.referencedFiles ?? []) + ]) + ].filter((file) => !file.includes('node_modules')); syncNfFileWatcher( changeWatcher.watcher, - normalized.options.federationCache.bundlerCache, + { keys: () => files[Symbol.iterator]() }, linkedDirs ); - } + }; + + syncFederationWatcher(); const rebuildQueue = new RebuildQueue(); @@ -197,11 +217,7 @@ export async function* runRemoteBuilder( // remain in pendingPaths and will drive the next iteration. for (const p of changedFiles) changeWatcher.pendingPaths.delete(p); - syncNfFileWatcher( - changeWatcher.watcher, - normalized.options.federationCache.bundlerCache, - linkedDirs - ); + syncFederationWatcher(); if (signal?.aborted) { throw new AbortedError('[remote-builder] After federation build.'); From 4fb161d04438426b1ac0cf5be1ab2dbfdc889996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Arif=20=C5=9Ei=C5=9Fman?= Date: Sun, 2 Aug 2026 23:41:54 +0300 Subject: [PATCH 2/3] refactor(builder): address review on federation watch fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/builders/build/builder.ts | 59 ++++++++++++----- src/builders/remote/builder.ts | 30 ++++----- src/utils/federation-source-files.spec.ts | 80 +++++++++++++++++++++++ src/utils/federation-source-files.ts | 40 ++++++++++++ 4 files changed, 174 insertions(+), 35 deletions(-) create mode 100644 src/utils/federation-source-files.spec.ts create mode 100644 src/utils/federation-source-files.ts diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 81eb22f..ddac887 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -46,6 +46,10 @@ import { import { type Plugin, type PluginBuild } from "esbuild"; import { devHostInstancesPlugin } from "../../plugin/dev-host-instances-plugin.js"; import { checkForInvalidImports } from "./../../utils/check-for-invalid-imports.js"; +import { + describeFederationCache, + federationSourceFiles, +} from "./../../utils/federation-source-files.js"; import { federationBuildNotifier } from "./federation-build-notifier.js"; import type { NfBuilderSchema, NfInternalOptions } from "./schema.js"; import { createAngularBuildAdapter } from "../../tools/esbuild/angular-esbuild-adapter.js"; @@ -295,6 +299,16 @@ export async function* runBuilder( const start = process.hrtime(); logger.measure(start, "To load the federation config."); + // Which TS compilation path the build takes is decided by module-load order: + // setup-builder-env-variables.ts sets NG_BUILD_PARALLEL_TS=0, but + // @angular/build captures it in a module-level const, so anything importing + // @angular/build first (common under Nx) wins. Pair this with the + // "SourceFileCache tracked files" line to see which path actually ran: + // outer=0 with typeScript>0 means the parallel path despite the env value. + logger.verbose( + `NG_BUILD_PARALLEL_TS=${process.env["NG_BUILD_PARALLEL_TS"] ?? "(unset)"}`, + ); + const externals = getExternals(normalized.config); // Realpath'd dirs of npm-linked shared packages (`[]` if none, making the @@ -408,6 +422,11 @@ export async function* runBuilder( let first = true; + // Set when a watcher-driven federation rebuild completed and no Angular output + // has been consumed since; lets the loop skip the redundant Angular-driven + // rebuild that follows an ordinary save (the watcher usually wins that race). + let federationFresh = false; + // A linked shared-package edit never makes Angular's iterator emit (it's an external), // so we wake the watch loop directly: notifyChange resolves changeSignal, which the // loop races against Angular's next output to drive a federation-only rebuild. @@ -425,27 +444,14 @@ export async function* runBuilder( const isUnderLinkedDir = (p: string): boolean => linkedDirs.some((d) => p === d || p.startsWith(d + path.sep)); - // Angular's SourceFileCache extends Map but keeps what it actually tracked in - // `typeScriptFileCache` (.ts) and `referencedFiles` (templates/styles); the - // outer Map stays empty. Reading only `keys()` therefore watches NOTHING, so - // shared-mapping and exposed sources never invalidate and the dev server keeps - // serving stale bundles until it is restarted. - const federationSourceFiles = (cache: { - keys(): IterableIterator; - typeScriptFileCache?: Map; - referencedFiles?: readonly string[]; - }): string[] => - [ - ...new Set([ - ...cache.keys(), - ...(cache.typeScriptFileCache?.keys() ?? []), - ...(cache.referencedFiles ?? []), - ]), - ].filter((file) => !file.includes("node_modules")); - + // Watch what the federation compilation actually tracked — where the cache + // records it depends on the TS compilation path; see federationSourceFiles. const federationWatchedFiles = new Set(); const syncFederationWatcher = (): void => { if (!nfWatcher) return; + logger.verbose( + describeFederationCache(normalized.options.federationCache.bundlerCache), + ); const files = federationSourceFiles( normalized.options.federationCache.bundlerCache, ); @@ -689,6 +695,7 @@ export async function* runBuilder( changeSignal, ); if (trackResult.type === "completed" && !trackResult.result.cancelled) { + federationFresh = trackResult.result.success; yield { success: trackResult.result.success }; } continue; @@ -718,6 +725,22 @@ export async function* runBuilder( continue; } + // An ordinary save reaches this loop twice: the file watcher usually wins + // the race (federationWatchedFiles covers most app sources), so the + // federation rebuild already ran, and this Angular output is the same + // save arriving second. With nothing new in the dirty buffer, rerunning + // would only re-link and rewrite identical federation outputs after + // another rebuildDelay — pass the Angular result through instead. The + // flag is consumed either way: it only vouches for the window since the + // last consumed Angular output. + const federationCoversThisOutput = + federationFresh && nfWatcher?.get().size === 0; + federationFresh = false; + if (federationCoversThisOutput) { + yield ngBuildStatus; + continue; + } + const trackResult = await rebuildQueue.track( runFederationRebuild, angularNext, diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index f72c4f3..e479941 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -27,6 +27,10 @@ import { import { createAngularBuildAdapter } from '../../tools/esbuild/angular-esbuild-adapter.js'; import { checkForInvalidImports } from '../../utils/check-for-invalid-imports.js'; +import { + describeFederationCache, + federationSourceFiles +} from '../../utils/federation-source-files.js'; import type { NfRemoteBuilderSchema, NfRemoteInternalOptions } from './schema.js'; import { resolveNgBuilderOptions } from './resolve-ng-options.js'; @@ -95,6 +99,11 @@ export async function* runRemoteBuilder( const start = process.hrtime(); logger.measure(start, 'To load the federation config.'); + // Which TS compilation path the build takes is decided by module-load order + // (see the note in build/builder.ts); pair this with the "SourceFileCache + // tracked files" line to see which path actually ran. + logger.verbose(`NG_BUILD_PARALLEL_TS=${process.env['NG_BUILD_PARALLEL_TS'] ?? '(unset)'}`); + const externals = getExternals(normalized.config); // Realpath'd dirs of npm-linked shared packages (`[]` if none, making the @@ -135,25 +144,12 @@ export async function* runRemoteBuilder( await copyAllAssets(assetEntries, absoluteBrowserOutput, context.workspaceRoot); - // Angular's SourceFileCache extends Map but keeps what it actually tracked in - // `typeScriptFileCache` (.ts) and `referencedFiles` (templates/styles); the - // outer Map stays empty. Reading only `keys()` therefore watches NOTHING, so - // shared-mapping and exposed sources never invalidate and the dev server keeps - // serving stale bundles until it is restarted. + // Watch what the federation compilation actually tracked — where the cache + // records it depends on the TS compilation path; see federationSourceFiles. const syncFederationWatcher = (): void => { if (!changeWatcher) return; - const cache = normalized.options.federationCache.bundlerCache as { - keys(): IterableIterator; - typeScriptFileCache?: Map; - referencedFiles?: readonly string[]; - }; - const files = [ - ...new Set([ - ...cache.keys(), - ...(cache.typeScriptFileCache?.keys() ?? []), - ...(cache.referencedFiles ?? []) - ]) - ].filter((file) => !file.includes('node_modules')); + logger.verbose(describeFederationCache(normalized.options.federationCache.bundlerCache)); + const files = federationSourceFiles(normalized.options.federationCache.bundlerCache); syncNfFileWatcher( changeWatcher.watcher, { keys: () => files[Symbol.iterator]() }, diff --git a/src/utils/federation-source-files.spec.ts b/src/utils/federation-source-files.spec.ts new file mode 100644 index 0000000..cbb346f --- /dev/null +++ b/src/utils/federation-source-files.spec.ts @@ -0,0 +1,80 @@ +import { SourceFileCache } from '@angular/build/private'; +import type ts from 'typescript'; + +import { describeFederationCache, federationSourceFiles } from './federation-source-files.js'; + +function cacheWith(options: { + outer?: readonly string[]; + typeScript?: readonly string[]; + referenced?: readonly string[]; +}): SourceFileCache { + const cache = new SourceFileCache(); + for (const file of options.outer ?? []) { + cache.set(file, {} as ts.SourceFile); + } + for (const file of options.typeScript ?? []) { + cache.typeScriptFileCache.set(file, ''); + } + if (options.referenced) { + cache.referencedFiles = options.referenced; + } + return cache; +} + +describe('federationSourceFiles', () => { + it('unions all three places the cache tracks files in', () => { + // outer Map: in-process TS path; typeScriptFileCache: emitted .ts output; + // referencedFiles: templates and styles (the only home they ever have). + const cache = cacheWith({ + outer: ['/app/in-process.ts'], + typeScript: ['/app/emitted.ts'], + referenced: ['/app/cmp.html', '/app/cmp.scss'], + }); + + expect(federationSourceFiles(cache).sort()).toEqual([ + '/app/cmp.html', + '/app/cmp.scss', + '/app/emitted.ts', + '/app/in-process.ts', + ]); + }); + + it('deduplicates files reported by more than one source', () => { + const cache = cacheWith({ + outer: ['/app/shared.ts'], + typeScript: ['/app/shared.ts'], + referenced: ['/app/shared.ts'], + }); + + expect(federationSourceFiles(cache)).toEqual(['/app/shared.ts']); + }); + + it('drops node_modules entries from every source', () => { + const cache = cacheWith({ + outer: ['/repo/node_modules/dep/index.ts'], + typeScript: ['/repo/node_modules/dep/emit.ts', '/repo/src/kept.ts'], + referenced: ['/repo/node_modules/dep/style.css'], + }); + + expect(federationSourceFiles(cache)).toEqual(['/repo/src/kept.ts']); + }); + + it('handles a cache that never set referencedFiles', () => { + const cache = cacheWith({ typeScript: ['/app/only.ts'] }); + + expect(federationSourceFiles(cache)).toEqual(['/app/only.ts']); + }); +}); + +describe('describeFederationCache', () => { + it('reports the size of each tracking source', () => { + const cache = cacheWith({ + typeScript: ['/app/a.ts', '/app/b.ts'], + referenced: ['/app/a.html'], + }); + + expect(describeFederationCache(cache)).toBe( + 'SourceFileCache tracked files: outer=0, typeScript=2, referenced=1', + ); + }); +}); diff --git a/src/utils/federation-source-files.ts b/src/utils/federation-source-files.ts new file mode 100644 index 0000000..f09ad6c --- /dev/null +++ b/src/utils/federation-source-files.ts @@ -0,0 +1,40 @@ +import type { SourceFileCache } from '@angular/build/private'; + +/** + * Files the federation compilation actually tracked, deduplicated and without + * node_modules — the watch list for federation-only rebuilds. + * + * Where Angular's `SourceFileCache` records a tracked file depends on the + * compilation mode. With in-process type checking (`NG_BUILD_PARALLEL_TS=0`) + * `augmentHostWithCaching` fills the outer Map with parsed `.ts` sources; on + * the default parallel path the type checker runs in a worker with its own + * cache and the outer Map stays empty. On both paths emitted `.ts` output + * lands in `typeScriptFileCache`, and templates/styles are listed only in + * `referencedFiles`. Reading only `keys()` therefore always missed templates + * and styles, and on the parallel path missed every tracked file — so + * shared-mapping and exposed sources never invalidated and the dev server + * kept serving stale bundles until it was restarted. + */ +export function federationSourceFiles(cache: SourceFileCache): string[] { + return [ + ...new Set([ + ...cache.keys(), + ...cache.typeScriptFileCache.keys(), + ...(cache.referencedFiles ?? []), + ]), + ].filter((file) => !file.includes('node_modules')); +} + +/** + * One-line fingerprint of where the cache tracked its files, for diagnosing + * which compilation path a dev server is on: a populated outer Map means + * in-process type checking (`NG_BUILD_PARALLEL_TS=0`); an empty outer Map + * alongside a populated `typeScriptFileCache` means the parallel-TS path. + */ +export function describeFederationCache(cache: SourceFileCache): string { + return ( + `SourceFileCache tracked files: outer=${cache.size}, ` + + `typeScript=${cache.typeScriptFileCache.size}, ` + + `referenced=${cache.referencedFiles?.length ?? 0}` + ); +} From e17fccfd7114d868a6c0683b199fe9c113594bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Arif=20=C5=9Ei=C5=9Fman?= Date: Mon, 3 Aug 2026 02:14:58 +0300 Subject: [PATCH 3/3] fix(builder): drop replayed watch events that carry no content change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/builders/build/builder.ts | 16 +++++- src/builders/remote/builder.ts | 8 ++- src/builders/remote/change-watcher.ts | 9 +++- src/utils/stale-watch-event-filter.spec.ts | 54 ++++++++++++++++++++ src/utils/stale-watch-event-filter.ts | 58 ++++++++++++++++++++++ 5 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 src/utils/stale-watch-event-filter.spec.ts create mode 100644 src/utils/stale-watch-event-filter.ts diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index ddac887..641d363 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -50,6 +50,7 @@ import { describeFederationCache, federationSourceFiles, } from "./../../utils/federation-source-files.js"; +import { createStaleWatchEventFilter } from "./../../utils/stale-watch-event-filter.js"; import { federationBuildNotifier } from "./federation-build-notifier.js"; import type { NfBuilderSchema, NfInternalOptions } from "./schema.js"; import { createAngularBuildAdapter } from "../../tools/esbuild/angular-esbuild-adapter.js"; @@ -446,7 +447,11 @@ export async function* runBuilder( // Watch what the federation compilation actually tracked — where the cache // records it depends on the TS compilation path; see federationSourceFiles. + // staleEvents guards the wide watch list: macOS FSEvents replays events for + // recently-edited files without a content change, and unguarded that replay + // wakes the loop forever (see stale-watch-event-filter.ts). const federationWatchedFiles = new Set(); + const staleEvents = createStaleWatchEventFilter(); const syncFederationWatcher = (): void => { if (!nfWatcher) return; logger.verbose( @@ -455,7 +460,13 @@ export async function* runBuilder( const files = federationSourceFiles( normalized.options.federationCache.bundlerCache, ); - for (const file of files) federationWatchedFiles.add(path.normalize(file)); + for (const file of files) { + const normalizedFile = path.normalize(file); + if (!federationWatchedFiles.has(normalizedFile)) { + federationWatchedFiles.add(normalizedFile); + staleEvents.seed(normalizedFile); + } + } syncNfFileWatcher( nfWatcher, { keys: () => files[Symbol.iterator]() }, @@ -470,6 +481,9 @@ export async function* runBuilder( // Coalesce ng-packagr's atomic multi-write bursts into one rebuild. debounceMs: 100, onChange: (p) => { + // Same-mtime replays never reach the dirty buffer — buffering them + // would rebuild federation outputs for files that did not change. + if (!staleEvents.isRealChange(p)) return; // Core stops filling the dirty buffer once onChange is set, so refill it // here (Set.add stays idempotent if core is later fixed). Wake the loop // for edits the Angular-driven rebuild will NOT cover: linked dirs and diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index e479941..67dc6a5 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -31,6 +31,7 @@ import { describeFederationCache, federationSourceFiles } from '../../utils/federation-source-files.js'; +import { createStaleWatchEventFilter } from '../../utils/stale-watch-event-filter.js'; import type { NfRemoteBuilderSchema, NfRemoteInternalOptions } from './schema.js'; import { resolveNgBuilderOptions } from './resolve-ng-options.js'; @@ -117,8 +118,12 @@ export async function* runRemoteBuilder( projectSourceRoot ); + // staleEvents guards the wide watch list: macOS FSEvents replays events for + // recently-edited files without a content change, and unguarded that replay + // rebuilds forever (see stale-watch-event-filter.ts). + const staleEvents = createStaleWatchEventFilter(); const changeWatcher = nfBuilderOptions.watch - ? createDebouncedChangeWatcher(nfBuilderOptions.rebuildDelay) + ? createDebouncedChangeWatcher(nfBuilderOptions.rebuildDelay, staleEvents.isRealChange) : undefined; if (changeWatcher) { @@ -150,6 +155,7 @@ export async function* runRemoteBuilder( if (!changeWatcher) return; logger.verbose(describeFederationCache(normalized.options.federationCache.bundlerCache)); const files = federationSourceFiles(normalized.options.federationCache.bundlerCache); + for (const file of files) staleEvents.seed(file); syncNfFileWatcher( changeWatcher.watcher, { keys: () => files[Symbol.iterator]() }, diff --git a/src/builders/remote/change-watcher.ts b/src/builders/remote/change-watcher.ts index 257c5cb..52d721a 100644 --- a/src/builders/remote/change-watcher.ts +++ b/src/builders/remote/change-watcher.ts @@ -8,7 +8,10 @@ export interface DebouncedChangeWatcher { dispose: () => void; } -export function createDebouncedChangeWatcher(rebuildDelay: number): DebouncedChangeWatcher { +export function createDebouncedChangeWatcher( + rebuildDelay: number, + isRealChange?: (path: string) => boolean +): DebouncedChangeWatcher { const pendingPaths = new Set(); let notifyChange: () => void = () => {}; @@ -30,6 +33,10 @@ export function createDebouncedChangeWatcher(rebuildDelay: number): DebouncedCha const watcher = createNfWatcher({ onChange: p => { + // Same-mtime replays (macOS FSEvents re-delivers events for recently + // edited files) must not enter pendingPaths: each entry drives a full + // rebuild cycle, so unfiltered replays rebuild forever. + if (isRealChange && !isRealChange(p)) return; pendingPaths.add(p); scheduleNotify(); }, diff --git a/src/utils/stale-watch-event-filter.spec.ts b/src/utils/stale-watch-event-filter.spec.ts new file mode 100644 index 0000000..565e169 --- /dev/null +++ b/src/utils/stale-watch-event-filter.spec.ts @@ -0,0 +1,54 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { createStaleWatchEventFilter } from './stale-watch-event-filter.js'; + +describe('createStaleWatchEventFilter', () => { + let dir: string; + let file: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nf-stale-filter-')); + file = path.join(dir, 'source.ts'); + fs.writeFileSync(file, 'export const a = 1;'); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('drops a replayed event for a seeded, unmodified file', () => { + const filter = createStaleWatchEventFilter(); + filter.seed(file); + + expect(filter.isRealChange(file)).toBe(false); + expect(filter.isRealChange(file)).toBe(false); + }); + + it('passes an event through when the mtime advanced, then drops its replay', () => { + const filter = createStaleWatchEventFilter(); + filter.seed(file); + + const later = new Date(Date.now() + 5_000); + fs.utimesSync(file, later, later); + + expect(filter.isRealChange(file)).toBe(true); + expect(filter.isRealChange(file)).toBe(false); + }); + + it('treats the first event for an unseeded file as real and its replay as stale', () => { + const filter = createStaleWatchEventFilter(); + + expect(filter.isRealChange(file)).toBe(true); + expect(filter.isRealChange(file)).toBe(false); + }); + + it('treats a deleted file as a real change', () => { + const filter = createStaleWatchEventFilter(); + filter.seed(file); + fs.rmSync(file); + + expect(filter.isRealChange(file)).toBe(true); + }); +}); diff --git a/src/utils/stale-watch-event-filter.ts b/src/utils/stale-watch-event-filter.ts new file mode 100644 index 0000000..f464873 --- /dev/null +++ b/src/utils/stale-watch-event-filter.ts @@ -0,0 +1,58 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface StaleWatchEventFilter { + /** Record the current mtime when a file first enters the watch list. */ + seed(file: string): void; + /** True when the event reflects an actual content change (or a deletion). */ + isRealChange(file: string): boolean; +} + +/** + * Drops file-watch events that do not correspond to a content change. + * + * On macOS, FSEvents re-delivers "changed" events for recently modified files + * on a ~30s cadence even when nothing touched them again (mtime and ctime + * stay put). Once the watch list covers every federation-tracked source + * (federationSourceFiles), a single recently-edited file otherwise wakes the + * rebuild loop forever — rebuild, replayed event, rebuild — pinning a core + * until the dev server is stopped. + * + * Comparing the file's mtime against the last value seen lets every real + * save through (its mtime advances) while replays compare equal and are + * dropped before they reach the dirty buffer. A failed stat counts as a real + * change: a deleted or renamed-away file must trigger a rebuild. + */ +export function createStaleWatchEventFilter(): StaleWatchEventFilter { + const mtimes = new Map(); + + const mtimeOf = (file: string): number | null => { + try { + return fs.statSync(file).mtimeMs; + } catch { + return null; + } + }; + + return { + seed(file: string): void { + const key = path.normalize(file); + if (mtimes.has(key)) return; + const mtime = mtimeOf(key); + if (mtime !== null) mtimes.set(key, mtime); + }, + isRealChange(file: string): boolean { + const key = path.normalize(file); + const mtime = mtimeOf(key); + if (mtime === null) { + mtimes.delete(key); + return true; + } + if (mtimes.get(key) === mtime) { + return false; + } + mtimes.set(key, mtime); + return true; + }, + }; +}