diff --git a/packages/angular/build/src/builders/application/setup-bundling.ts b/packages/angular/build/src/builders/application/setup-bundling.ts index 79209ba0eaf9..4380db080e87 100644 --- a/packages/angular/build/src/builders/application/setup-bundling.ts +++ b/packages/angular/build/src/builders/application/setup-bundling.ts @@ -19,6 +19,7 @@ import { import { BundlerContext } from '../../tools/esbuild/bundler-context'; import { createGlobalScriptsBundleOptions } from '../../tools/esbuild/global-scripts'; import { createGlobalStylesBundleOptions } from '../../tools/esbuild/global-styles'; +import { MemoryLoadResultCache } from '../../tools/esbuild/load-result-cache'; import { getSupportedNodeTargets } from '../../tools/esbuild/target'; import type { NormalizedApplicationBuildOptions } from './options'; @@ -94,11 +95,19 @@ export function setupBundlerContexts( // Global Stylesheets if (options.globalStyles.length > 0) { + const globalStylesCache = new MemoryLoadResultCache(); for (const initial of [true, false]) { const bundleOptions = createGlobalStylesBundleOptions(options, target, initial); if (bundleOptions) { otherContexts.push( - new BundlerContext(workspaceRoot, watch, bundleOptions, true, () => initial), + new BundlerContext( + workspaceRoot, + watch, + bundleOptions, + true, + () => initial, + globalStylesCache, + ), ); } } diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index 38a82d944e32..865ff301a214 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -17,7 +17,7 @@ import { context, } from 'esbuild'; import assert from 'node:assert'; -import { basename, extname, join, relative } from 'node:path'; +import { basename, extname, isAbsolute, join, normalize, relative } from 'node:path'; import { SERVER_GENERATED_EXTERNALS } from '../../utils/server-rendering/manifest'; import { type BuildOutputFile, @@ -79,16 +79,18 @@ export class BundlerContext { #disposed = false; #optionsFactory: BundlerOptionsFactory; #shouldCacheResult: boolean; - #loadCache?: MemoryLoadResultCache; + #loadCache?: LoadResultCache; readonly watchFiles = new Set(); constructor( private workspaceRoot: string, private incremental: boolean, options: BuildOptions | BundlerOptionsFactory, - private alwaysUseContext = false, + private useContext = incremental, private initialFilter?: (initial: Readonly) => boolean, + sharedLoadCache?: LoadResultCache, ) { + this.#loadCache = sharedLoadCache; // To cache the results an option factory is needed to capture the full set of dependencies this.#shouldCacheResult = incremental && typeof options === 'function'; this.#optionsFactory = (...args) => { @@ -219,7 +221,7 @@ export class BundlerContext { async #performBundle(): Promise { // Create esbuild options if not present if (this.#esbuildOptions === undefined) { - if (this.incremental) { + if (this.incremental && !this.#loadCache) { this.#loadCache = new MemoryLoadResultCache(); } this.#esbuildOptions = this.#optionsFactory(this.#loadCache); @@ -234,7 +236,7 @@ export class BundlerContext { if (this.#esbuildContext) { // Rebuild using the existing incremental build context result = await this.#esbuildContext.rebuild(); - } else if (this.incremental || this.alwaysUseContext) { + } else if (this.useContext) { // Create a build context and perform the build. // Context creation does not perform a build. const esbuildContext = await context(this.#esbuildOptions); @@ -258,23 +260,12 @@ export class BundlerContext { // Build failures will throw an exception which contains errors/warnings if (isEsBuildFailure(failure)) { this.#addErrorsToWatch(failure); + this.#addLoadCacheFilesToWatch(); return failure; } else { throw failure; } - } finally { - if (this.incremental) { - // When incremental always add any files from the load result cache - if (this.#loadCache) { - for (const file of this.#loadCache.watchFiles) { - if (!isInternalAngularFile(file)) { - // watch files are fully resolved paths - this.watchFiles.add(file); - } - } - } - } } // Update files that should be watched. @@ -283,18 +274,38 @@ export class BundlerContext { if (this.incremental) { // Add input files except virtual angular files which do not exist on disk for (const input of Object.keys(result.metafile.inputs)) { - if (isInternalAngularFile(input) || isInternalBundlerFile(input)) { - continue; + const isInternal = isInternalAngularFile(input) || isInternalBundlerFile(input); + + // Input file paths are always relative to the workspace root unless already absolute + const normalizedAbsoluteInput = isAbsolute(input) + ? normalize(input) + : join(this.workspaceRoot, input); + + if (!isInternal) { + this.watchFiles.add(normalizedAbsoluteInput); } - // Input file paths are always relative to the workspace root - this.watchFiles.add(join(this.workspaceRoot, input)); + if (this.#loadCache) { + const cachedLoad = await (this.#loadCache.get(input) ?? + this.#loadCache.get(input.replace(';', ':')) ?? + this.#loadCache.get('file:' + normalizedAbsoluteInput)); + if (cachedLoad?.watchFiles) { + for (const file of cachedLoad.watchFiles) { + if (!isInternalAngularFile(file)) { + this.watchFiles.add( + isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file), + ); + } + } + } + } } } // Return if the build encountered any errors if (result.errors.length) { this.#addErrorsToWatch(result); + this.#addLoadCacheFilesToWatch(); return { errors: result.errors, @@ -443,12 +454,22 @@ export class BundlerContext { for (const error of result.errors) { let file = error.location?.file; if (file && !isInternalAngularFile(file)) { - this.watchFiles.add(join(this.workspaceRoot, file)); + this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file)); } for (const note of error.notes) { file = note.location?.file; if (file && !isInternalAngularFile(file)) { - this.watchFiles.add(join(this.workspaceRoot, file)); + this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file)); + } + } + } + } + + #addLoadCacheFilesToWatch(): void { + if (this.incremental && this.#loadCache) { + for (const file of this.#loadCache.watchFiles) { + if (!isInternalAngularFile(file)) { + this.watchFiles.add(isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file)); } } } @@ -468,12 +489,11 @@ export class BundlerContext { let invalid = false; for (const file of files) { - if (this.#loadCache?.invalidate(file)) { - invalid = true; - continue; - } + const normalizedFile = isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file); + + this.#loadCache?.invalidate(normalizedFile); - invalid ||= this.watchFiles.has(file); + invalid ||= this.watchFiles.has(normalizedFile); } if (invalid) { diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache.ts b/packages/angular/build/src/tools/esbuild/load-result-cache.ts index a0e8d1139310..402069cff1a5 100644 --- a/packages/angular/build/src/tools/esbuild/load-result-cache.ts +++ b/packages/angular/build/src/tools/esbuild/load-result-cache.ts @@ -12,6 +12,7 @@ import { normalize } from 'node:path'; export interface LoadResultCache { get(path: string): OnLoadResult | Promise | undefined; put(path: string, result: OnLoadResult): Promise; + invalidate(path: string): boolean; readonly watchFiles: ReadonlyArray; } @@ -108,18 +109,15 @@ export class MemoryLoadResultCache implements LoadResultCache { invalidate(path: string): boolean { const affectedPaths = this.#fileDependencies.get(path); - let found = false; + if (!affectedPaths) { + return false; + } - if (affectedPaths) { - for (const affected of affectedPaths) { - if (this.#loadResults.delete(affected)) { - found = true; - } - } - this.#fileDependencies.delete(path); + for (const affected of affectedPaths) { + this.#loadResults.delete(affected); } - return found; + return true; } get watchFiles(): string[] { diff --git a/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts index a44256676653..8a8682beed61 100644 --- a/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts +++ b/packages/angular/build/src/tools/esbuild/load-result-cache_spec.ts @@ -111,6 +111,9 @@ describe('MemoryLoadResultCache', () => { // Invalidating new dependency should invalidate the cache expect(cache.invalidate('/test/new-dep.json')).toBeTrue(); expect(cache.get('file:/test/styles.css')).toBeUndefined(); - expect(cache.watchFiles).not.toContain('/test/new-dep.json'); + // Invalidating a file marks its cached results stale, but preserves watch file tracking + // so the file watcher continues monitoring the dependency for subsequent changes until + // a new build pass (via put) updates the active dependencies. + expect(cache.watchFiles).toContain('/test/new-dep.json'); }); });