From e8369a6f68c7dca6081543dc4a7b8416202f0988 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Mon, 3 Aug 2026 14:34:22 +0200 Subject: [PATCH 1/4] fix(builder): hand replay dedupe to core and watch shared-mapping dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps @softarc/native-federation to ^4.4.0, which takes ownership of replay dedupe and collapses watch handles per directory. - Delete stale-watch-event-filter. Core's dedupe keys on mtime *and* byte size and holds a grace window, so it lets through a second save landing inside one mtime tick; the adapter's mtime-only copy dropped exactly that event on coarse-granularity filesystems (WSL2 drvfs, NFS, gRPC-FUSE, HFS+). Keeping both would run the weaker filter second. - Pass paths to syncNfFileWatcher directly, retiring the { keys() } shim core widened WatchSources to accept. - Drop the dirty-buffer refill in onChange: core now buffers every change unconditionally, including when onChange is set. - Watch sharedMappingDirs, and wake for them too. A file created since the last build is in no compiled-inputs watch set, so registering the watch alone left the change buffered with nothing to consume it — the file is not imported yet, so Angular's iterator never emits for it. - Clear federationFresh beside advanceAngular, so the failed-build and first-output branches can no longer leave it set for an unrelated Angular output. - Extract the trigger decisions into watch-decisions.ts and cover the wake-up and rebuild-skip behaviour with specs. federation-source-files.ts stays: core owns replay dedupe, not the Angular cache expansion that encodes which compilation path recorded the tracked files. --- package.json | 2 +- pnpm-lock.yaml | 80 ++++++----- src/builders/build/builder.ts | 87 +++++------- src/builders/build/watch-decisions.spec.ts | 154 +++++++++++++++++++++ src/builders/build/watch-decisions.ts | 59 ++++++++ src/builders/remote/builder.ts | 25 ++-- src/builders/remote/change-watcher.ts | 7 +- src/utils/stale-watch-event-filter.spec.ts | 54 -------- src/utils/stale-watch-event-filter.ts | 58 -------- 9 files changed, 306 insertions(+), 220 deletions(-) create mode 100644 src/builders/build/watch-decisions.spec.ts create mode 100644 src/builders/build/watch-decisions.ts delete mode 100644 src/utils/stale-watch-event-filter.spec.ts delete mode 100644 src/utils/stale-watch-event-filter.ts diff --git a/package.json b/package.json index c6c53ec..5fc4446 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@angular-devkit/core": "~22.0.0", "@angular-devkit/schematics": "~22.0.0", "@chialab/esbuild-plugin-commonjs": "^0.19.0", - "@softarc/native-federation": "^4.3.2", + "@softarc/native-federation": "^4.4.0", "@softarc/native-federation-orchestrator": "^4.5.2", "es-module-shims": "^2.8.0", "esbuild": "^0.28.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40b210c..2013f9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: ^0.19.0 version: 0.19.1 '@softarc/native-federation': - specifier: ^4.3.2 - version: 4.3.2(typescript@6.0.3) + specifier: ^4.4.0 + version: 4.4.0(typescript@6.0.3) '@softarc/native-federation-orchestrator': specifier: ^4.5.2 version: 4.5.3 @@ -45,7 +45,7 @@ importers: version: 22.0.9(@angular/compiler-cli@22.1.0(@angular/compiler@22.1.0)(typescript@6.0.3))(@angular/compiler@22.1.0)(@types/node@24.10.15)(chokidar@5.0.0)(jiti@2.7.0)(postcss@8.5.24)(supports-color@7.2.0)(tslib@2.8.1)(typescript@6.0.3)(vitest@4.1.10)(yaml@2.9.0) '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.8.0(jiti@2.7.0)) + version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) '@nx/devkit': specifier: ^23.1.0 version: 23.1.0(nx@23.1.0) @@ -60,7 +60,7 @@ importers: version: 4.1.10(vitest@4.1.10) eslint: specifier: ^10.8.0 - version: 10.8.0(jiti@2.7.0) + version: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) globals: specifier: ^17.0.0 version: 17.8.0 @@ -81,7 +81,7 @@ importers: version: 6.0.3 typescript-eslint: specifier: ^8.65.0 - version: 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vite: specifier: ^7.0.0 version: 7.3.6(@types/node@24.10.15)(jiti@2.7.0)(sass@1.99.0)(yaml@2.9.0) @@ -1711,8 +1711,8 @@ packages: resolution: {integrity: sha512-KmEt7IOjycfUVdI+QjNI3dOtzBNHkMJzi8HasGg1eAfo03idNd3qb0JVeX4spxjAhdpGKhytuCudZfVxNtqgsg==, tarball: https://registry.npmjs.org/@softarc/native-federation-orchestrator/-/native-federation-orchestrator-4.5.3.tgz} engines: {node: '>=24.16.0'} - '@softarc/native-federation@4.3.2': - resolution: {integrity: sha512-kRYaAWXNDtj5GURzs0yucPeQ4lODxgnyoUUoNqfM7H4ePIYZFOV+GLu8MkXSHokswOirjSmGAaSZZHkvCT/JIg==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.3.2.tgz} + '@softarc/native-federation@4.4.0': + resolution: {integrity: sha512-uFCcAUSb1XgUxMgNgncC6hf6L2jF2ojmTexhA0sQHj0YeHuVbxPNjbWC+CXwisDMuqOmCsULn59O90P10AE47A==, tarball: https://registry.npmjs.org/@softarc/native-federation/-/native-federation-4.4.0.tgz} '@softarc/sheriff-core@0.19.6': resolution: {integrity: sha512-KACxHG9sS7kNWgnnBODzdr14kMLMrJVlQKc+tViUP03p2fRwNhESOA49bz51Yn7dro1mbtMmmFjICLmZSJDZZA==, tarball: https://registry.npmjs.org/@softarc/sheriff-core/-/sheriff-core-0.19.6.tgz} @@ -2002,6 +2002,10 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==, tarball: https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chalk@6.0.0: + resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==, tarball: https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz} + engines: {node: '>=22'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz} engines: {node: '>= 14.16.0'} @@ -3409,7 +3413,7 @@ snapshots: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 @@ -3491,7 +3495,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-module-imports': 7.29.7 @@ -3708,14 +3712,14 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))': dependencies: - eslint: 10.8.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@7.2.0)': dependencies: '@eslint/object-schema': 3.0.5 debug: 4.4.3(supports-color@7.2.0) @@ -3731,9 +3735,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0))': + '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))': optionalDependencies: - eslint: 10.8.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) '@eslint/object-schema@3.0.5': {} @@ -4459,10 +4463,10 @@ snapshots: dependencies: semver: 7.8.5 - '@softarc/native-federation@4.3.2(typescript@6.0.3)': + '@softarc/native-federation@4.4.0(typescript@6.0.3)': dependencies: '@softarc/sheriff-core': 0.19.6(typescript@6.0.3) - chalk: 5.6.2 + chalk: 6.0.0 esbuild: 0.28.1 fast-glob: 3.3.3 json5: 2.2.3 @@ -4507,15 +4511,15 @@ snapshots: dependencies: undici-types: 7.16.0 - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 - eslint: 10.8.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) @@ -4523,14 +4527,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.65.0 debug: 4.4.3(supports-color@7.2.0) - eslint: 10.8.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -4553,13 +4557,13 @@ snapshots: dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.65.0 '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) debug: 4.4.3(supports-color@7.2.0) - eslint: 10.8.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -4582,13 +4586,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) '@typescript-eslint/scope-manager': 8.65.0 '@typescript-eslint/types': 8.65.0 '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - eslint: 10.8.0(jiti@2.7.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -4800,6 +4804,8 @@ snapshots: chalk@5.6.2: {} + chalk@6.0.0: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -5017,11 +5023,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.8.0(jiti@2.7.0): + eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@7.2.0) '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 @@ -6065,13 +6071,13 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) '@typescript-eslint/typescript-estree': 8.65.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.8.0(jiti@2.7.0) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 4c5bf28..63b2481 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -41,6 +41,7 @@ import { type NfFileWatcher, RebuildQueue, setLogLevel, + sharedMappingDirs, syncNfFileWatcher, } from "@softarc/native-federation/internal"; import { type Plugin, type PluginBuild } from "esbuild"; @@ -50,8 +51,12 @@ 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 { + createFederationFreshness, + shouldRunWatcherRebuild, + shouldWakeFederation, +} from "./watch-decisions.js"; import type { NfBuilderSchema, NfInternalOptions } from "./schema.js"; import { createAngularBuildAdapter } from "../../tools/esbuild/angular-esbuild-adapter.js"; import { getI18nConfig, translateFederationArtifacts } from "./i18n.js"; @@ -426,7 +431,7 @@ export async function* runBuilder( // 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; + const freshness = createFederationFreshness(); // 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 @@ -442,16 +447,10 @@ export async function* runBuilder( changeSignal = new Promise((r) => (notifyChange = r)); changeTagged = changeSignal.then(() => ({ kind: "watcher" as const })); }; - 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( @@ -461,49 +460,39 @@ export async function* runBuilder( normalized.options.federationCache.bundlerCache, ); for (const file of files) { - const normalizedFile = path.normalize(file); - if (!federationWatchedFiles.has(normalizedFile)) { - federationWatchedFiles.add(normalizedFile); - staleEvents.seed(normalizedFile); - } + federationWatchedFiles.add(path.normalize(file)); } - syncNfFileWatcher( - nfWatcher, - { keys: () => files[Symbol.iterator]() }, - linkedDirs, - ); + syncNfFileWatcher(nfWatcher, files, linkedDirs); }; - // watcherRef lets onChange reach the watcher without a const self-reference. - const watcherRef: { current?: NfFileWatcher } = {}; + // sharedMappingDirs is derived from config, not from a build's inputs, so it + // also covers files added to a shared lib since the last build — which a + // compiled-inputs watch set structurally cannot. It has to be in the wake-up + // set as well as the watch set: a new file matches neither federationWatchedFiles + // nor linkedDirs, so without it core buffers the change and nothing consumes it. + const sharedDirs = sharedMappingDirs(normalized.config); + const wakeDirs = [...linkedDirs, ...sharedDirs]; + const nfWatcher: NfFileWatcher | undefined = watch ? createNfWatcher({ // 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 - // 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) || - federationWatchedFiles.has(path.normalize(p)) - ) + // Core has already buffered p (and dropped it if it was a replay); this + // only wakes the loop for edits the Angular-driven rebuild will NOT + // cover — see shouldWakeFederation. + if (shouldWakeFederation(p, federationWatchedFiles, wakeDirs)) { notifyChange(); + } }, }) : undefined; - watcherRef.current = nfWatcher; if (nfWatcher) { - nfWatcher.addPaths( + nfWatcher.addPaths([ path.dirname(path.resolve(context.workspaceRoot, federationTsConfig)), - ); + ...sharedDirs, + ]); } if (fs.existsSync(normalized.options.outputPath)) { @@ -702,7 +691,7 @@ export async function* runBuilder( // only and leave the pending Angular output in flight for the next race. if (trigger.kind === "watcher") { resetChangeSignal(); - if (first || !nfWatcher || nfWatcher.get().size === 0) continue; + if (!shouldRunWatcherRebuild(first, nfWatcher?.get().size)) continue; // Interrupt on the next linked edit so a fresh save folds into one rebuild. const trackResult = await rebuildQueue.track( @@ -710,7 +699,7 @@ export async function* runBuilder( changeSignal, ); if (trackResult.type === "completed" && !trackResult.result.cancelled) { - federationFresh = trackResult.result.success; + freshness.mark(trackResult.result.success); yield { success: trackResult.result.success }; } continue; @@ -725,6 +714,17 @@ export async function* runBuilder( // interrupt for the rebuild below (a fresh Angular build aborts the stale one). advanceAngular(); + // 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. Consumed here, + // beside advanceAngular, so every path below leaves the flag cleared. + const federationCoversThisOutput = freshness.consume( + nfWatcher?.get().size, + ); + if (!ngBuildStatus.success) { logger.warn( "Skipping federation artifacts because Angular build failed.", @@ -740,17 +740,6 @@ 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; diff --git a/src/builders/build/watch-decisions.spec.ts b/src/builders/build/watch-decisions.spec.ts new file mode 100644 index 0000000..5dbd6a4 --- /dev/null +++ b/src/builders/build/watch-decisions.spec.ts @@ -0,0 +1,154 @@ +import * as path from "path"; +import { describe, expect, it } from "vitest"; + +import { + createFederationFreshness, + shouldRunWatcherRebuild, + shouldWakeFederation, +} from "./watch-decisions.js"; + +describe("createFederationFreshness", () => { + it("skips the Angular-driven rebuild when a watcher rebuild covered the save", () => { + // The ordinary save: watcher wins the race, rebuilds, and by the time + // Angular's output arrives core's dirty buffer is empty again. + const freshness = createFederationFreshness(); + freshness.mark(true); + + expect(freshness.consume(0)).toBe(true); + }); + + it("rebuilds when the dirty buffer is non-empty at the Angular output", () => { + const freshness = createFederationFreshness(); + freshness.mark(true); + + expect(freshness.consume(3)).toBe(false); + }); + + it("never skips without a preceding watcher rebuild", () => { + expect(createFederationFreshness().consume(0)).toBe(false); + }); + + it("never skips when a watcher rebuild failed", () => { + const freshness = createFederationFreshness(); + freshness.mark(false); + + expect(freshness.consume(0)).toBe(false); + }); + + it("never skips without a watcher (undefined dirty count)", () => { + const freshness = createFederationFreshness(); + freshness.mark(true); + + expect(freshness.consume(undefined)).toBe(false); + }); + + it("vouches for one Angular output only", () => { + const freshness = createFederationFreshness(); + freshness.mark(true); + + expect(freshness.consume(0)).toBe(true); + expect(freshness.consume(0)).toBe(false); + }); + + // Regression: the flag used to be cleared below the failed-build and + // first-output branches, both of which `continue` — so a rebuild that + // happened before a *failed* Angular build still vouched for the next, + // unrelated output and wrongly skipped it. + it("is cleared by an output it did not cover", () => { + const freshness = createFederationFreshness(); + freshness.mark(true); + + // Angular build failed: consumed with a non-empty buffer, so no skip... + expect(freshness.consume(2)).toBe(false); + // ...and the next output must not inherit the stale flag. + expect(freshness.consume(0)).toBe(false); + }); +}); + +describe("shouldRunWatcherRebuild", () => { + it("runs when paths are buffered after the first build", () => { + expect(shouldRunWatcherRebuild(false, 1)).toBe(true); + }); + + it("does not run on a wake with an empty buffer", () => { + // fs.watch fires several events per save; the ones that arrive after the + // buffer was consumed would otherwise drive a phantom rebuild. + expect(shouldRunWatcherRebuild(false, 0)).toBe(false); + }); + + it("does not run before the first Angular output", () => { + expect(shouldRunWatcherRebuild(true, 5)).toBe(false); + }); + + it("does not run without a watcher", () => { + expect(shouldRunWatcherRebuild(false, undefined)).toBe(false); + }); +}); + +describe("shouldWakeFederation", () => { + const watched = new Set([ + path.normalize("/ws/apps/host/src/app/app.component.ts"), + ]); + const linkedDir = path.normalize("/ws/node_modules/.links/my-lib"); + const sharedDir = path.normalize("/ws/libs/internal/src/logging"); + const wakeDirs = [linkedDir, sharedDir]; + + it("wakes for a federation-tracked source", () => { + expect( + shouldWakeFederation( + "/ws/apps/host/src/app/app.component.ts", + watched, + wakeDirs, + ), + ).toBe(true); + }); + + it("wakes for a file under a linked dir", () => { + expect( + shouldWakeFederation( + path.join(linkedDir, "src", "lib", "thing.ts"), + watched, + wakeDirs, + ), + ).toBe(true); + }); + + it("wakes for a wake dir itself", () => { + expect(shouldWakeFederation(linkedDir, watched, wakeDirs)).toBe(true); + }); + + // The reason sharedMappingDirs is in the wake set: a file created since the + // last build is in no compiled-inputs set, so this is its only wake path. + it("wakes for a newly created file in a shared-mapping dir", () => { + expect( + shouldWakeFederation( + path.join(sharedDir, "audit.service.ts"), + watched, + wakeDirs, + ), + ).toBe(true); + }); + + it("does not wake for an unrelated path", () => { + expect( + shouldWakeFederation("/ws/apps/host/src/styles.css", watched, wakeDirs), + ).toBe(false); + }); + + // A sibling whose name merely starts with a wake dir's name is outside it. + it("does not wake for a wake-dir name prefix", () => { + expect( + shouldWakeFederation(sharedDir + "-other/src/index.ts", watched, wakeDirs), + ).toBe(false); + }); + + it("matches tracked files regardless of path separators in the event", () => { + expect( + shouldWakeFederation( + "/ws/apps/host/src/app/../app/app.component.ts", + watched, + wakeDirs, + ), + ).toBe(true); + }); +}); diff --git a/src/builders/build/watch-decisions.ts b/src/builders/build/watch-decisions.ts new file mode 100644 index 0000000..379c533 --- /dev/null +++ b/src/builders/build/watch-decisions.ts @@ -0,0 +1,59 @@ +import * as path from "path"; + +export interface FederationFreshness { + /** Record the outcome of a watcher-driven federation rebuild. */ + mark(success: boolean): void; + /** + * True when the Angular output being consumed now needs no federation rebuild. + * Clears the flag either way — it only vouches for the window since the last + * consumed Angular output, so it must be consumed with that output rather than + * at the point a rebuild would run. + */ + consume(dirtyPathCount: number | undefined): boolean; +} + +export function createFederationFreshness(): FederationFreshness { + let fresh = false; + + return { + mark(success: boolean): void { + fresh = success; + }, + consume(dirtyPathCount: number | undefined): boolean { + const covers = fresh && dirtyPathCount === 0; + fresh = false; + return covers; + }, + }; +} + +/** + * Whether a watcher wake-up should drive a federation-only rebuild. `undefined` + * means no watcher, so nothing can be pending. + */ +export function shouldRunWatcherRebuild( + first: boolean, + dirtyPathCount: number | undefined, +): boolean { + return !first && dirtyPathCount !== undefined && dirtyPathCount > 0; +} + +/** + * Whether a changed path has to wake the federation rebuild directly, because + * Angular's iterator will not emit for it. + * + * `watchedFiles` is what the last federation build compiled. `wakeDirs` covers + * what that set cannot: linked shared packages (externals to the app build) and + * shared-mapping source dirs, where a *newly created* file is by definition + * absent from any compiled-inputs set and is not yet imported by the app. + */ +export function shouldWakeFederation( + changedPath: string, + watchedFiles: ReadonlySet, + wakeDirs: readonly string[], +): boolean { + const underWakeDir = wakeDirs.some( + (d) => changedPath === d || changedPath.startsWith(d + path.sep), + ); + return underWakeDir || watchedFiles.has(path.normalize(changedPath)); +} diff --git a/src/builders/remote/builder.ts b/src/builders/remote/builder.ts index 67dc6a5..f1a48ba 100644 --- a/src/builders/remote/builder.ts +++ b/src/builders/remote/builder.ts @@ -23,6 +23,7 @@ import { getDefaultCachePath, syncNfFileWatcher, linkedSharedDirs, + sharedMappingDirs, } from '@softarc/native-federation/internal'; import { createAngularBuildAdapter } from '../../tools/esbuild/angular-esbuild-adapter.js'; @@ -31,7 +32,6 @@ 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'; @@ -118,18 +118,18 @@ 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, staleEvents.isRealChange) + ? createDebouncedChangeWatcher(nfBuilderOptions.rebuildDelay) : undefined; if (changeWatcher) { - changeWatcher.watcher.addPaths( - path.dirname(path.resolve(context.workspaceRoot, federationTsConfig)) - ); + // sharedMappingDirs is derived from config, not from a build's inputs, so it + // also covers files added to a shared lib since the last build — which a + // compiled-inputs watch set structurally cannot. + changeWatcher.watcher.addPaths([ + path.dirname(path.resolve(context.workspaceRoot, federationTsConfig)), + ...sharedMappingDirs(normalized.config), + ]); for (const assetDir of getAssetWatchDirs(assetEntries, context.workspaceRoot)) { changeWatcher.watcher.addPaths(assetDir); } @@ -155,12 +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]() }, - linkedDirs - ); + syncNfFileWatcher(changeWatcher.watcher, files, linkedDirs); }; syncFederationWatcher(); diff --git a/src/builders/remote/change-watcher.ts b/src/builders/remote/change-watcher.ts index 52d721a..c3f8871 100644 --- a/src/builders/remote/change-watcher.ts +++ b/src/builders/remote/change-watcher.ts @@ -9,8 +9,7 @@ export interface DebouncedChangeWatcher { } export function createDebouncedChangeWatcher( - rebuildDelay: number, - isRealChange?: (path: string) => boolean + rebuildDelay: number ): DebouncedChangeWatcher { const pendingPaths = new Set(); @@ -33,10 +32,6 @@ export function createDebouncedChangeWatcher( 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 deleted file mode 100644 index 565e169..0000000 --- a/src/utils/stale-watch-event-filter.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index f464873..0000000 --- a/src/utils/stale-watch-event-filter.ts +++ /dev/null @@ -1,58 +0,0 @@ -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; - }, - }; -} From 48dd512b448b027885b370185c94b99b295442c0 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Mon, 3 Aug 2026 16:45:41 +0200 Subject: [PATCH 2/4] fix(builder): compare wake dirs with core's posix-safe isUnderAnyDir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wake-up predicate spliced in `path.sep`, but core delivers watch paths through `toPosix`, so no wake dir could ever match on Windows. Pre-existing for `linkedDirs`; Item 5 routed `sharedMappingDirs` through the same comparison, which would have shipped new-file pickup broken there. Core already exports the predicate for this — `isUnderAnyDir` normalizes both sides, and its docstring exists to stop exactly this splice. The `watchedFiles` lookup keeps `path.normalize`, which does resolve a delivered posix path on Windows. Covered by specs that pass backslash dirs, so the regression reproduces off Windows. --- src/builders/build/watch-decisions.spec.ts | 23 ++++++++++++++++++++++ src/builders/build/watch-decisions.ts | 11 ++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/builders/build/watch-decisions.spec.ts b/src/builders/build/watch-decisions.spec.ts index 5dbd6a4..842a70b 100644 --- a/src/builders/build/watch-decisions.spec.ts +++ b/src/builders/build/watch-decisions.spec.ts @@ -142,6 +142,29 @@ describe("shouldWakeFederation", () => { ).toBe(false); }); + // Regression: the wake dirs arrive with native separators while core delivers + // posix paths, so comparing against `dir + path.sep` was always false on + // Windows. Backslash dirs make that reproducible off Windows. + it("wakes for a posix event path against a backslash wake dir", () => { + expect( + shouldWakeFederation( + "C:/ws/libs/internal/src/logging/audit.service.ts", + new Set(), + ["C:\\ws\\libs\\internal\\src\\logging"], + ), + ).toBe(true); + }); + + it("still rejects a sibling name prefix across separator styles", () => { + expect( + shouldWakeFederation( + "C:/ws/libs/internal/src/logging-other/thing.ts", + new Set(), + ["C:\\ws\\libs\\internal\\src\\logging"], + ), + ).toBe(false); + }); + it("matches tracked files regardless of path separators in the event", () => { expect( shouldWakeFederation( diff --git a/src/builders/build/watch-decisions.ts b/src/builders/build/watch-decisions.ts index 379c533..e1fad5d 100644 --- a/src/builders/build/watch-decisions.ts +++ b/src/builders/build/watch-decisions.ts @@ -1,5 +1,7 @@ import * as path from "path"; +import { isUnderAnyDir } from "@softarc/native-federation/internal"; + export interface FederationFreshness { /** Record the outcome of a watcher-driven federation rebuild. */ mark(success: boolean): void; @@ -46,14 +48,17 @@ export function shouldRunWatcherRebuild( * what that set cannot: linked shared packages (externals to the app build) and * shared-mapping source dirs, where a *newly created* file is by definition * absent from any compiled-inputs set and is not yet imported by the app. + * + * Core's `isUnderAnyDir` rather than a `path.sep` comparison: it delivers posix + * paths, so splicing in the native separator is always false on Windows. */ export function shouldWakeFederation( changedPath: string, watchedFiles: ReadonlySet, wakeDirs: readonly string[], ): boolean { - const underWakeDir = wakeDirs.some( - (d) => changedPath === d || changedPath.startsWith(d + path.sep), + return ( + isUnderAnyDir(changedPath, wakeDirs) || + watchedFiles.has(path.normalize(changedPath)) ); - return underWakeDir || watchedFiles.has(path.normalize(changedPath)); } From e9ec4b185a955aca1c8a7c85def3d682329de452 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Tue, 4 Aug 2026 11:08:30 +0200 Subject: [PATCH 3/4] fix(builder): never wake the federation rebuild for its own output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `sharedMappings` unset core promotes every tsconfig `paths` entry to a mapping, so an entry point near the workspace root makes `sharedMappingDirs` yield a dir containing the federation output. A rebuild writes there with a fresh mtime — not a replay, so core delivers the event — and the wake set turned that write into the next rebuild, forever. Filter the event path against outputPath rather than dropping the dir from wakeDirs: that dir also holds the sources the wake set exists to cover. --- src/builders/build/builder.ts | 13 +++- src/builders/build/watch-decisions.spec.ts | 79 +++++++++++++++++++++- src/builders/build/watch-decisions.ts | 12 +++- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/src/builders/build/builder.ts b/src/builders/build/builder.ts index 91b3ffe..d768b26 100644 --- a/src/builders/build/builder.ts +++ b/src/builders/build/builder.ts @@ -458,6 +458,10 @@ export async function* runBuilder( // nor linkedDirs, so without it core buffers the change and nothing consumes it. const sharedDirs = sharedMappingDirs(normalized.config); const wakeDirs = [...linkedDirs, ...sharedDirs]; + const federationOutputPath = path.resolve( + context.workspaceRoot, + normalized.options.outputPath, + ); const nfWatcher: NfFileWatcher | undefined = watch ? createNfWatcher({ @@ -467,7 +471,14 @@ export async function* runBuilder( // Core has already buffered p (and dropped it if it was a replay); this // only wakes the loop for edits the Angular-driven rebuild will NOT // cover — see shouldWakeFederation. - if (shouldWakeFederation(p, federationWatchedFiles, wakeDirs)) { + if ( + shouldWakeFederation( + p, + federationWatchedFiles, + wakeDirs, + federationOutputPath, + ) + ) { notifyChange(); } }, diff --git a/src/builders/build/watch-decisions.spec.ts b/src/builders/build/watch-decisions.spec.ts index 842a70b..5ebd19c 100644 --- a/src/builders/build/watch-decisions.spec.ts +++ b/src/builders/build/watch-decisions.spec.ts @@ -92,6 +92,7 @@ describe("shouldWakeFederation", () => { const linkedDir = path.normalize("/ws/node_modules/.links/my-lib"); const sharedDir = path.normalize("/ws/libs/internal/src/logging"); const wakeDirs = [linkedDir, sharedDir]; + const outDir = path.normalize("/ws/dist/host"); it("wakes for a federation-tracked source", () => { expect( @@ -99,6 +100,7 @@ describe("shouldWakeFederation", () => { "/ws/apps/host/src/app/app.component.ts", watched, wakeDirs, + outDir, ), ).toBe(true); }); @@ -109,12 +111,15 @@ describe("shouldWakeFederation", () => { path.join(linkedDir, "src", "lib", "thing.ts"), watched, wakeDirs, + outDir, ), ).toBe(true); }); it("wakes for a wake dir itself", () => { - expect(shouldWakeFederation(linkedDir, watched, wakeDirs)).toBe(true); + expect(shouldWakeFederation(linkedDir, watched, wakeDirs, outDir)).toBe( + true, + ); }); // The reason sharedMappingDirs is in the wake set: a file created since the @@ -125,23 +130,76 @@ describe("shouldWakeFederation", () => { path.join(sharedDir, "audit.service.ts"), watched, wakeDirs, + outDir, ), ).toBe(true); }); it("does not wake for an unrelated path", () => { expect( - shouldWakeFederation("/ws/apps/host/src/styles.css", watched, wakeDirs), + shouldWakeFederation( + "/ws/apps/host/src/styles.css", + watched, + wakeDirs, + outDir, + ), ).toBe(false); }); // A sibling whose name merely starts with a wake dir's name is outside it. it("does not wake for a wake-dir name prefix", () => { expect( - shouldWakeFederation(sharedDir + "-other/src/index.ts", watched, wakeDirs), + shouldWakeFederation( + sharedDir + "-other/src/index.ts", + watched, + wakeDirs, + outDir, + ), ).toBe(false); }); + // With `sharedMappings` unset core promotes every tsconfig `paths` entry, so an + // entry point near the workspace root yields a wake dir containing dist. The + // rebuild writes there with a fresh mtime — not a replay, so core delivers it — + // and without the guard that wake drives the next rebuild, forever. + it("does not wake for the output it just wrote, under a workspace-root wake dir", () => { + const rootWakeDirs = [path.normalize("/ws")]; + + expect( + shouldWakeFederation( + path.join(outDir, "remoteEntry.json"), + watched, + rootWakeDirs, + outDir, + ), + ).toBe(false); + }); + + it("still wakes for a source under that same workspace-root wake dir", () => { + const rootWakeDirs = [path.normalize("/ws")]; + + expect( + shouldWakeFederation( + "/ws/libs/internal/src/logging/audit.service.ts", + watched, + rootWakeDirs, + outDir, + ), + ).toBe(true); + }); + + // The guard is containment, not a string prefix: `dist/host-e2e` is not output. + it("wakes for a sibling of the output dir", () => { + expect( + shouldWakeFederation( + outDir + "-e2e/src/app.po.ts", + watched, + [path.normalize("/ws")], + outDir, + ), + ).toBe(true); + }); + // Regression: the wake dirs arrive with native separators while core delivers // posix paths, so comparing against `dir + path.sep` was always false on // Windows. Backslash dirs make that reproducible off Windows. @@ -151,6 +209,7 @@ describe("shouldWakeFederation", () => { "C:/ws/libs/internal/src/logging/audit.service.ts", new Set(), ["C:\\ws\\libs\\internal\\src\\logging"], + "C:\\ws\\dist\\host", ), ).toBe(true); }); @@ -161,6 +220,19 @@ describe("shouldWakeFederation", () => { "C:/ws/libs/internal/src/logging-other/thing.ts", new Set(), ["C:\\ws\\libs\\internal\\src\\logging"], + "C:\\ws\\dist\\host", + ), + ).toBe(false); + }); + + // The output path reaches the guard with native separators too. + it("rejects a posix output event against a backslash output path", () => { + expect( + shouldWakeFederation( + "C:/ws/dist/host/remoteEntry.json", + new Set(), + ["C:\\ws"], + "C:\\ws\\dist\\host", ), ).toBe(false); }); @@ -171,6 +243,7 @@ describe("shouldWakeFederation", () => { "/ws/apps/host/src/app/../app/app.component.ts", watched, wakeDirs, + outDir, ), ).toBe(true); }); diff --git a/src/builders/build/watch-decisions.ts b/src/builders/build/watch-decisions.ts index e1fad5d..aa620c4 100644 --- a/src/builders/build/watch-decisions.ts +++ b/src/builders/build/watch-decisions.ts @@ -1,6 +1,6 @@ import * as path from "path"; -import { isUnderAnyDir } from "@softarc/native-federation/internal"; +import { isUnderAnyDir, isUnderDir } from "@softarc/native-federation/internal"; export interface FederationFreshness { /** Record the outcome of a watcher-driven federation rebuild. */ @@ -51,12 +51,22 @@ export function shouldRunWatcherRebuild( * * Core's `isUnderAnyDir` rather than a `path.sep` comparison: it delivers posix * paths, so splicing in the native separator is always false on Windows. + * + * `outputPath` is excluded first: with `sharedMappings` unset core promotes every + * tsconfig `paths` entry to a mapping, so an entry point near the workspace root + * yields a wake dir containing the federation output — and a rebuild writes there + * with a fresh mtime, which is not a replay, so the loop would wake itself forever. + * Filtering the event rather than dropping the dir keeps the rest of that dir's + * sources woken. */ export function shouldWakeFederation( changedPath: string, watchedFiles: ReadonlySet, wakeDirs: readonly string[], + outputPath: string, ): boolean { + if (isUnderDir(changedPath, outputPath)) return false; + return ( isUnderAnyDir(changedPath, wakeDirs) || watchedFiles.has(path.normalize(changedPath)) From 530fe889c06ae50eb2260c4e0c7c0fb836433646 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Tue, 4 Aug 2026 11:57:56 +0200 Subject: [PATCH 4/4] docs(builder): correct what makes a wake dir contain the output A wake dir is the dirname of a mapping's *resolved* entry point, not of a raw tsconfig `paths` entry: wildcards expand per used entry point (`@internal/*` yields libs/internal/src/logging, never libs/internal/src) and ignoreUnusedDeps prunes unused mappings before sharedMappingDirs sees them. The old wording implied a workspace-root wake dir was routine. Record the measurement behind the guard's spec: with the output moved into a mapping dir, one source edit produced 88 rebuilds in 60s and was still climbing, against 1 with the guard on. --- src/builders/build/watch-decisions.spec.ts | 8 +++++--- src/builders/build/watch-decisions.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/builders/build/watch-decisions.spec.ts b/src/builders/build/watch-decisions.spec.ts index 5ebd19c..0f86a91 100644 --- a/src/builders/build/watch-decisions.spec.ts +++ b/src/builders/build/watch-decisions.spec.ts @@ -158,10 +158,12 @@ describe("shouldWakeFederation", () => { ).toBe(false); }); - // With `sharedMappings` unset core promotes every tsconfig `paths` entry, so an - // entry point near the workspace root yields a wake dir containing dist. The + // A wake dir is the dir of a mapping's resolved entry point, so a mapping whose + // entry point sits beside the build output yields a wake dir containing it. The // rebuild writes there with a fresh mtime — not a replay, so core delivers it — - // and without the guard that wake drives the next rebuild, forever. + // and without the guard that wake drives the next rebuild, forever. Measured on + // the nx playground with the output moved into a mapping dir: one source edit + // produced 88 rebuilds in 60s and still climbing, vs 1 with the guard on. it("does not wake for the output it just wrote, under a workspace-root wake dir", () => { const rootWakeDirs = [path.normalize("/ws")]; diff --git a/src/builders/build/watch-decisions.ts b/src/builders/build/watch-decisions.ts index aa620c4..067b4a6 100644 --- a/src/builders/build/watch-decisions.ts +++ b/src/builders/build/watch-decisions.ts @@ -52,12 +52,12 @@ export function shouldRunWatcherRebuild( * Core's `isUnderAnyDir` rather than a `path.sep` comparison: it delivers posix * paths, so splicing in the native separator is always false on Windows. * - * `outputPath` is excluded first: with `sharedMappings` unset core promotes every - * tsconfig `paths` entry to a mapping, so an entry point near the workspace root - * yields a wake dir containing the federation output — and a rebuild writes there - * with a fresh mtime, which is not a replay, so the loop would wake itself forever. - * Filtering the event rather than dropping the dir keeps the rest of that dir's - * sources woken. + * `outputPath` is excluded first. A wake dir is the directory of a mapping's + * resolved entry point, so a mapping whose entry point sits beside the build + * output yields a wake dir that contains it — and a rebuild writes there with a + * fresh mtime, which is no replay, so the loop would wake itself forever. + * Filtering the event rather than dropping the dir keeps that dir's real sources + * woken. */ export function shouldWakeFederation( changedPath: string,