diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 0ed503e..641d363 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -46,6 +46,11 @@ 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 { 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"; @@ -295,6 +300,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 +423,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,6 +445,35 @@ export async function* runBuilder( const isUnderLinkedDir = (p: string): boolean => linkedDirs.some((d) => p === d || p.startsWith(d + path.sep)); + // 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( + describeFederationCache(normalized.options.federationCache.bundlerCache), + ); + const files = federationSourceFiles( + normalized.options.federationCache.bundlerCache, + ); + 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]() }, + linkedDirs, + ); + }; + // watcherRef lets onChange reach the watcher without a const self-reference. const watcherRef: { current?: NfFileWatcher } = {}; const nfWatcher: NfFileWatcher | undefined = watch @@ -432,11 +481,20 @@ 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). 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 +532,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 +634,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."); @@ -663,6 +709,7 @@ export async function* runBuilder( changeSignal, ); if (trackResult.type === "completed" && !trackResult.result.cancelled) { + federationFresh = trackResult.result.success; yield { success: trackResult.result.success }; } continue; @@ -692,6 +739,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 482e267..67dc6a5 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -27,6 +27,11 @@ 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 { createStaleWatchEventFilter } from '../../utils/stale-watch-event-filter.js'; import type { NfRemoteBuilderSchema, NfRemoteInternalOptions } from './schema.js'; import { resolveNgBuilderOptions } from './resolve-ng-options.js'; @@ -95,6 +100,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 @@ -108,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) { @@ -135,13 +149,21 @@ export async function* runRemoteBuilder( await copyAllAssets(assetEntries, absoluteBrowserOutput, context.workspaceRoot); - if (changeWatcher) { + // 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; + 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, - normalized.options.federationCache.bundlerCache, + { keys: () => files[Symbol.iterator]() }, linkedDirs ); - } + }; + + syncFederationWatcher(); const rebuildQueue = new RebuildQueue(); @@ -197,11 +219,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.'); 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/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}` + ); +} 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; + }, + }; +}