From df3016c43f16b04b155f70d19c000361ac0b7589 Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:43:55 +0000 Subject: [PATCH 1/2] fix(@angular/build): invalidate inline stylesheet load cache and support persistent cache Ensure `ComponentStylesheetBundler.invalidate()` always invalidates `#loadCache` for all modified files, including evicting `angular:styles/component:${entry}` keys when an inline stylesheet's containing component `.ts` file is invalidated. Previously, invalidating a component `.ts` file deleted the inline `BundlerContext` without invalidating `#loadCache`, leaving stale errors (such as missing `@use` partials), stale Tailwind utility CSS, and unreferenced inline stylesheet entries in memory. Additionally, allow `ComponentStylesheetBundler` to accept an optional `LoadResultCache` and wire `PersistentLoadResultCache` in `createComponentStylesheetBundlerForLibrary` when persistent disk caching is enabled. --- .../library/pipeline/stylesheet-bundler.ts | 12 +- .../esbuild/angular/component-stylesheets.ts | 41 +++- .../angular/component-stylesheets_spec.ts | 158 +++++++++++++ .../src/tools/esbuild/load-result-cache.ts | 16 +- .../esbuild/persistent-load-result-cache.ts | 218 ++++++++++++------ 5 files changed, 365 insertions(+), 80 deletions(-) create mode 100644 packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts diff --git a/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts b/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts index 971741d7a2e2..ecdec25b1354 100644 --- a/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts @@ -7,7 +7,9 @@ */ import { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; +import { PersistentLoadResultCache } from '../../../tools/esbuild/persistent-load-result-cache'; import type { BundleStylesheetOptions } from '../../../tools/esbuild/stylesheets/bundle-options'; +import { calculateGlobalStylesheetConfigHash } from '../../../tools/esbuild/stylesheets/stylesheet-cache-key'; import type { NormalizedLibraryOptions } from '../types'; /** @@ -65,5 +67,13 @@ export function createComponentStylesheetBundlerForLibrary( tailwindConfiguration, }; - return new ComponentStylesheetBundler(bundleOptions, inlineStyleLanguage, incremental); + const loadCache = + cacheOptions.enabled && cacheOptions.path + ? new PersistentLoadResultCache( + cacheOptions.path, + calculateGlobalStylesheetConfigHash(bundleOptions), + ) + : undefined; + + return new ComponentStylesheetBundler(bundleOptions, inlineStyleLanguage, incremental, loadCache); } 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 b5d2e453b57e..67d1c341c3e1 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -12,11 +12,13 @@ import { MemoryCache } from '../../../utils/cache'; import { createContentHash } from '../../../utils/hash'; import { BundleContextResult, BundlerContext } from '../bundler-context'; import { type BuildOutputFile, BuildOutputFileType } from '../bundler-files'; -import { MemoryLoadResultCache } from '../load-result-cache'; +import { type LoadResultCache, MemoryLoadResultCache } from '../load-result-cache'; +import { PersistentLoadResultCache } from '../persistent-load-result-cache'; import { BundleStylesheetOptions, createStylesheetBundleOptions, } from '../stylesheets/bundle-options'; +import { calculateGlobalStylesheetConfigHash } from '../stylesheets/stylesheet-cache-key'; export type ComponentStylesheetResult = BundleContextResult & { contents: string; @@ -31,18 +33,29 @@ export type ComponentStylesheetResult = BundleContextResult & { export class ComponentStylesheetBundler { readonly #fileContexts = new MemoryCache(); readonly #inlineContexts = new MemoryCache(); - readonly #loadCache = new MemoryLoadResultCache(); + readonly #loadCache: LoadResultCache; /** * @param options An object containing the stylesheet bundling options. * @param defaultInlineLanguage The default language to use for inline component styles. * @param incremental True if incremental watch mode is enabled. + * @param loadCache Optional cache instance to use for stylesheet load results. */ constructor( private readonly options: BundleStylesheetOptions, private readonly defaultInlineLanguage: string, private readonly incremental: boolean, - ) {} + loadCache?: LoadResultCache, + ) { + this.#loadCache = + loadCache ?? + (options.cacheOptions.enabled && options.cacheOptions.path + ? new PersistentLoadResultCache( + options.cacheOptions.path, + calculateGlobalStylesheetConfigHash(options), + ) + : new MemoryLoadResultCache()); + } /** * Bundle a file-based component stylesheet for use within an AOT compiled Angular application. @@ -217,14 +230,27 @@ 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 && normalizedFiles.has(path.normalize(filename))) { + const normalizedFilename = filename ? path.normalize(filename) : ''; + if ( + normalizedFilename && + (normalizedFiles.has(normalizedFilename) || + (!path.isAbsolute(normalizedFilename) && + normalizedFiles.has( + path.normalize(path.join(this.options.workspaceRoot, normalizedFilename)), + ))) + ) { this.#inlineContexts.delete(entry); + this.#loadCache.invalidate(`angular:styles/component:${entry}`); void bundler.dispose().catch(() => {}); } else { bundler.invalidate(normalizedFiles); } } + for (const file of normalizedFiles) { + this.#loadCache.invalidate(file); + } + return entries; } @@ -244,9 +270,12 @@ export class ComponentStylesheetBundler { const contexts = [...this.#fileContexts.values(), ...this.#inlineContexts.values()]; this.#fileContexts.clear(); this.#inlineContexts.clear(); - this.#loadCache.clear(); + this.#loadCache.clear?.(); - await Promise.allSettled(contexts.map((context) => context.dispose())); + await Promise.allSettled([ + ...contexts.map((context) => context.dispose()), + this.#loadCache.dispose?.(), + ]); } private extractResult( diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts new file mode 100644 index 000000000000..2ecefadb3f70 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts @@ -0,0 +1,158 @@ +/** + * @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 assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createComponentStylesheetBundlerForLibrary } from '../../../builders/library/pipeline/stylesheet-bundler'; +import { initializeHash } from '../../../utils/hash'; +import { MemoryLoadResultCache } from '../load-result-cache'; +import { resetSassWorkerPoolCaches, shutdownSassWorkerPool } from '../stylesheets/sass-language'; +import { ComponentStylesheetBundler } from './component-stylesheets'; + +describe('ComponentStylesheetBundler', () => { + let tempRoot: string; + + beforeAll(async () => { + await initializeHash(); + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-component-stylesheets-')); + }); + + afterAll(async () => { + shutdownSassWorkerPool(); + await rm(tempRoot, { force: true, recursive: true }); + }); + + it('invalidates cached inline stylesheet errors when the containing component .ts file is invalidated', async () => { + const testDir = await mkdtemp(join(tempRoot, 'inline-error-recovery-')); + const componentFile = join(testDir, 'comp.ts'); + const partialFile = join(testDir, '_missing.scss'); + const loadCache = new MemoryLoadResultCache(); + + const bundler = new ComponentStylesheetBundler( + { + workspaceRoot: testDir, + optimization: false, + inlineFonts: false, + sourcemap: false, + outputNames: { bundles: '[name]', media: 'media/[name]' }, + target: ['es2022'], + cacheOptions: { enabled: false, path: '', basePath: testDir }, + }, + 'scss', + true, + loadCache, + ); + + try { + const initialResult = await bundler.bundleInline('@use "./missing";', componentFile, 'scss'); + assert( + initialResult.errors, + 'Expected initial bundleInline to fail when _missing.scss does not exist', + ); + expect(initialResult.errors.length).toBeGreaterThan(0); + + await writeFile(partialFile, '.recovered { color: green; }'); + resetSassWorkerPoolCaches(); + + bundler.invalidate([componentFile]); + + const recoveredResult = await bundler.bundleInline( + '@use "./missing";', + componentFile, + 'scss', + ); + expect(recoveredResult.errors).toBeUndefined(); + expect(recoveredResult.contents).toContain('.recovered'); + expect(recoveredResult.contents).toContain('green'); + } finally { + await bundler.dispose(); + } + }); + + it('evicts angular:styles/component loadCache entries when the containing component .ts file is invalidated', async () => { + const testDir = await mkdtemp(join(tempRoot, 'inline-evict-')); + const componentFile = join(testDir, 'comp.ts'); + const loadCache = new MemoryLoadResultCache(); + const putSpy = spyOn(loadCache, 'put').and.callThrough(); + + const bundler = new ComponentStylesheetBundler( + { + workspaceRoot: testDir, + optimization: false, + inlineFonts: false, + sourcemap: false, + outputNames: { bundles: '[name]', media: 'media/[name]' }, + target: ['es2022'], + cacheOptions: { enabled: false, path: '', basePath: testDir }, + }, + 'scss', + true, + loadCache, + ); + + try { + const result = await bundler.bundleInline('.btn { color: red; }', componentFile, 'scss'); + expect(result.errors).toBeUndefined(); + expect(putSpy).toHaveBeenCalledTimes(1); + + const cacheKey = putSpy.calls.mostRecent().args[0]; + expect(cacheKey).toMatch(/^angular:styles\/component:scss;/); + expect(loadCache.get(cacheKey)).toBeDefined(); + + bundler.invalidate([componentFile]); + + expect(loadCache.get(cacheKey)).toBeUndefined(); + } finally { + await bundler.dispose(); + } + }); + + it('persists and reuses stylesheet load results across builds when persistent cache is enabled', async () => { + const testDir = await mkdtemp(join(tempRoot, 'persistent-cache-')); + const cacheDir = join(testDir, '.angular', 'cache'); + await mkdir(testDir, { recursive: true }); + const styleFile = join(testDir, 'comp.scss'); + await writeFile(styleFile, '$primary: blue;\n.card { color: $primary; }\n'); + + const options = { + workspaceRoot: testDir, + preserveSymlinks: false, + styleIncludePaths: [], + sass: undefined, + cacheOptions: { + enabled: true, + path: cacheDir, + basePath: cacheDir, + }, + inlineStyleLanguage: 'scss' as const, + postcssConfiguration: undefined, + tailwindConfiguration: undefined, + }; + + const firstBundler = createComponentStylesheetBundlerForLibrary(options, false, ['es2022']); + try { + const firstResult = await firstBundler.bundleFile(styleFile); + expect(firstResult.errors).toBeUndefined(); + expect(firstResult.contents).toContain('.card{color:#00f}'); + } finally { + await firstBundler.dispose(); + } + + const secondBundler = createComponentStylesheetBundlerForLibrary(options, false, ['es2022']); + try { + const secondResult = await secondBundler.bundleFile(styleFile); + expect(secondResult.errors).toBeUndefined(); + expect(secondResult.contents).toContain('.card{color:#00f}'); + } finally { + await secondBundler.dispose(); + } + }); +}); 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 402069cff1a5..13af0ba48b78 100644 --- a/packages/angular/build/src/tools/esbuild/load-result-cache.ts +++ b/packages/angular/build/src/tools/esbuild/load-result-cache.ts @@ -13,6 +13,8 @@ export interface LoadResultCache { get(path: string): OnLoadResult | Promise | undefined; put(path: string, result: OnLoadResult): Promise; invalidate(path: string): boolean; + clear?(): void; + dispose?(): Promise | void; readonly watchFiles: ReadonlyArray; } @@ -108,16 +110,16 @@ export class MemoryLoadResultCache implements LoadResultCache { } invalidate(path: string): boolean { + let invalidated = this.#loadResults.delete(path); const affectedPaths = this.#fileDependencies.get(path); - if (!affectedPaths) { - return false; - } - - for (const affected of affectedPaths) { - this.#loadResults.delete(affected); + if (affectedPaths) { + for (const affected of affectedPaths) { + this.#loadResults.delete(affected); + } + invalidated = true; } - return true; + return invalidated; } get watchFiles(): string[] { diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts index 7eaf11806f1d..7efd4c963722 100644 --- a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts +++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts @@ -28,10 +28,15 @@ */ import type { Loader, OnLoadResult, PartialMessage } from 'esbuild'; +import { existsSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; -import { isAbsolute } from 'node:path'; +import { isAbsolute, join, normalize } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { Cache as PersistentCacheStore } from '../../utils/cache'; +import { + type Cache as PersistentCacheStore, + type PersistentCacheStore as RawPersistentCacheStore, + createPersistentCacheStore, +} from '../../utils/cache'; import { MAX_CONCURRENT_READS, mapConcurrent, runConcurrent } from '../../utils/concurrency'; import { calculateHash, createContentHash } from '../../utils/hash'; import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache'; @@ -185,25 +190,27 @@ async function validateAndHealCacheEntry( */ async function computeMetadataForWatchFiles( watchFiles: string[], - knownContents?: Map, + metadataCache: Map>, ): Promise> { const watchFilesMetadata: Record = {}; await runConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => { - try { - const knownContent = knownContents?.get(filePath); - const [content, stats] = await Promise.all([ - knownContent !== undefined ? knownContent : readFile(filePath), - stat(filePath), - ]); - const hash = calculateHash(content); - watchFilesMetadata[filePath] = { - hash, - mtimeMs: stats.mtimeMs, - size: stats.size, - }; - } catch { - // Ignore unreadable files + let metadataPromise = metadataCache.get(filePath); + if (!metadataPromise) { + metadataPromise = Promise.all([readFile(filePath), stat(filePath)]).then( + ([content, stats]) => ({ + hash: calculateHash(content), + mtimeMs: stats.mtimeMs, + size: stats.size, + }), + () => undefined, + ); + metadataCache.set(filePath, metadataPromise); + } + + const metadata = await metadataPromise; + if (metadata) { + watchFilesMetadata[filePath] = metadata; } }); @@ -211,12 +218,62 @@ async function computeMetadataForWatchFiles( } export class PersistentLoadResultCache implements LoadResultCache { - private readonly memoryCache = new MemoryLoadResultCache(); + readonly #memoryCache = new MemoryLoadResultCache(); + readonly #globalConfigHash: string; + readonly #cachePath?: string; + readonly #dependencyMetadataCache = new Map< + string, + Promise + >(); + #persistentStore?: PersistentCacheStore; + #rawStore?: RawPersistentCacheStore; + #storePromise?: Promise | undefined>; + #storeInitFailed = false; + #canHitL2: boolean; + #hasStoredEntries = false; constructor( - private readonly persistentStore?: PersistentCacheStore, - private readonly globalConfigHash: string = '', - ) {} + persistentStoreOrPath?: PersistentCacheStore | string, + globalConfigHash: string = '', + ) { + this.#globalConfigHash = globalConfigHash; + if (typeof persistentStoreOrPath === 'string') { + this.#cachePath = persistentStoreOrPath; + const baseCachePath = join(persistentStoreOrPath, 'angular-styles'); + this.#canHitL2 = + existsSync(baseCachePath + '.db') || existsSync(baseCachePath + '-sqlite.db'); + } else { + this.#persistentStore = persistentStoreOrPath; + this.#canHitL2 = !!persistentStoreOrPath; + } + } + + async #getStore(): Promise | undefined> { + if (this.#persistentStore) { + return this.#persistentStore; + } + + if (!this.#cachePath || this.#storeInitFailed) { + return undefined; + } + + this.#storePromise ??= createPersistentCacheStore(join(this.#cachePath, 'angular-styles')).then( + (store) => { + this.#rawStore = store; + this.#persistentStore = store.createCache('load-results'); + + return this.#persistentStore; + }, + () => { + this.#storeInitFailed = true; + this.#storePromise = undefined; + + return undefined; + }, + ); + + return this.#storePromise; + } /** * Retrieves a load result from cache. @@ -225,27 +282,27 @@ export class PersistentLoadResultCache implements LoadResultCache { */ async get(path: string): Promise { // 1. Check L1 Memory Cache - const memoryResult = this.memoryCache.get(path); + const memoryResult = this.#memoryCache.get(path); if (memoryResult) { return memoryResult; } - if (!this.persistentStore) { + if (!this.#canHitL2) { + return undefined; + } + + const store = await this.#getStore(); + if (!store) { return undefined; } // 2. Check L2 Persistent Disk Cache - const cacheKey = calculateCacheKey(this.globalConfigHash, path); - const cached = await this.persistentStore.get(cacheKey); + const cacheKey = calculateCacheKey(this.#globalConfigHash, path); + const cached = await store.get(cacheKey); if ( cached && - (await validateAndHealCacheEntry( - cached.watchFilesMetadata, - this.persistentStore, - cacheKey, - cached, - )) + (await validateAndHealCacheEntry(cached.watchFilesMetadata, store, cacheKey, cached)) ) { const result: OnLoadResult = { contents: cached.contents, @@ -257,7 +314,7 @@ export class PersistentLoadResultCache implements LoadResultCache { }; // Populate L1 Memory Cache for subsequent lookups - await this.memoryCache.put(path, result); + await this.#memoryCache.put(path, result); return result; } @@ -269,43 +326,44 @@ export class PersistentLoadResultCache implements LoadResultCache { * Stores a load result in both L1 memory cache and L2 persistent disk store. */ async put(path: string, result: OnLoadResult): Promise { - await this.memoryCache.put(path, result); + await this.#memoryCache.put(path, result); // Persist to L2 store if persistentStore is configured and contents exist (including empty strings/buffers) - if (this.persistentStore && result.contents !== undefined) { - let content: string | Uint8Array = ''; - const filePath = extractDiskFilePath(path); - if (filePath) { - try { - content = await readFile(filePath); - } catch { - // Skip L2 persistent store if target disk file cannot be read - return; - } - } + if (result.contents === undefined) { + return; + } - const cacheKey = calculateCacheKey(this.globalConfigHash, path); + const store = await this.#getStore(); + if (!store) { + return; + } - // Reuse the target file's pre-read content buffer to avoid redundant disk reads (readFile) - // during dependency watch file metadata computation. - const knownContents = filePath - ? new Map([[filePath, content]]) - : undefined; - const allWatchFiles = Array.from( - new Set(filePath ? [filePath, ...(result.watchFiles ?? [])] : result.watchFiles), - ); - const watchFilesMetadata = await computeMetadataForWatchFiles(allWatchFiles, knownContents); - - await this.persistentStore.put(cacheKey, { - contents: result.contents, - loader: result.loader, - watchFiles: result.watchFiles ?? [], - watchDirs: result.watchDirs, - watchFilesMetadata, - warnings: result.warnings, - errors: result.errors, - }); + const filePath = extractDiskFilePath(path); + const allWatchFiles = Array.from( + new Set(filePath ? [filePath, ...(result.watchFiles ?? [])] : result.watchFiles), + ); + const watchFilesMetadata = await computeMetadataForWatchFiles( + allWatchFiles, + this.#dependencyMetadataCache, + ); + + if (filePath && !watchFilesMetadata[filePath]) { + // Skip L2 persistent store if target disk file cannot be read + return; } + + const cacheKey = calculateCacheKey(this.#globalConfigHash, path); + + await store.put(cacheKey, { + contents: result.contents, + loader: result.loader, + watchFiles: result.watchFiles ?? [], + watchDirs: result.watchDirs, + watchFilesMetadata, + warnings: result.warnings, + errors: result.errors, + }); + this.#hasStoredEntries = true; } /** @@ -316,10 +374,38 @@ export class PersistentLoadResultCache implements LoadResultCache { * during `get()` via dependency metadata verification (`validateAndHealCacheEntry`). */ invalidate(path: string): boolean { - return this.memoryCache.invalidate(path); + this.#dependencyMetadataCache.delete(normalize(path)); + this.#dependencyMetadataCache.delete(path); + if (this.#hasStoredEntries) { + this.#canHitL2 = true; + } + + return this.#memoryCache.invalidate(path); + } + + clear(): void { + this.#dependencyMetadataCache.clear(); + if (this.#hasStoredEntries) { + this.#canHitL2 = true; + } + this.#memoryCache.clear(); + } + + async dispose(): Promise { + this.clear(); + const rawStore = + this.#rawStore ?? + (await this.#storePromise?.then( + () => this.#rawStore, + () => undefined, + )); + this.#rawStore = undefined; + this.#persistentStore = undefined; + this.#storePromise = undefined; + await rawStore?.close(); } get watchFiles(): ReadonlyArray { - return this.memoryCache.watchFiles; + return this.#memoryCache.watchFiles; } } From 3e3f1efe2e918e7d2324717c27f5d15e8b37933b Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:46:14 +0000 Subject: [PATCH 2/2] fixup! fix(@angular/build): invalidate inline stylesheet load cache and support persistent cache --- .../library/pipeline/stylesheet-bundler.ts | 12 +- .../esbuild/angular/component-stylesheets.ts | 26 +-- .../angular/component-stylesheets_spec.ts | 63 ++--- .../src/tools/esbuild/load-result-cache.ts | 2 - .../esbuild/persistent-load-result-cache.ts | 218 ++++++------------ 5 files changed, 85 insertions(+), 236 deletions(-) diff --git a/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts b/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts index ecdec25b1354..971741d7a2e2 100644 --- a/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts +++ b/packages/angular/build/src/builders/library/pipeline/stylesheet-bundler.ts @@ -7,9 +7,7 @@ */ import { ComponentStylesheetBundler } from '../../../tools/esbuild/angular/component-stylesheets'; -import { PersistentLoadResultCache } from '../../../tools/esbuild/persistent-load-result-cache'; import type { BundleStylesheetOptions } from '../../../tools/esbuild/stylesheets/bundle-options'; -import { calculateGlobalStylesheetConfigHash } from '../../../tools/esbuild/stylesheets/stylesheet-cache-key'; import type { NormalizedLibraryOptions } from '../types'; /** @@ -67,13 +65,5 @@ export function createComponentStylesheetBundlerForLibrary( tailwindConfiguration, }; - const loadCache = - cacheOptions.enabled && cacheOptions.path - ? new PersistentLoadResultCache( - cacheOptions.path, - calculateGlobalStylesheetConfigHash(bundleOptions), - ) - : undefined; - - return new ComponentStylesheetBundler(bundleOptions, inlineStyleLanguage, incremental, loadCache); + return new ComponentStylesheetBundler(bundleOptions, inlineStyleLanguage, incremental); } 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 67d1c341c3e1..3bb63b082a28 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets.ts @@ -12,13 +12,11 @@ import { MemoryCache } from '../../../utils/cache'; import { createContentHash } from '../../../utils/hash'; import { BundleContextResult, BundlerContext } from '../bundler-context'; import { type BuildOutputFile, BuildOutputFileType } from '../bundler-files'; -import { type LoadResultCache, MemoryLoadResultCache } from '../load-result-cache'; -import { PersistentLoadResultCache } from '../persistent-load-result-cache'; +import { MemoryLoadResultCache } from '../load-result-cache'; import { BundleStylesheetOptions, createStylesheetBundleOptions, } from '../stylesheets/bundle-options'; -import { calculateGlobalStylesheetConfigHash } from '../stylesheets/stylesheet-cache-key'; export type ComponentStylesheetResult = BundleContextResult & { contents: string; @@ -33,29 +31,18 @@ export type ComponentStylesheetResult = BundleContextResult & { export class ComponentStylesheetBundler { readonly #fileContexts = new MemoryCache(); readonly #inlineContexts = new MemoryCache(); - readonly #loadCache: LoadResultCache; + readonly #loadCache = new MemoryLoadResultCache(); /** * @param options An object containing the stylesheet bundling options. * @param defaultInlineLanguage The default language to use for inline component styles. * @param incremental True if incremental watch mode is enabled. - * @param loadCache Optional cache instance to use for stylesheet load results. */ constructor( private readonly options: BundleStylesheetOptions, private readonly defaultInlineLanguage: string, private readonly incremental: boolean, - loadCache?: LoadResultCache, - ) { - this.#loadCache = - loadCache ?? - (options.cacheOptions.enabled && options.cacheOptions.path - ? new PersistentLoadResultCache( - options.cacheOptions.path, - calculateGlobalStylesheetConfigHash(options), - ) - : new MemoryLoadResultCache()); - } + ) {} /** * Bundle a file-based component stylesheet for use within an AOT compiled Angular application. @@ -270,12 +257,9 @@ export class ComponentStylesheetBundler { const contexts = [...this.#fileContexts.values(), ...this.#inlineContexts.values()]; this.#fileContexts.clear(); this.#inlineContexts.clear(); - this.#loadCache.clear?.(); + this.#loadCache.clear(); - await Promise.allSettled([ - ...contexts.map((context) => context.dispose()), - this.#loadCache.dispose?.(), - ]); + await Promise.allSettled(contexts.map((context) => context.dispose())); } private extractResult( diff --git a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts index 2ecefadb3f70..6ae372ee1d57 100644 --- a/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts +++ b/packages/angular/build/src/tools/esbuild/angular/component-stylesheets_spec.ts @@ -7,9 +7,8 @@ */ import assert from 'node:assert'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { createComponentStylesheetBundlerForLibrary } from '../../../builders/library/pipeline/stylesheet-bundler'; import { initializeHash } from '../../../utils/hash'; import { MemoryLoadResultCache } from '../load-result-cache'; import { resetSassWorkerPoolCaches, shutdownSassWorkerPool } from '../stylesheets/sass-language'; @@ -34,7 +33,6 @@ describe('ComponentStylesheetBundler', () => { const testDir = await mkdtemp(join(tempRoot, 'inline-error-recovery-')); const componentFile = join(testDir, 'comp.ts'); const partialFile = join(testDir, '_missing.scss'); - const loadCache = new MemoryLoadResultCache(); const bundler = new ComponentStylesheetBundler( { @@ -48,7 +46,6 @@ describe('ComponentStylesheetBundler', () => { }, 'scss', true, - loadCache, ); try { @@ -77,11 +74,11 @@ describe('ComponentStylesheetBundler', () => { } }); - it('evicts angular:styles/component loadCache entries when the containing component .ts file is invalidated', async () => { + it('should evict #loadCache entries when invalidating inline component stylesheets', async () => { const testDir = await mkdtemp(join(tempRoot, 'inline-evict-')); const componentFile = join(testDir, 'comp.ts'); - const loadCache = new MemoryLoadResultCache(); - const putSpy = spyOn(loadCache, 'put').and.callThrough(); + const putSpy = spyOn(MemoryLoadResultCache.prototype, 'put').and.callThrough(); + const invalidateSpy = spyOn(MemoryLoadResultCache.prototype, 'invalidate').and.callThrough(); const bundler = new ComponentStylesheetBundler( { @@ -95,7 +92,6 @@ describe('ComponentStylesheetBundler', () => { }, 'scss', true, - loadCache, ); try { @@ -105,54 +101,21 @@ describe('ComponentStylesheetBundler', () => { const cacheKey = putSpy.calls.mostRecent().args[0]; expect(cacheKey).toMatch(/^angular:styles\/component:scss;/); - expect(loadCache.get(cacheKey)).toBeDefined(); bundler.invalidate([componentFile]); - expect(loadCache.get(cacheKey)).toBeUndefined(); - } finally { - await bundler.dispose(); - } - }); + expect(invalidateSpy).toHaveBeenCalledWith(cacheKey); + expect(invalidateSpy).toHaveBeenCalledWith(componentFile); - it('persists and reuses stylesheet load results across builds when persistent cache is enabled', async () => { - const testDir = await mkdtemp(join(tempRoot, 'persistent-cache-')); - const cacheDir = join(testDir, '.angular', 'cache'); - await mkdir(testDir, { recursive: true }); - const styleFile = join(testDir, 'comp.scss'); - await writeFile(styleFile, '$primary: blue;\n.card { color: $primary; }\n'); - - const options = { - workspaceRoot: testDir, - preserveSymlinks: false, - styleIncludePaths: [], - sass: undefined, - cacheOptions: { - enabled: true, - path: cacheDir, - basePath: cacheDir, - }, - inlineStyleLanguage: 'scss' as const, - postcssConfiguration: undefined, - tailwindConfiguration: undefined, - }; - - const firstBundler = createComponentStylesheetBundlerForLibrary(options, false, ['es2022']); - try { - const firstResult = await firstBundler.bundleFile(styleFile); - expect(firstResult.errors).toBeUndefined(); - expect(firstResult.contents).toContain('.card{color:#00f}'); - } finally { - await firstBundler.dispose(); - } - - const secondBundler = createComponentStylesheetBundlerForLibrary(options, false, ['es2022']); - try { - const secondResult = await secondBundler.bundleFile(styleFile); + const secondResult = await bundler.bundleInline( + '.btn { color: red; }', + componentFile, + 'scss', + ); expect(secondResult.errors).toBeUndefined(); - expect(secondResult.contents).toContain('.card{color:#00f}'); + expect(putSpy).toHaveBeenCalledTimes(2); } finally { - await secondBundler.dispose(); + await bundler.dispose(); } }); }); 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 13af0ba48b78..9aff3c66d540 100644 --- a/packages/angular/build/src/tools/esbuild/load-result-cache.ts +++ b/packages/angular/build/src/tools/esbuild/load-result-cache.ts @@ -13,8 +13,6 @@ export interface LoadResultCache { get(path: string): OnLoadResult | Promise | undefined; put(path: string, result: OnLoadResult): Promise; invalidate(path: string): boolean; - clear?(): void; - dispose?(): Promise | void; readonly watchFiles: ReadonlyArray; } diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts index 7efd4c963722..7eaf11806f1d 100644 --- a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts +++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts @@ -28,15 +28,10 @@ */ import type { Loader, OnLoadResult, PartialMessage } from 'esbuild'; -import { existsSync } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; -import { isAbsolute, join, normalize } from 'node:path'; +import { isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - type Cache as PersistentCacheStore, - type PersistentCacheStore as RawPersistentCacheStore, - createPersistentCacheStore, -} from '../../utils/cache'; +import type { Cache as PersistentCacheStore } from '../../utils/cache'; import { MAX_CONCURRENT_READS, mapConcurrent, runConcurrent } from '../../utils/concurrency'; import { calculateHash, createContentHash } from '../../utils/hash'; import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache'; @@ -190,27 +185,25 @@ async function validateAndHealCacheEntry( */ async function computeMetadataForWatchFiles( watchFiles: string[], - metadataCache: Map>, + knownContents?: Map, ): Promise> { const watchFilesMetadata: Record = {}; await runConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => { - let metadataPromise = metadataCache.get(filePath); - if (!metadataPromise) { - metadataPromise = Promise.all([readFile(filePath), stat(filePath)]).then( - ([content, stats]) => ({ - hash: calculateHash(content), - mtimeMs: stats.mtimeMs, - size: stats.size, - }), - () => undefined, - ); - metadataCache.set(filePath, metadataPromise); - } - - const metadata = await metadataPromise; - if (metadata) { - watchFilesMetadata[filePath] = metadata; + try { + const knownContent = knownContents?.get(filePath); + const [content, stats] = await Promise.all([ + knownContent !== undefined ? knownContent : readFile(filePath), + stat(filePath), + ]); + const hash = calculateHash(content); + watchFilesMetadata[filePath] = { + hash, + mtimeMs: stats.mtimeMs, + size: stats.size, + }; + } catch { + // Ignore unreadable files } }); @@ -218,62 +211,12 @@ async function computeMetadataForWatchFiles( } export class PersistentLoadResultCache implements LoadResultCache { - readonly #memoryCache = new MemoryLoadResultCache(); - readonly #globalConfigHash: string; - readonly #cachePath?: string; - readonly #dependencyMetadataCache = new Map< - string, - Promise - >(); - #persistentStore?: PersistentCacheStore; - #rawStore?: RawPersistentCacheStore; - #storePromise?: Promise | undefined>; - #storeInitFailed = false; - #canHitL2: boolean; - #hasStoredEntries = false; + private readonly memoryCache = new MemoryLoadResultCache(); constructor( - persistentStoreOrPath?: PersistentCacheStore | string, - globalConfigHash: string = '', - ) { - this.#globalConfigHash = globalConfigHash; - if (typeof persistentStoreOrPath === 'string') { - this.#cachePath = persistentStoreOrPath; - const baseCachePath = join(persistentStoreOrPath, 'angular-styles'); - this.#canHitL2 = - existsSync(baseCachePath + '.db') || existsSync(baseCachePath + '-sqlite.db'); - } else { - this.#persistentStore = persistentStoreOrPath; - this.#canHitL2 = !!persistentStoreOrPath; - } - } - - async #getStore(): Promise | undefined> { - if (this.#persistentStore) { - return this.#persistentStore; - } - - if (!this.#cachePath || this.#storeInitFailed) { - return undefined; - } - - this.#storePromise ??= createPersistentCacheStore(join(this.#cachePath, 'angular-styles')).then( - (store) => { - this.#rawStore = store; - this.#persistentStore = store.createCache('load-results'); - - return this.#persistentStore; - }, - () => { - this.#storeInitFailed = true; - this.#storePromise = undefined; - - return undefined; - }, - ); - - return this.#storePromise; - } + private readonly persistentStore?: PersistentCacheStore, + private readonly globalConfigHash: string = '', + ) {} /** * Retrieves a load result from cache. @@ -282,27 +225,27 @@ export class PersistentLoadResultCache implements LoadResultCache { */ async get(path: string): Promise { // 1. Check L1 Memory Cache - const memoryResult = this.#memoryCache.get(path); + const memoryResult = this.memoryCache.get(path); if (memoryResult) { return memoryResult; } - if (!this.#canHitL2) { - return undefined; - } - - const store = await this.#getStore(); - if (!store) { + if (!this.persistentStore) { return undefined; } // 2. Check L2 Persistent Disk Cache - const cacheKey = calculateCacheKey(this.#globalConfigHash, path); - const cached = await store.get(cacheKey); + const cacheKey = calculateCacheKey(this.globalConfigHash, path); + const cached = await this.persistentStore.get(cacheKey); if ( cached && - (await validateAndHealCacheEntry(cached.watchFilesMetadata, store, cacheKey, cached)) + (await validateAndHealCacheEntry( + cached.watchFilesMetadata, + this.persistentStore, + cacheKey, + cached, + )) ) { const result: OnLoadResult = { contents: cached.contents, @@ -314,7 +257,7 @@ export class PersistentLoadResultCache implements LoadResultCache { }; // Populate L1 Memory Cache for subsequent lookups - await this.#memoryCache.put(path, result); + await this.memoryCache.put(path, result); return result; } @@ -326,44 +269,43 @@ export class PersistentLoadResultCache implements LoadResultCache { * Stores a load result in both L1 memory cache and L2 persistent disk store. */ async put(path: string, result: OnLoadResult): Promise { - await this.#memoryCache.put(path, result); + await this.memoryCache.put(path, result); // Persist to L2 store if persistentStore is configured and contents exist (including empty strings/buffers) - if (result.contents === undefined) { - return; - } + if (this.persistentStore && result.contents !== undefined) { + let content: string | Uint8Array = ''; + const filePath = extractDiskFilePath(path); + if (filePath) { + try { + content = await readFile(filePath); + } catch { + // Skip L2 persistent store if target disk file cannot be read + return; + } + } - const store = await this.#getStore(); - if (!store) { - return; - } + const cacheKey = calculateCacheKey(this.globalConfigHash, path); - const filePath = extractDiskFilePath(path); - const allWatchFiles = Array.from( - new Set(filePath ? [filePath, ...(result.watchFiles ?? [])] : result.watchFiles), - ); - const watchFilesMetadata = await computeMetadataForWatchFiles( - allWatchFiles, - this.#dependencyMetadataCache, - ); - - if (filePath && !watchFilesMetadata[filePath]) { - // Skip L2 persistent store if target disk file cannot be read - return; + // Reuse the target file's pre-read content buffer to avoid redundant disk reads (readFile) + // during dependency watch file metadata computation. + const knownContents = filePath + ? new Map([[filePath, content]]) + : undefined; + const allWatchFiles = Array.from( + new Set(filePath ? [filePath, ...(result.watchFiles ?? [])] : result.watchFiles), + ); + const watchFilesMetadata = await computeMetadataForWatchFiles(allWatchFiles, knownContents); + + await this.persistentStore.put(cacheKey, { + contents: result.contents, + loader: result.loader, + watchFiles: result.watchFiles ?? [], + watchDirs: result.watchDirs, + watchFilesMetadata, + warnings: result.warnings, + errors: result.errors, + }); } - - const cacheKey = calculateCacheKey(this.#globalConfigHash, path); - - await store.put(cacheKey, { - contents: result.contents, - loader: result.loader, - watchFiles: result.watchFiles ?? [], - watchDirs: result.watchDirs, - watchFilesMetadata, - warnings: result.warnings, - errors: result.errors, - }); - this.#hasStoredEntries = true; } /** @@ -374,38 +316,10 @@ export class PersistentLoadResultCache implements LoadResultCache { * during `get()` via dependency metadata verification (`validateAndHealCacheEntry`). */ invalidate(path: string): boolean { - this.#dependencyMetadataCache.delete(normalize(path)); - this.#dependencyMetadataCache.delete(path); - if (this.#hasStoredEntries) { - this.#canHitL2 = true; - } - - return this.#memoryCache.invalidate(path); - } - - clear(): void { - this.#dependencyMetadataCache.clear(); - if (this.#hasStoredEntries) { - this.#canHitL2 = true; - } - this.#memoryCache.clear(); - } - - async dispose(): Promise { - this.clear(); - const rawStore = - this.#rawStore ?? - (await this.#storePromise?.then( - () => this.#rawStore, - () => undefined, - )); - this.#rawStore = undefined; - this.#persistentStore = undefined; - this.#storePromise = undefined; - await rawStore?.close(); + return this.memoryCache.invalidate(path); } get watchFiles(): ReadonlyArray { - return this.#memoryCache.watchFiles; + return this.memoryCache.watchFiles; } }