Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 80 additions & 17 deletions src/builders/build/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -425,18 +445,56 @@ 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<string>();
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
? 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). 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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 27 additions & 9 deletions src/builders/remote/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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();

Expand Down Expand Up @@ -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.');
Expand Down
9 changes: 8 additions & 1 deletion src/builders/remote/change-watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

let notifyChange: () => void = () => {};
Expand All @@ -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();
},
Expand Down
80 changes: 80 additions & 0 deletions src/utils/federation-source-files.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
40 changes: 40 additions & 0 deletions src/utils/federation-source-files.ts
Original file line number Diff line number Diff line change
@@ -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<string>([
...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}`
);
}
Loading
Loading