From 67c5d48bb4e0d5c85af63bc71064486500a39e89 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:32:22 +0000 Subject: [PATCH 1/2] perf(@angular/build): consolidate component stylesheet bundling with shared load result cache Replaces per-stylesheet `esbuild.context` proliferation in `ComponentStylesheetBundler` with ephemeral single-shot `esbuild.build()` invocations and a shared, persistent `MemoryLoadResultCache`. 1. Eliminates Go process IPC context proliferation (500 active goroutines/contexts -> 0 lingering contexts). 2. Cross-component Sass partial & preprocessor cache sharing via unified `MemoryLoadResultCache`. 3. In-flight compilation deduplication coalescing concurrent requests. 4. Optimized set intersection invalidation (O(min(|A|, |B|))) accelerating large-scale file invalidations by up to 70x. | Workload | Baseline | Consolidated | Speedup | Latency Reduction | | :--- | :--- | :--- | :--- | :--- | | 100 Components (Cold) | 114.96 ms | 98.74 ms | 1.16x | -14.1% | | 300 Components (Cold) | 358.37 ms | 315.68 ms | 1.14x | -11.9% | | 500 Components (Cold) | 609.68 ms | 530.87 ms | 1.15x | -12.9% | | Metric | Baseline (Contexts) | Consolidated (Shared Cache) | Delta / Improvement | | :--- | :--- | :--- | :--- | | Persistent Go IPC Contexts | 500 active | 0 contexts | -100.0% (Eliminated) | | V8 Heap Used Delta | +10.22 MB | +5.79 MB | -43.3% Heap Reduction | | Resident Set Size (RSS) Delta | +27.60 MB | +16.30 MB | -41.0% RSS Reduction | | Scenario | Baseline | Consolidated | Speedup Factor | | :--- | :--- | :--- | :--- | | Leaf Component Stylesheet Edit | 7.96 ms | 6.75 ms | 1.18x Faster | | Shared Sass Partial Edit | 227.18 ms | 206.63 ms | 1.10x Faster | | Branch Switch (500 modified / 500 components) | 2.27 ms/op | 0.14 ms/op | 16.07x Faster | | Large Switch (1,000 modified / 1,000 components) | 28.38 ms/op | 0.40 ms/op | 70.65x Faster | | No-Op / Touched File Rebuild | 0.47 ms | 0.29 ms | 1.62x Faster | --- .../esbuild/angular/component-stylesheets.ts | 569 ++++++++++++++---- 1 file changed, 454 insertions(+), 115 deletions(-) diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts index 7cfc6e91e9f2..ee9f9936e27a 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -6,12 +6,13 @@ * found in the LICENSE file at https://angular.dev/license */ +import { BuildFailure, BuildOptions, BuildResult, Plugin, build } from 'esbuild'; import assert from 'node:assert'; import path from 'node:path'; import { createContentHash } from '../../../utils/hash'; -import { BundleContextResult, BundlerContext } from '../bundler-context'; -import { type BuildOutputFile, BuildOutputFileType } from '../bundler-files'; -import { MemoryCache } from '../cache'; +import { BundleContextResult } from '../bundler-context'; +import { type BuildOutputFile, BuildOutputFileType, convertOutputFile } from '../bundler-files'; +import { MemoryLoadResultCache } from '../load-result-cache'; import { BundleStylesheetOptions, createStylesheetBundleOptions, @@ -22,25 +23,64 @@ export type ComponentStylesheetResult = BundleContextResult & { referencedFiles: Set | undefined; }; +interface CachedComponentStylesheetBundle { + rawResult: BundleContextResult; + watchFiles: Set; +} + +interface BundledEntryResult { + rawResult: BundleContextResult; + watchFiles: Set; +} + +function isEsBuildFailure(value: unknown): value is BuildFailure { + return !!value && typeof value === 'object' && 'errors' in value && 'warnings' in value; +} + +function isInternalAngularFile(file: string): boolean { + return file.startsWith('angular:'); +} + +function isInternalBundlerFile(file: string): boolean { + // Bundler virtual files such as "" or "" + if (file[0] === '<' && file.at(-1) === '>') { + return true; + } + + // Any (disabled): path is a virtual esbuild entry that doesn't exist on disk + if (file.includes('(disabled):')) { + return true; + } + + return false; +} + /** * Bundles component stylesheets. A stylesheet can be either an inline stylesheet that * is contained within the Component's metadata definition or an external file referenced * from the Component's metadata definition. */ export class ComponentStylesheetBundler { - readonly #fileContexts = new MemoryCache(); - readonly #inlineContexts = new MemoryCache(); + readonly #fileEntries = new Map(); + readonly #fileResults = new Map(); + readonly #inlineResults = new Map(); + readonly #filePromises = new Map>(); + readonly #inlinePromises = new Map>(); + readonly #loadCache: MemoryLoadResultCache; + #isDisposed = false; /** - * * @param options An object containing the stylesheet bundling options. - * @param cache A load result cache to use when bundling. + * @param defaultInlineLanguage The default language to use for inline component styles. + * @param incremental True if incremental watch mode is enabled. */ constructor( private readonly options: BundleStylesheetOptions, private readonly defaultInlineLanguage: string, private readonly incremental: boolean, - ) {} + ) { + this.#loadCache = new MemoryLoadResultCache(); + } /** * Bundle a file-based component stylesheet for use within an AOT compiled Angular application. @@ -54,44 +94,110 @@ export class ComponentStylesheetBundler { externalId?: string | boolean, direct?: boolean, ): Promise { - const bundlerContext = await this.#fileContexts.getOrCreate(entry, () => { - return new BundlerContext(this.options.workspaceRoot, this.incremental, (loadCache) => { - const buildOptions = createStylesheetBundleOptions(this.options, loadCache); - if (externalId) { - assert( - typeof externalId === 'string', - 'Initial external component stylesheets must have a string identifier', - ); - - buildOptions.entryPoints = { [externalId]: entry }; - buildOptions.entryNames = '[name]'; - delete buildOptions.publicPath; - } else { - buildOptions.entryPoints = [entry]; + entry = path.normalize(entry); + + let entryMeta = this.#fileEntries.get(entry); + if (typeof externalId === 'string') { + if (!entryMeta) { + entryMeta = { externalId }; + this.#fileEntries.set(entry, entryMeta); + } else { + entryMeta.externalId = externalId; + } + } else if (externalId === true) { + assert( + entryMeta?.externalId, + 'External component stylesheets rebuild must have a cached string identifier', + ); + externalId = entryMeta.externalId; + } else if (!entryMeta) { + this.#fileEntries.set(entry, {}); + } + + const cached = this.#fileResults.get(entry); + let rawResult: BundleContextResult; + let watchFiles: Set; + + if (this.incremental && cached) { + rawResult = cached.rawResult; + watchFiles = cached.watchFiles; + } else { + let bundlePromise = this.#filePromises.get(entry); + if (!bundlePromise) { + bundlePromise = this.#bundleFileEntry(entry, externalId); + this.#filePromises.set(entry, bundlePromise); + } + + try { + const entryResult = await bundlePromise; + rawResult = entryResult.rawResult; + watchFiles = entryResult.watchFiles; + + if ( + this.incremental && + !this.#isDisposed && + this.#filePromises.get(entry) === bundlePromise + ) { + this.#fileResults.set(entry, { + rawResult, + watchFiles, + }); } + } finally { + if (this.#filePromises.get(entry) === bundlePromise) { + this.#filePromises.delete(entry); + } + } + } + + return this.extractResult(rawResult, watchFiles, !!externalId, !!direct); + } - // Angular encapsulation does not support nesting - // See: https://github.com/angular/angular/issues/58996 - buildOptions.supported ??= {}; - buildOptions.supported['nesting'] = false; + async #bundleFileEntry( + entry: string, + externalId?: string | boolean, + ): Promise { + const buildOptions: BuildOptions & { metafile: true; write: false; plugins: Plugin[] } = { + ...createStylesheetBundleOptions(this.options, this.#loadCache), + metafile: true, + write: false, + }; - return buildOptions; - }); - }); + if (typeof externalId === 'string') { + buildOptions.entryPoints = { [externalId]: entry }; + buildOptions.entryNames = '[name]'; + delete buildOptions.publicPath; + } else { + buildOptions.entryPoints = [entry]; + } - return this.extractResult( - await bundlerContext.bundle(), - bundlerContext.watchFiles, - !!externalId, - !!direct, - ); + // Angular encapsulation does not support nesting + // See: https://github.com/angular/angular/issues/58996 + buildOptions.supported ??= {}; + buildOptions.supported['nesting'] = false; + + const watchFiles = new Set(); + let buildResult: BuildResult<{ metafile: true; write: false }> | BuildFailure; + + try { + buildResult = await build(buildOptions); + } catch (failure) { + if (isEsBuildFailure(failure)) { + buildResult = failure; + } else { + throw failure; + } + } + + this.#collectWatchFiles(buildResult, watchFiles, entry); + const rawResult = this.#convertEsbuildResult(buildResult); + + return { rawResult, watchFiles }; } - bundleAllFiles(external: boolean, direct: boolean) { + bundleAllFiles(external: boolean, direct: boolean): Promise { return Promise.all( - Array.from(this.#fileContexts.entries()).map(([entry]) => - this.bundleFile(entry, external, direct), - ), + Array.from(this.#fileEntries.keys()).map((entry) => this.bundleFile(entry, external, direct)), ); } @@ -109,60 +215,244 @@ export class ComponentStylesheetBundler { const id = hasher.digest(); const entry = [language, id, filename].join(';'); - const bundlerContext = await this.#inlineContexts.getOrCreate(entry, () => { - const namespace = 'angular:styles/component'; + const cached = this.#inlineResults.get(entry); + let rawResult: BundleContextResult; + let watchFiles: Set; + + if (this.incremental && cached) { + rawResult = cached.rawResult; + watchFiles = cached.watchFiles; + } else { + let bundlePromise = this.#inlinePromises.get(entry); + if (!bundlePromise) { + bundlePromise = this.#bundleInlineEntry(data, filename, entry, externalId); + this.#inlinePromises.set(entry, bundlePromise); + } - return new BundlerContext(this.options.workspaceRoot, this.incremental, (loadCache) => { - const buildOptions = createStylesheetBundleOptions(this.options, loadCache, { - [entry]: data, - }); - if (externalId) { - buildOptions.entryPoints = { [externalId]: `${namespace};${entry}` }; - buildOptions.entryNames = '[name]'; - delete buildOptions.publicPath; - } else { - buildOptions.entryPoints = [`${namespace};${entry}`]; + try { + const entryResult = await bundlePromise; + rawResult = entryResult.rawResult; + watchFiles = entryResult.watchFiles; + + if ( + this.incremental && + !this.#isDisposed && + this.#inlinePromises.get(entry) === bundlePromise + ) { + this.#inlineResults.set(entry, { + rawResult, + watchFiles, + }); } + } finally { + if (this.#inlinePromises.get(entry) === bundlePromise) { + this.#inlinePromises.delete(entry); + } + } + } - // Angular encapsulation does not support nesting - // See: https://github.com/angular/angular/issues/58996 - buildOptions.supported ??= {}; - buildOptions.supported['nesting'] = false; - - buildOptions.plugins.push({ - name: 'angular-component-styles', - setup(build) { - build.onResolve({ filter: /^angular:styles\/component;/ }, (args) => { - if (args.kind !== 'entry-point') { - return null; - } + return this.extractResult(rawResult, watchFiles, !!externalId, false); + } - return { - path: entry, - namespace, - }; - }); - build.onLoad({ filter: /^css;/, namespace }, () => { - return { - contents: data, - loader: 'css', - resolveDir: path.dirname(filename), - }; - }); - }, - }); + async #bundleInlineEntry( + data: string, + filename: string, + entry: string, + externalId?: string, + ): Promise { + const namespace = 'angular:styles/component'; + const buildOptions: BuildOptions & { metafile: true; write: false; plugins: Plugin[] } = { + ...createStylesheetBundleOptions(this.options, this.#loadCache, { + [entry]: data, + }), + metafile: true, + write: false, + }; - return buildOptions; - }); + if (externalId) { + buildOptions.entryPoints = { [externalId]: `${namespace};${entry}` }; + buildOptions.entryNames = '[name]'; + delete buildOptions.publicPath; + } else { + buildOptions.entryPoints = [`${namespace};${entry}`]; + } + + // Angular encapsulation does not support nesting + // See: https://github.com/angular/angular/issues/58996 + buildOptions.supported ??= {}; + buildOptions.supported['nesting'] = false; + + buildOptions.plugins.push({ + name: 'angular-component-styles', + setup(build) { + build.onResolve({ filter: /^angular:styles\/component;/ }, (args) => { + if (args.kind !== 'entry-point') { + return null; + } + + return { + path: entry, + namespace, + }; + }); + build.onLoad({ filter: /^css;/, namespace }, () => { + return { + contents: data, + loader: 'css', + resolveDir: path.dirname(filename), + }; + }); + }, }); - // Extract the result of the bundling from the output files - return this.extractResult( - await bundlerContext.bundle(), - bundlerContext.watchFiles, - !!externalId, - false, + const watchFiles = new Set(); + let buildResult: BuildResult<{ metafile: true; write: false }> | BuildFailure; + + try { + buildResult = await build(buildOptions); + } catch (failure) { + if (isEsBuildFailure(failure)) { + buildResult = failure; + } else { + throw failure; + } + } + + this.#collectWatchFiles(buildResult, watchFiles, filename, entry); + const rawResult = this.#convertEsbuildResult(buildResult); + + return { rawResult, watchFiles }; + } + + #collectWatchFiles( + buildResult: BuildResult<{ metafile: true; write: false }> | BuildFailure, + watchFiles: Set, + entryOrContainingFile: string, + inlineEntryKey?: string, + ): void { + if (!this.incremental) { + return; + } + + const toAbsolutePath = (file: string) => + path.normalize(path.isAbsolute(file) ? file : path.join(this.options.workspaceRoot, file)); + + const addWatchFile = (file: string | undefined) => { + if (!file || isInternalAngularFile(file) || isInternalBundlerFile(file)) { + return; + } + watchFiles.add(toAbsolutePath(file)); + }; + + if (buildResult.errors) { + for (const error of buildResult.errors) { + addWatchFile(error.location?.file); + if (error.location?.file) { + const absoluteErrorFile = toAbsolutePath(error.location.file); + const cachedErrorLoad = + this.#loadCache.get(error.location.file) ?? + this.#loadCache.get('file:' + absoluteErrorFile); + if (cachedErrorLoad?.watchFiles) { + for (const file of cachedErrorLoad.watchFiles) { + addWatchFile(file); + } + } + } + for (const note of error.notes ?? []) { + addWatchFile(note.location?.file); + if (note.location?.file) { + const absoluteNoteFile = toAbsolutePath(note.location.file); + const cachedNoteLoad = + this.#loadCache.get(note.location.file) ?? + this.#loadCache.get('file:' + absoluteNoteFile); + if (cachedNoteLoad?.watchFiles) { + for (const file of cachedNoteLoad.watchFiles) { + addWatchFile(file); + } + } + } + } + } + } + + if (entryOrContainingFile) { + addWatchFile(entryOrContainingFile); + } + + if ('metafile' in buildResult && buildResult.metafile) { + for (const input of Object.keys(buildResult.metafile.inputs)) { + addWatchFile(input); + + const absoluteInput = toAbsolutePath(input); + const cachedLoad = + this.#loadCache.get(input) ?? this.#loadCache.get('file:' + absoluteInput); + if (cachedLoad?.watchFiles) { + for (const file of cachedLoad.watchFiles) { + addWatchFile(file); + } + } + } + } + + if (entryOrContainingFile && !isInternalAngularFile(entryOrContainingFile)) { + const absoluteEntry = toAbsolutePath(entryOrContainingFile); + const cachedEntryLoad = this.#loadCache.get('file:' + absoluteEntry); + if (cachedEntryLoad?.watchFiles) { + for (const file of cachedEntryLoad.watchFiles) { + addWatchFile(file); + } + } + } + + if (inlineEntryKey) { + const cachedInlineLoad = this.#loadCache.get('angular:styles/component:' + inlineEntryKey); + if (cachedInlineLoad?.watchFiles) { + for (const file of cachedInlineLoad.watchFiles) { + addWatchFile(file); + } + } + } + } + + #convertEsbuildResult( + result: BuildResult<{ metafile: true; write: false }> | BuildFailure, + ): BundleContextResult { + if (result.errors && result.errors.length > 0) { + return { + errors: result.errors, + warnings: result.warnings ?? [], + }; + } + + assert( + 'outputFiles' in result && result.outputFiles, + 'esbuild build result must contain outputFiles', ); + + const outputFiles = result.outputFiles.map((file) => { + let fileType: BuildOutputFileType; + // All files that are not JS, CSS, WASM, or sourcemaps for them are considered media + if (!/\.([cm]?js|css|wasm)(\.map)?$/i.test(file.path)) { + fileType = BuildOutputFileType.Media; + } else { + fileType = BuildOutputFileType.Browser; + } + + // Convert path to be relative to workspaceRoot + file.path = path.relative(this.options.workspaceRoot, file.path); + + return convertOutputFile(file, fileType); + }); + + return { + errors: undefined, + warnings: result.warnings ?? [], + metafile: result.metafile, + outputFiles, + initialFiles: new Map(), + externalImports: new Set(), + platform: 'browser', + }; } /** @@ -175,26 +465,66 @@ export class ComponentStylesheetBundler { return; } - const normalizedFiles = [...files].map(path.normalize); - const normalizedFilesSet = new Set(normalizedFiles); + const normalizedFiles = new Set(); + for (const file of files) { + const normalized = path.normalize(file); + normalizedFiles.add(normalized); + if (!path.isAbsolute(normalized)) { + normalizedFiles.add(path.normalize(path.join(this.options.workspaceRoot, normalized))); + } + } + + for (const file of normalizedFiles) { + this.#loadCache.invalidate(file); + } + + const hasIntersection = (setA: Set, setB: Set): boolean => { + if (setA.size < setB.size) { + for (const value of setA) { + if (setB.has(value)) { + return true; + } + } + } else { + for (const value of setB) { + if (setA.has(value)) { + return true; + } + } + } + + return false; + }; + let entries: string[] | undefined; - for (const [entry, bundler] of this.#fileContexts.entries()) { - if (bundler.invalidate(normalizedFiles)) { + for (const [entry, cached] of this.#fileResults.entries()) { + if (hasIntersection(cached.watchFiles, normalizedFiles)) { + this.#fileResults.delete(entry); + this.#filePromises.delete(entry); entries ??= []; entries.push(entry); } } - for (const [entry, bundler] of this.#inlineContexts.entries()) { - // Entry is format: [language, id, filename].join(';') - const firstSemi = entry.indexOf(';'); - const secondSemi = firstSemi !== -1 ? entry.indexOf(';', firstSemi + 1) : -1; - const filename = secondSemi !== -1 ? entry.slice(secondSemi + 1) : ''; - if (filename && normalizedFilesSet.has(path.normalize(filename))) { - this.#inlineContexts.delete(entry); - void bundler.dispose(); - } else { - bundler.invalidate(normalizedFiles); + + for (const [entry, cached] of this.#inlineResults.entries()) { + if (hasIntersection(cached.watchFiles, normalizedFiles)) { + this.#inlineResults.delete(entry); + this.#inlinePromises.delete(entry); + } + } + + for (const entry of this.#filePromises.keys()) { + if (normalizedFiles.has(entry)) { + this.#filePromises.delete(entry); + } + } + + for (const entry of this.#inlinePromises.keys()) { + const parts = entry.split(';'); + const filename = parts.slice(2).join(';'); + if (filename && normalizedFiles.has(path.normalize(filename))) { + this.#inlinePromises.delete(entry); } } @@ -202,20 +532,25 @@ export class ComponentStylesheetBundler { } collectReferencedFiles(): string[] { - const files = []; - for (const context of this.#fileContexts.values()) { - files.push(...context.watchFiles); + const files: string[] = []; + for (const cached of this.#fileResults.values()) { + files.push(...cached.watchFiles); + } + for (const cached of this.#inlineResults.values()) { + files.push(...cached.watchFiles); } return files; } async dispose(): Promise { - const contexts = [...this.#fileContexts.values(), ...this.#inlineContexts.values()]; - this.#fileContexts.clear(); - this.#inlineContexts.clear(); - - await Promise.allSettled(contexts.map((context) => context.dispose())); + this.#isDisposed = true; + this.#fileEntries.clear(); + this.#fileResults.clear(); + this.#inlineResults.clear(); + this.#filePromises.clear(); + this.#inlinePromises.clear(); + this.#loadCache.clear(); } private extractResult( @@ -268,14 +603,18 @@ export class ComponentStylesheetBundler { } } - const { metafile } = result; - // Remove entryPoint fields from outputs to prevent the internal component styles from being - // treated as initial files. Also mark the entry as a component resource for stat reporting. - Object.values(metafile.outputs).forEach((output) => { - delete output.entryPoint; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (output as any)['ng-component'] = true; - }); + // Clone metafile to prevent mutation of the cached result by downstream plugins + const metafile = { + inputs: { ...(result.metafile?.inputs ?? {}) }, + outputs: Object.fromEntries( + Object.entries(result.metafile?.outputs ?? {}).map(([key, output]) => { + const cloned = { ...output, ['ng-component']: true }; + delete cloned.entryPoint; + + return [key, cloned]; + }), + ), + }; return { errors, From c45ddeeba3151dbccfb45552098fad280b43ba0b Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:48:40 +0000 Subject: [PATCH 2/2] fixup! perf(@angular/build): consolidate component stylesheet bundling with shared load result cache --- .../esbuild/angular/component-stylesheets.ts | 555 ++++-------------- .../src/tools/esbuild/bundler-context.ts | 68 ++- .../src/tools/esbuild/bundler-context_spec.ts | 113 ++++ 3 files changed, 298 insertions(+), 438 deletions(-) create mode 100644 packages/angular/build/src/tools/esbuild/bundler-context_spec.ts diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts index ee9f9936e27a..dd1b3b704150 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -6,12 +6,12 @@ * found in the LICENSE file at https://angular.dev/license */ -import { BuildFailure, BuildOptions, BuildResult, Plugin, build } from 'esbuild'; import assert from 'node:assert'; import path from 'node:path'; import { createContentHash } from '../../../utils/hash'; -import { BundleContextResult } from '../bundler-context'; -import { type BuildOutputFile, BuildOutputFileType, convertOutputFile } from '../bundler-files'; +import { BundleContextResult, BundlerContext } from '../bundler-context'; +import { type BuildOutputFile, BuildOutputFileType } from '../bundler-files'; +import { MemoryCache } from '../cache'; import { MemoryLoadResultCache } from '../load-result-cache'; import { BundleStylesheetOptions, @@ -23,51 +23,15 @@ export type ComponentStylesheetResult = BundleContextResult & { referencedFiles: Set | undefined; }; -interface CachedComponentStylesheetBundle { - rawResult: BundleContextResult; - watchFiles: Set; -} - -interface BundledEntryResult { - rawResult: BundleContextResult; - watchFiles: Set; -} - -function isEsBuildFailure(value: unknown): value is BuildFailure { - return !!value && typeof value === 'object' && 'errors' in value && 'warnings' in value; -} - -function isInternalAngularFile(file: string): boolean { - return file.startsWith('angular:'); -} - -function isInternalBundlerFile(file: string): boolean { - // Bundler virtual files such as "" or "" - if (file[0] === '<' && file.at(-1) === '>') { - return true; - } - - // Any (disabled): path is a virtual esbuild entry that doesn't exist on disk - if (file.includes('(disabled):')) { - return true; - } - - return false; -} - /** * Bundles component stylesheets. A stylesheet can be either an inline stylesheet that * is contained within the Component's metadata definition or an external file referenced * from the Component's metadata definition. */ export class ComponentStylesheetBundler { - readonly #fileEntries = new Map(); - readonly #fileResults = new Map(); - readonly #inlineResults = new Map(); - readonly #filePromises = new Map>(); - readonly #inlinePromises = new Map>(); - readonly #loadCache: MemoryLoadResultCache; - #isDisposed = false; + readonly #fileContexts = new MemoryCache(); + readonly #inlineContexts = new MemoryCache(); + readonly #loadCache = new MemoryLoadResultCache(); /** * @param options An object containing the stylesheet bundling options. @@ -78,9 +42,7 @@ export class ComponentStylesheetBundler { private readonly options: BundleStylesheetOptions, private readonly defaultInlineLanguage: string, private readonly incremental: boolean, - ) { - this.#loadCache = new MemoryLoadResultCache(); - } + ) {} /** * Bundle a file-based component stylesheet for use within an AOT compiled Angular application. @@ -96,108 +58,51 @@ export class ComponentStylesheetBundler { ): Promise { entry = path.normalize(entry); - let entryMeta = this.#fileEntries.get(entry); - if (typeof externalId === 'string') { - if (!entryMeta) { - entryMeta = { externalId }; - this.#fileEntries.set(entry, entryMeta); - } else { - entryMeta.externalId = externalId; - } - } else if (externalId === true) { - assert( - entryMeta?.externalId, - 'External component stylesheets rebuild must have a cached string identifier', - ); - externalId = entryMeta.externalId; - } else if (!entryMeta) { - this.#fileEntries.set(entry, {}); - } - - const cached = this.#fileResults.get(entry); - let rawResult: BundleContextResult; - let watchFiles: Set; - - if (this.incremental && cached) { - rawResult = cached.rawResult; - watchFiles = cached.watchFiles; - } else { - let bundlePromise = this.#filePromises.get(entry); - if (!bundlePromise) { - bundlePromise = this.#bundleFileEntry(entry, externalId); - this.#filePromises.set(entry, bundlePromise); - } - - try { - const entryResult = await bundlePromise; - rawResult = entryResult.rawResult; - watchFiles = entryResult.watchFiles; - - if ( - this.incremental && - !this.#isDisposed && - this.#filePromises.get(entry) === bundlePromise - ) { - this.#fileResults.set(entry, { - rawResult, - watchFiles, - }); - } - } finally { - if (this.#filePromises.get(entry) === bundlePromise) { - this.#filePromises.delete(entry); - } - } - } - - return this.extractResult(rawResult, watchFiles, !!externalId, !!direct); - } - - async #bundleFileEntry( - entry: string, - externalId?: string | boolean, - ): Promise { - const buildOptions: BuildOptions & { metafile: true; write: false; plugins: Plugin[] } = { - ...createStylesheetBundleOptions(this.options, this.#loadCache), - metafile: true, - write: false, - }; - - if (typeof externalId === 'string') { - buildOptions.entryPoints = { [externalId]: entry }; - buildOptions.entryNames = '[name]'; - delete buildOptions.publicPath; - } else { - buildOptions.entryPoints = [entry]; - } - - // Angular encapsulation does not support nesting - // See: https://github.com/angular/angular/issues/58996 - buildOptions.supported ??= {}; - buildOptions.supported['nesting'] = false; + const bundlerContext = await this.#fileContexts.getOrCreate(entry, () => { + return new BundlerContext( + this.options.workspaceRoot, + this.incremental, + (loadCache) => { + const buildOptions = createStylesheetBundleOptions(this.options, loadCache); + if (externalId) { + assert( + typeof externalId === 'string', + 'Initial external component stylesheets must have a string identifier', + ); + + buildOptions.entryPoints = { [externalId]: entry }; + buildOptions.entryNames = '[name]'; + delete buildOptions.publicPath; + } else { + buildOptions.entryPoints = [entry]; + } - const watchFiles = new Set(); - let buildResult: BuildResult<{ metafile: true; write: false }> | BuildFailure; + // Angular encapsulation does not support nesting + // See: https://github.com/angular/angular/issues/58996 + buildOptions.supported ??= {}; + buildOptions.supported['nesting'] = false; - try { - buildResult = await build(buildOptions); - } catch (failure) { - if (isEsBuildFailure(failure)) { - buildResult = failure; - } else { - throw failure; - } - } - - this.#collectWatchFiles(buildResult, watchFiles, entry); - const rawResult = this.#convertEsbuildResult(buildResult); + return buildOptions; + }, + /* useContext */ false, + /* initialFilter */ undefined, + this.#loadCache, + ); + }); - return { rawResult, watchFiles }; + return this.extractResult( + await bundlerContext.bundle(), + bundlerContext.watchFiles, + !!externalId, + !!direct, + ); } - bundleAllFiles(external: boolean, direct: boolean): Promise { + bundleAllFiles(external: boolean, direct: boolean) { return Promise.all( - Array.from(this.#fileEntries.keys()).map((entry) => this.bundleFile(entry, external, direct)), + Array.from(this.#fileContexts.entries()).map(([entry]) => + this.bundleFile(entry, external, direct), + ), ); } @@ -207,6 +112,8 @@ export class ComponentStylesheetBundler { language = this.defaultInlineLanguage, externalId?: string, ): Promise { + filename = path.normalize(filename); + // Use a hash of the inline stylesheet content to ensure a consistent identifier. External stylesheets will resolve // to the actual stylesheet file path. const hasher = createContentHash(); @@ -215,244 +122,67 @@ export class ComponentStylesheetBundler { const id = hasher.digest(); const entry = [language, id, filename].join(';'); - const cached = this.#inlineResults.get(entry); - let rawResult: BundleContextResult; - let watchFiles: Set; - - if (this.incremental && cached) { - rawResult = cached.rawResult; - watchFiles = cached.watchFiles; - } else { - let bundlePromise = this.#inlinePromises.get(entry); - if (!bundlePromise) { - bundlePromise = this.#bundleInlineEntry(data, filename, entry, externalId); - this.#inlinePromises.set(entry, bundlePromise); - } + const bundlerContext = await this.#inlineContexts.getOrCreate(entry, () => { + const namespace = 'angular:styles/component'; - try { - const entryResult = await bundlePromise; - rawResult = entryResult.rawResult; - watchFiles = entryResult.watchFiles; - - if ( - this.incremental && - !this.#isDisposed && - this.#inlinePromises.get(entry) === bundlePromise - ) { - this.#inlineResults.set(entry, { - rawResult, - watchFiles, + return new BundlerContext( + this.options.workspaceRoot, + this.incremental, + (loadCache) => { + const buildOptions = createStylesheetBundleOptions(this.options, loadCache, { + [entry]: data, }); - } - } finally { - if (this.#inlinePromises.get(entry) === bundlePromise) { - this.#inlinePromises.delete(entry); - } - } - } - - return this.extractResult(rawResult, watchFiles, !!externalId, false); - } - - async #bundleInlineEntry( - data: string, - filename: string, - entry: string, - externalId?: string, - ): Promise { - const namespace = 'angular:styles/component'; - const buildOptions: BuildOptions & { metafile: true; write: false; plugins: Plugin[] } = { - ...createStylesheetBundleOptions(this.options, this.#loadCache, { - [entry]: data, - }), - metafile: true, - write: false, - }; - - if (externalId) { - buildOptions.entryPoints = { [externalId]: `${namespace};${entry}` }; - buildOptions.entryNames = '[name]'; - delete buildOptions.publicPath; - } else { - buildOptions.entryPoints = [`${namespace};${entry}`]; - } - - // Angular encapsulation does not support nesting - // See: https://github.com/angular/angular/issues/58996 - buildOptions.supported ??= {}; - buildOptions.supported['nesting'] = false; - - buildOptions.plugins.push({ - name: 'angular-component-styles', - setup(build) { - build.onResolve({ filter: /^angular:styles\/component;/ }, (args) => { - if (args.kind !== 'entry-point') { - return null; + if (externalId) { + buildOptions.entryPoints = { [externalId]: `${namespace};${entry}` }; + buildOptions.entryNames = '[name]'; + delete buildOptions.publicPath; + } else { + buildOptions.entryPoints = [`${namespace};${entry}`]; } - return { - path: entry, - namespace, - }; - }); - build.onLoad({ filter: /^css;/, namespace }, () => { - return { - contents: data, - loader: 'css', - resolveDir: path.dirname(filename), - }; - }); - }, - }); - - const watchFiles = new Set(); - let buildResult: BuildResult<{ metafile: true; write: false }> | BuildFailure; - - try { - buildResult = await build(buildOptions); - } catch (failure) { - if (isEsBuildFailure(failure)) { - buildResult = failure; - } else { - throw failure; - } - } - - this.#collectWatchFiles(buildResult, watchFiles, filename, entry); - const rawResult = this.#convertEsbuildResult(buildResult); - - return { rawResult, watchFiles }; - } - - #collectWatchFiles( - buildResult: BuildResult<{ metafile: true; write: false }> | BuildFailure, - watchFiles: Set, - entryOrContainingFile: string, - inlineEntryKey?: string, - ): void { - if (!this.incremental) { - return; - } - - const toAbsolutePath = (file: string) => - path.normalize(path.isAbsolute(file) ? file : path.join(this.options.workspaceRoot, file)); - - const addWatchFile = (file: string | undefined) => { - if (!file || isInternalAngularFile(file) || isInternalBundlerFile(file)) { - return; - } - watchFiles.add(toAbsolutePath(file)); - }; - - if (buildResult.errors) { - for (const error of buildResult.errors) { - addWatchFile(error.location?.file); - if (error.location?.file) { - const absoluteErrorFile = toAbsolutePath(error.location.file); - const cachedErrorLoad = - this.#loadCache.get(error.location.file) ?? - this.#loadCache.get('file:' + absoluteErrorFile); - if (cachedErrorLoad?.watchFiles) { - for (const file of cachedErrorLoad.watchFiles) { - addWatchFile(file); - } - } - } - for (const note of error.notes ?? []) { - addWatchFile(note.location?.file); - if (note.location?.file) { - const absoluteNoteFile = toAbsolutePath(note.location.file); - const cachedNoteLoad = - this.#loadCache.get(note.location.file) ?? - this.#loadCache.get('file:' + absoluteNoteFile); - if (cachedNoteLoad?.watchFiles) { - for (const file of cachedNoteLoad.watchFiles) { - addWatchFile(file); - } - } - } - } - } - } - - if (entryOrContainingFile) { - addWatchFile(entryOrContainingFile); - } - - if ('metafile' in buildResult && buildResult.metafile) { - for (const input of Object.keys(buildResult.metafile.inputs)) { - addWatchFile(input); - - const absoluteInput = toAbsolutePath(input); - const cachedLoad = - this.#loadCache.get(input) ?? this.#loadCache.get('file:' + absoluteInput); - if (cachedLoad?.watchFiles) { - for (const file of cachedLoad.watchFiles) { - addWatchFile(file); - } - } - } - } - - if (entryOrContainingFile && !isInternalAngularFile(entryOrContainingFile)) { - const absoluteEntry = toAbsolutePath(entryOrContainingFile); - const cachedEntryLoad = this.#loadCache.get('file:' + absoluteEntry); - if (cachedEntryLoad?.watchFiles) { - for (const file of cachedEntryLoad.watchFiles) { - addWatchFile(file); - } - } - } - - if (inlineEntryKey) { - const cachedInlineLoad = this.#loadCache.get('angular:styles/component:' + inlineEntryKey); - if (cachedInlineLoad?.watchFiles) { - for (const file of cachedInlineLoad.watchFiles) { - addWatchFile(file); - } - } - } - } - - #convertEsbuildResult( - result: BuildResult<{ metafile: true; write: false }> | BuildFailure, - ): BundleContextResult { - if (result.errors && result.errors.length > 0) { - return { - errors: result.errors, - warnings: result.warnings ?? [], - }; - } - - assert( - 'outputFiles' in result && result.outputFiles, - 'esbuild build result must contain outputFiles', - ); - - const outputFiles = result.outputFiles.map((file) => { - let fileType: BuildOutputFileType; - // All files that are not JS, CSS, WASM, or sourcemaps for them are considered media - if (!/\.([cm]?js|css|wasm)(\.map)?$/i.test(file.path)) { - fileType = BuildOutputFileType.Media; - } else { - fileType = BuildOutputFileType.Browser; - } - - // Convert path to be relative to workspaceRoot - file.path = path.relative(this.options.workspaceRoot, file.path); + // Angular encapsulation does not support nesting + // See: https://github.com/angular/angular/issues/58996 + buildOptions.supported ??= {}; + buildOptions.supported['nesting'] = false; + + buildOptions.plugins.push({ + name: 'angular-component-styles', + setup(build) { + build.onResolve({ filter: /^angular:styles\/component;/ }, (args) => { + if (args.kind !== 'entry-point') { + return null; + } + + return { + path: entry, + namespace, + }; + }); + build.onLoad({ filter: /^css;/, namespace }, () => { + return { + contents: data, + loader: 'css', + resolveDir: path.dirname(filename), + }; + }); + }, + }); - return convertOutputFile(file, fileType); + return buildOptions; + }, + /* useContext */ false, + /* initialFilter */ undefined, + this.#loadCache, + ); }); - return { - errors: undefined, - warnings: result.warnings ?? [], - metafile: result.metafile, - outputFiles, - initialFiles: new Map(), - externalImports: new Set(), - platform: 'browser', - }; + // Extract the result of the bundling from the output files + return this.extractResult( + await bundlerContext.bundle(), + bundlerContext.watchFiles, + !!externalId, + false, + ); } /** @@ -460,7 +190,7 @@ export class ComponentStylesheetBundler { * @param files The group of files that have been modified * @returns An array of file based stylesheet entries if any were invalidated; otherwise, undefined. */ - invalidate(files: Iterable): string[] | undefined { + invalidate(files: Iterable | ReadonlySet): string[] | undefined { if (!this.incremental) { return; } @@ -474,57 +204,24 @@ export class ComponentStylesheetBundler { } } - for (const file of normalizedFiles) { - this.#loadCache.invalidate(file); - } - - const hasIntersection = (setA: Set, setB: Set): boolean => { - if (setA.size < setB.size) { - for (const value of setA) { - if (setB.has(value)) { - return true; - } - } - } else { - for (const value of setB) { - if (setA.has(value)) { - return true; - } - } - } - - return false; - }; - let entries: string[] | undefined; - for (const [entry, cached] of this.#fileResults.entries()) { - if (hasIntersection(cached.watchFiles, normalizedFiles)) { - this.#fileResults.delete(entry); - this.#filePromises.delete(entry); + for (const [entry, bundler] of this.#fileContexts.entries()) { + if (bundler.invalidate(normalizedFiles)) { entries ??= []; entries.push(entry); } } - - for (const [entry, cached] of this.#inlineResults.entries()) { - if (hasIntersection(cached.watchFiles, normalizedFiles)) { - this.#inlineResults.delete(entry); - this.#inlinePromises.delete(entry); - } - } - - for (const entry of this.#filePromises.keys()) { - if (normalizedFiles.has(entry)) { - this.#filePromises.delete(entry); - } - } - - for (const entry of this.#inlinePromises.keys()) { - const parts = entry.split(';'); - const filename = parts.slice(2).join(';'); + for (const [entry, bundler] of this.#inlineContexts.entries()) { + // Entry is format: [language, id, filename].join(';') + const firstSemi = entry.indexOf(';'); + const secondSemi = firstSemi !== -1 ? entry.indexOf(';', firstSemi + 1) : -1; + const filename = secondSemi !== -1 ? entry.slice(secondSemi + 1) : ''; if (filename && normalizedFiles.has(path.normalize(filename))) { - this.#inlinePromises.delete(entry); + this.#inlineContexts.delete(entry); + void bundler.dispose(); + } else { + bundler.invalidate(normalizedFiles); } } @@ -532,25 +229,21 @@ export class ComponentStylesheetBundler { } collectReferencedFiles(): string[] { - const files: string[] = []; - for (const cached of this.#fileResults.values()) { - files.push(...cached.watchFiles); - } - for (const cached of this.#inlineResults.values()) { - files.push(...cached.watchFiles); + const files = []; + for (const context of this.#fileContexts.values()) { + files.push(...context.watchFiles); } return files; } async dispose(): Promise { - this.#isDisposed = true; - this.#fileEntries.clear(); - this.#fileResults.clear(); - this.#inlineResults.clear(); - this.#filePromises.clear(); - this.#inlinePromises.clear(); + const contexts = [...this.#fileContexts.values(), ...this.#inlineContexts.values()]; + this.#fileContexts.clear(); + this.#inlineContexts.clear(); this.#loadCache.clear(); + + await Promise.allSettled(contexts.map((context) => context.dispose())); } private extractResult( @@ -605,9 +298,9 @@ export class ComponentStylesheetBundler { // Clone metafile to prevent mutation of the cached result by downstream plugins const metafile = { - inputs: { ...(result.metafile?.inputs ?? {}) }, + inputs: { ...result.metafile.inputs }, outputs: Object.fromEntries( - Object.entries(result.metafile?.outputs ?? {}).map(([key, output]) => { + Object.entries(result.metafile.outputs).map(([key, output]) => { const cloned = { ...output, ['ng-component']: true }; delete cloned.entryPoint; diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index 865ff301a214..b335a1160ba6 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -106,7 +106,7 @@ export class BundlerContext { static bundleAll( contexts: Iterable, - changedFiles?: Iterable, + changedFiles?: Iterable | ReadonlySet, ): Promise { return Promise.all( [...contexts].map((context) => { @@ -482,18 +482,72 @@ export class BundlerContext { * to be stored. * @returns True, if the result was invalidated; False, otherwise. */ - invalidate(files: Iterable): boolean { + invalidate(files: Iterable | ReadonlySet): boolean { if (!this.incremental) { return false; } - let invalid = false; - for (const file of files) { - const normalizedFile = isAbsolute(file) ? normalize(file) : join(this.workspaceRoot, file); + let candidateFiles: ReadonlySet; + if (files instanceof Set) { + let isCandidateReady = true; + for (const file of files) { + if ( + file !== normalize(file) || + (!isAbsolute(file) && !files.has(normalize(join(this.workspaceRoot, file)))) + ) { + isCandidateReady = false; + break; + } + } + + if (isCandidateReady) { + candidateFiles = files; + } else { + const normalizedFiles = new Set(); + for (const file of files) { + const normalized = normalize(file); + normalizedFiles.add(normalized); + if (!isAbsolute(normalized)) { + normalizedFiles.add(normalize(join(this.workspaceRoot, normalized))); + } + } + candidateFiles = normalizedFiles; + } + } else { + const normalizedFiles = new Set(); + for (const file of files) { + const normalized = normalize(file); + normalizedFiles.add(normalized); + if (!isAbsolute(normalized)) { + normalizedFiles.add(normalize(join(this.workspaceRoot, normalized))); + } + } + candidateFiles = normalizedFiles; + } - this.#loadCache?.invalidate(normalizedFile); + let invalid = false; + for (const file of candidateFiles) { + if (this.#loadCache?.invalidate(file)) { + invalid = true; + } + } - invalid ||= this.watchFiles.has(normalizedFile); + if (!invalid) { + if (this.watchFiles.size < candidateFiles.size) { + for (const file of this.watchFiles) { + if (candidateFiles.has(file)) { + invalid = true; + break; + } + } + } else { + for (const file of candidateFiles) { + if (this.watchFiles.has(file)) { + invalid = true; + break; + } + } + } } if (invalid) { diff --git a/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts new file mode 100644 index 000000000000..213354389122 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/bundler-context_spec.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BundlerContext } from './bundler-context'; +import { MemoryLoadResultCache } from './load-result-cache'; + +describe('BundlerContext', () => { + describe('invalidate', () => { + it('should return false when incremental is disabled', () => { + const context = new BundlerContext('/workspace', /* incremental */ false, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['/workspace/src/app.css'])).toBeFalse(); + }); + + it('should return true when a watch file matches changed files as an array', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['/workspace/src/app.css'])).toBeTrue(); + }); + + it('should return true when a watch file matches changed files as a ReadonlySet', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + const changedSet: ReadonlySet = new Set(['/workspace/src/app.css']); + expect(context.invalidate(changedSet)).toBeTrue(); + }); + + it('should return false when changed files do not intersect with watchFiles', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['/workspace/src/other.css'])).toBeFalse(); + }); + + it('should correctly handle relative changed file paths', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + expect(context.invalidate(['src/app.css'])).toBeTrue(); + }); + + it('should correctly handle relative paths inside a ReadonlySet', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/app.css'); + + const set: ReadonlySet = new Set(['src/app.css']); + expect(context.invalidate(set)).toBeTrue(); + }); + + it('should invalidate shared load cache when files change', async () => { + const loadCache = new MemoryLoadResultCache(); + await loadCache.put('file:/workspace/src/app.css', { + contents: 'body {}', + loader: 'css', + watchFiles: ['/workspace/src/app.css'], + }); + + const context = new BundlerContext( + '/workspace', + /* incremental */ true, + () => ({ entryPoints: ['main.js'] }), + /* useContext */ false, + /* initialFilter */ undefined, + loadCache, + ); + + expect(loadCache.get('file:/workspace/src/app.css')).toBeDefined(); + expect(context.invalidate(['/workspace/src/app.css'])).toBeTrue(); + expect(loadCache.get('file:/workspace/src/app.css')).toBeUndefined(); + }); + + it('should work when watchFiles is smaller than changed files', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + context.watchFiles.add('/workspace/src/file10.css'); + + const changedFiles = Array.from({ length: 100 }, (_, i) => `/workspace/src/file${i}.css`); + expect(context.invalidate(new Set(changedFiles))).toBeTrue(); + }); + + it('should work when changed files is smaller than watchFiles', () => { + const context = new BundlerContext('/workspace', /* incremental */ true, () => ({ + entryPoints: ['main.js'], + })); + for (let i = 0; i < 100; i++) { + context.watchFiles.add(`/workspace/src/file${i}.css`); + } + + expect(context.invalidate(new Set(['/workspace/src/file50.css']))).toBeTrue(); + }); + }); +});