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..dd1b3b704150 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -12,6 +12,7 @@ import { createContentHash } from '../../../utils/hash'; 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, createStylesheetBundleOptions, @@ -30,11 +31,12 @@ export type ComponentStylesheetResult = BundleContextResult & { export class ComponentStylesheetBundler { readonly #fileContexts = new MemoryCache(); readonly #inlineContexts = new MemoryCache(); + readonly #loadCache = new MemoryLoadResultCache(); /** - * * @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, @@ -54,29 +56,38 @@ 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); - // 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]; + } - return buildOptions; - }); + // Angular encapsulation does not support nesting + // See: https://github.com/angular/angular/issues/58996 + buildOptions.supported ??= {}; + buildOptions.supported['nesting'] = false; + + return buildOptions; + }, + /* useContext */ false, + /* initialFilter */ undefined, + this.#loadCache, + ); }); return this.extractResult( @@ -101,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(); @@ -112,48 +125,55 @@ export class ComponentStylesheetBundler { const bundlerContext = await this.#inlineContexts.getOrCreate(entry, () => { const namespace = 'angular:styles/component'; - 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}`]; - } + 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}`]; + } - // 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 buildOptions; - }); + // 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 buildOptions; + }, + /* useContext */ false, + /* initialFilter */ undefined, + this.#loadCache, + ); }); // Extract the result of the bundling from the output files @@ -170,13 +190,20 @@ 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; } - 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))); + } + } + let entries: string[] | undefined; for (const [entry, bundler] of this.#fileContexts.entries()) { @@ -190,7 +217,7 @@ export class ComponentStylesheetBundler { 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))) { + if (filename && normalizedFiles.has(path.normalize(filename))) { this.#inlineContexts.delete(entry); void bundler.dispose(); } else { @@ -214,6 +241,7 @@ export class ComponentStylesheetBundler { 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())); } @@ -268,14 +296,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, 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(); + }); + }); +});