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 30067486a384..5ad744c401b5 100644 --- a/packages/angular/build/src/tools/esbuild/load-result-cache.ts +++ b/packages/angular/build/src/tools/esbuild/load-result-cache.ts @@ -10,7 +10,7 @@ import type { OnLoadResult, PluginBuild } from 'esbuild'; import { normalize } from 'node:path'; export interface LoadResultCache { - get(path: string): OnLoadResult | undefined; + get(path: string): OnLoadResult | Promise | undefined; put(path: string, result: OnLoadResult): Promise; readonly watchFiles: ReadonlyArray; } @@ -25,7 +25,7 @@ export function createCachedLoad( return async (args) => { const loadCacheKey = `${args.namespace}:${args.path}`; - let result: OnLoadResult | null | undefined = cache.get(loadCacheKey); + let result: OnLoadResult | null | undefined = await cache.get(loadCacheKey); if (result === undefined) { result = await callback(args); @@ -35,7 +35,9 @@ export function createCachedLoad( // Ensure requested path is included if it was a resolved file if (args.namespace === 'file') { result.watchFiles ??= []; - result.watchFiles.push(args.path); + if (!result.watchFiles.includes(args.path)) { + result.watchFiles.push(args.path); + } } await cache.put(loadCacheKey, result); } 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 new file mode 100644 index 000000000000..82f2b70ed25d --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache.ts @@ -0,0 +1,383 @@ +/** + * @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 + */ + +/** + * @fileoverview + * Implements a generic, two-tier (L1 memory + L2 persistent disk store) caching system for + * esbuild plugin `OnLoadResult` outcomes across the `@angular/build` compiler pipeline. + * + * Supported Module Types: + * 1. **Disk File Modules** (`file:` namespace): Standard disk-based source files (TS, JS, CSS, Sass, Less). + * Cache keys are computed using the root `globalConfigHash`, file path, and file content. Validity is + * verified via fast-path metadata (`mtimeMs` + `size`) with fallback to content hashing (`sha256`). + * 2. **Custom Plugin Namespace Modules** (e.g. `angular:script/global`, `sass:`): Modules loaded through + * custom esbuild namespaces that resolve to disk source files. + * 3. **Virtual Modules & Remote Resources** (e.g. `angular:styles/component`, `css-inline-fonts`): Synthetic + * in-memory modules or remote asset declarations whose compiled outcomes depend on parent source file + * dependencies (`watchFiles`) or global configuration options (`globalConfigHash`). + * + * Key Exported Types: + * - {@link PersistentLoadResultCache}: Primary two-tier cache manager implementing `LoadResultCache`. + * - {@link CachedLoadResultEntry}: Serialized structure persisted to disk for cached esbuild `OnLoadResult` items. + * - {@link CachedDependencyMetadata}: Per-dependency file metadata (`hash`, `mtimeMs`, `size`) used for cache validation and healing. + */ + +import type { Loader, OnLoadResult, PartialMessage } from 'esbuild'; +import { createHash } from 'node:crypto'; +import { readFile, stat } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Cache as PersistentCacheStore } from './cache'; +import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache'; + +/** + * Metadata for a single watch file dependency. + */ +export interface CachedDependencyMetadata { + hash: string; + mtimeMs: number; + size: number; +} + +/** + * Serialized representation of any esbuild load result stored in persistent cache. + */ +export interface CachedLoadResultEntry { + /** Compiled output string or binary data */ + contents: string | Uint8Array; + + /** esbuild loader type */ + loader?: Loader; + + /** Absolute paths of all imported/watched dependency files */ + watchFiles: string[]; + + /** Absolute paths of all watched directories */ + watchDirs?: string[]; + + /** Map of watchFile absolute paths to dependency metadata */ + watchFilesMetadata: Record; + + /** Warnings emitted during load processing */ + warnings?: PartialMessage[]; + + /** Errors emitted during load processing */ + errors?: PartialMessage[]; +} + +function hashContent(content: string | Uint8Array): string { + return createHash('sha256').update(content).digest('hex'); +} + +/** + * Calculates a unique cache key by updating the hash incrementally. + * This prevents implicit string coercion of large binary content buffers. + */ +function calculateCacheKey( + globalConfigHash: string, + path: string, + content: string | Uint8Array, +): string { + return createHash('sha256') + .update(globalConfigHash) + .update('\0') + .update(path) + .update('\0') + .update(content) + .digest('hex'); +} + +/** + * Normalizes a namespaced cache key into a valid disk file path if one exists. + * Handles 'file:' URIs, OS platform differences, and custom plugin namespaces. + */ +export function extractDiskFilePath(path: string): string | undefined { + if (path.startsWith('file:')) { + const urlStr = path.startsWith('file://') ? path : 'file://' + path.slice(5); + try { + return fileURLToPath(urlStr); + } catch { + const candidate = path.slice(5); + + return isAbsolute(candidate) ? candidate : undefined; + } + } + + // Handle custom namespace prefix (e.g. "sass:/path/to/file") + // Ensure colonIndex > 1 to avoid treating Windows drive letters (e.g. "C:/") as namespace prefixes. + const colonIndex = path.indexOf(':'); + if (colonIndex > 1) { + const candidatePath = path.slice(colonIndex + 1); + if (isAbsolute(candidatePath)) { + return candidatePath; + } + } + + return isAbsolute(path) ? path : undefined; +} + +/** Maximum number of concurrent file system read/stat operations to prevent OS file descriptor exhaustion. */ +const MAX_CONCURRENT_READS = 16; + +/** + * Maps an array asynchronously with a sliding worker pool to maintain full concurrency saturation. + */ +async function mapConcurrent( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let index = 0; + + const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (index < items.length) { + const i = index++; + results[i] = await fn(items[i]); + } + }); + + await Promise.all(workers); + + return results; +} + +/** + * Validates that all imported watch files exist on disk and their contents match. + * Performs a fast-path metadata check (mtime + size) first, falling back to content hashing. + * Heals/updates the cached metadata on disk if the content hash was valid but the metadata changed. + */ +async function validateAndHealCacheEntry( + watchFilesMetadata: Record | undefined, + store: PersistentCacheStore, + cacheKey: string, + cached: CachedLoadResultEntry, + targetFilePath?: string, +): Promise { + if (!watchFilesMetadata) { + return false; + } + + const watchFiles = Object.keys(watchFilesMetadata); + let healed = false; + + const isValidResults = await mapConcurrent(watchFiles, MAX_CONCURRENT_READS, async (filePath) => { + try { + const stats = await stat(filePath); + const expected = watchFilesMetadata[filePath]; + if (!expected) { + return false; + } + + // 1. Fast Path: size and mtime match + if (stats.size === expected.size && stats.mtimeMs === expected.mtimeMs) { + return true; + } + + // 2. Target File Path: content hash was already verified by cacheKey lookup, heal metadata if mtime changed + if (targetFilePath && filePath === targetFilePath) { + watchFilesMetadata[filePath] = { + ...expected, + mtimeMs: stats.mtimeMs, + size: stats.size, + }; + healed = true; + + return true; + } + + // 3. Slow Path for dependencies: content hash fallback + const currentContent = await readFile(filePath); + const currentHash = hashContent(currentContent); + if (currentHash === expected.hash) { + // Heal cache entry with new metadata + watchFilesMetadata[filePath] = { + ...expected, + mtimeMs: stats.mtimeMs, + size: stats.size, + }; + healed = true; + + return true; + } + + return false; + } catch { + return false; + } + }); + + if (isValidResults.some((isValid) => !isValid)) { + return false; + } + + if (healed) { + try { + await store.put(cacheKey, cached); + } catch { + // Ignore errors writing healed entries + } + } + + return true; +} + +/** + * Computes metadata (content hashes, mtime, size) for an array of watch file paths. + * Processes files with a sliding worker pool of 16 concurrent operations. + */ +async function computeMetadataForWatchFiles( + watchFiles: string[], + knownContents?: Map, +): Promise> { + const watchFilesMetadata: Record = {}; + + await mapConcurrent(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), + ]); + watchFilesMetadata[filePath] = { + hash: hashContent(content), + mtimeMs: stats.mtimeMs, + size: stats.size, + }; + } catch { + // Ignore unreadable files + } + }); + + return watchFilesMetadata; +} + +export class PersistentLoadResultCache implements LoadResultCache { + private readonly memoryCache = new MemoryLoadResultCache(); + + constructor( + private readonly persistentStore?: PersistentCacheStore, + private readonly globalConfigHash: string = '', + ) {} + + /** + * Retrieves a load result from cache. + * Checks L1 memory cache first for immediate watch-mode speed, falling back to L2 persistent disk + * store on L1 cache miss. L2 persistent cache entries are validated against dependency metadata. + */ + async get(path: string): Promise { + // 1. Check L1 Memory Cache + const memoryResult = this.memoryCache.get(path); + if (memoryResult) { + return memoryResult; + } + + if (!this.persistentStore) { + return undefined; + } + + // 2. Check L2 Persistent Disk Cache + let content: string | Uint8Array = ''; + const filePath = extractDiskFilePath(path); + if (filePath) { + try { + content = await readFile(filePath); + } catch { + return undefined; + } + } + + const cacheKey = calculateCacheKey(this.globalConfigHash, path, content); + const cached = await this.persistentStore.get(cacheKey); + + if ( + cached && + (await validateAndHealCacheEntry( + cached.watchFilesMetadata, + this.persistentStore, + cacheKey, + cached, + filePath, + )) + ) { + const result: OnLoadResult = { + contents: cached.contents, + loader: cached.loader, + watchFiles: cached.watchFiles, + watchDirs: cached.watchDirs, + warnings: cached.warnings, + errors: cached.errors, + }; + + // Populate L1 Memory Cache for subsequent lookups + await this.memoryCache.put(path, result); + + return result; + } + + return undefined; + } + + /** + * 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); + + // 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; + } + } + + const cacheKey = calculateCacheKey(this.globalConfigHash, path, content); + + // 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 watchFilesMetadata = await computeMetadataForWatchFiles( + result.watchFiles ?? [], + 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, + }); + } + } + + /** + * Invalidates cached entries affected by a modified dependency file during watch mode. + * + * Note: Invalidation of L1 memory cache is sufficient for active watch mode. + * Cross-process/cold start stale entries in L2 persistent store are automatically handled + * during `get()` via dependency metadata verification (`validateAndHealCacheEntry`). + */ + invalidate(path: string): boolean { + return this.memoryCache.invalidate(path); + } + + get watchFiles(): ReadonlyArray { + return this.memoryCache.watchFiles; + } +} diff --git a/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts new file mode 100644 index 000000000000..b99c0a76a74e --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/persistent-load-result-cache_spec.ts @@ -0,0 +1,258 @@ +/** + * @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 type { OnLoadResult } from 'esbuild'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { Cache as PersistentCacheStore } from './cache'; +import { + type CachedLoadResultEntry, + PersistentLoadResultCache, + extractDiskFilePath, +} from './persistent-load-result-cache'; + +describe('extractDiskFilePath', () => { + it('should extract disk file path for file: URIs', () => { + const filePath = '/Users/test/project/file.js'; + expect(extractDiskFilePath(`file://${filePath}`)).toBe(filePath); + }); + + it('should extract disk file path for custom plugin namespaces', () => { + const filePath = '/Users/test/project/file.js'; + expect(extractDiskFilePath(`sass:${filePath}`)).toBe(filePath); + }); + + it('should not strip Windows drive letter as a namespace prefix', () => { + const winPath = 'C:/Users/test/project/file.js'; + expect(extractDiskFilePath(winPath)).not.toBe('/Users/test/project/file.js'); + }); +}); + +describe('PersistentLoadResultCache', () => { + let mockStore: Map; + let persistentStore: PersistentCacheStore; + let tmpDir: string; + let file1: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'persistent-cache-test-')); + file1 = path.join(tmpDir, 'test.js'); + fs.writeFileSync(file1, 'console.log("hello");'); + + mockStore = new Map(); + persistentStore = { + async get(key: string) { + return mockStore.get(key); + }, + async put(key: string, value: CachedLoadResultEntry) { + mockStore.set(key, value); + }, + } as unknown as PersistentCacheStore; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return undefined on L1 and L2 cache miss', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const result = await cache.get(file1); + expect(result).toBeUndefined(); + }); + + it('should hit L2 persistent store and return cached output when dependencies are valid', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Create a second cache instance (simulating cold start) + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('console.log("hello");'); + expect(hit?.loader).toBe('js'); + }); + + it('should hit L2 persistent store and return cached output for empty file contents', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: '', + loader: 'css', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Create a second cache instance (simulating cold start) + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe(''); + expect(hit?.loader).toBe('css'); + }); + + it('should preserve watchDirs when hitting L2 persistent store', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + watchDirs: [tmpDir], + }; + + await cache.put(file1, sampleResult); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.watchDirs).toEqual([tmpDir]); + }); + + it('should hit L2 persistent store for custom plugin namespaces backed by disk files', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const customPath = `sass:${file1}`; + const sampleResult: OnLoadResult = { + contents: '.btn { color: red; }', + loader: 'css', + watchFiles: [file1], + }; + + await cache.put(customPath, sampleResult); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(customPath); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('.btn { color: red; }'); + expect(hit?.loader).toBe('css'); + }); + + it('should hit L2 persistent store for virtual modules without disk representation', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const virtualPath = 'angular:styles/component:css;0;data'; + const sampleResult: OnLoadResult = { + contents: 'h1 { margin: 0; }', + loader: 'css', + watchFiles: [file1], + }; + + await cache.put(virtualPath, sampleResult); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(virtualPath); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('h1 { margin: 0; }'); + expect(hit?.loader).toBe('css'); + }); + + it('should invalidate L2 persistent cache hit if a watch dependency file is modified', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Modify dependency file content + fs.writeFileSync(file1, 'console.log("world");'); + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeUndefined(); + }); + + it('should heal the cache entry with new metadata if content hash is still valid after mtime changes', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Retrieve the cache key from store + const keys = Array.from(mockStore.keys()); + expect(keys.length).toBe(1); + const cacheKey = keys[0]; + + const initialEntry = mockStore.get(cacheKey); + const initialMtime = initialEntry?.watchFilesMetadata[file1].mtimeMs; + expect(initialMtime).toBeDefined(); + + // Artificially change file modification time without changing content + const futureTime = new Date(Date.now() + 50000); + fs.utimesSync(file1, futureTime, futureTime); + + const statsAfter = fs.statSync(file1); + expect(statsAfter.mtimeMs).not.toEqual(initialMtime as number); + + // Query cold cache to trigger fallback content hash check and healing + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeDefined(); + expect(hit?.contents).toBe('console.log("hello");'); + + // Verify metadata was healed in-place in the persistent store + const healedEntry = mockStore.get(cacheKey); + expect(healedEntry?.watchFilesMetadata[file1].mtimeMs).toEqual(statsAfter.mtimeMs); + }); + + it('should safely handle corrupted cache entries with missing watchFilesMetadata', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const sampleResult: OnLoadResult = { + contents: 'console.log("hello");', + loader: 'js', + watchFiles: [file1], + }; + + await cache.put(file1, sampleResult); + + // Corrupt entry in store by removing watchFilesMetadata + const keys = Array.from(mockStore.keys()); + expect(keys.length).toBe(1); + const entry = mockStore.get(keys[0]); + expect(entry).toBeDefined(); + if (entry) { + delete (entry as Partial).watchFilesMetadata; + } + + const coldCache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const hit = await coldCache.get(file1); + + expect(hit).toBeUndefined(); + }); + + it('should skip L2 store put if target file cannot be read from disk', async () => { + const cache = new PersistentLoadResultCache(persistentStore, 'global-hash'); + const nonExistentFile = path.join(tmpDir, 'non-existent.js'); + const sampleResult: OnLoadResult = { + contents: 'console.log("missing");', + loader: 'js', + watchFiles: [], + }; + + await cache.put(nonExistentFile, sampleResult); + + expect(mockStore.size).toBe(0); + }); +}); diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts new file mode 100644 index 000000000000..75b0915daa64 --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key.ts @@ -0,0 +1,72 @@ +/** + * @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 { createHash } from 'node:crypto'; +import type { BundleStylesheetOptions } from './bundle-options'; + +/** + * Generates a global hash based on all build options that affect stylesheet compilation. + * + * IMPORTANT: This hash acts as the root cache key prefix for all persistent stylesheet cache + * entries. Any change in the values hashed below will invalidate all cached stylesheet outputs + * across the entire application. + * + * Included build options and their rationale: + * - `optimization`: Affects CSS minification, dead code elimination, and whitespace stripping. + * - `sourcemap`: Toggles inline/external source map generation and comment insertion. + * - `sourcesContent`: Toggles embedding original source content inside generated source maps. + * - `includePaths`: Changes Sass/Less `@import` and `@use` path resolution search directories. + * - `sassOptions`: Changes Sass compiler deprecations and behavior flags. + * - `target`: Affects CSS property lowering and browser vendor prefixing in esbuild. + * - `publicPath`: Affects relative asset URL rewriting (`url('...')`) inside CSS output. + * - `outputNames`: Affects asset output filename hashing schemes. + * - `inlineFonts`: Controls whether external web font `@import` / `` directives are inlined. + * - `preserveSymlinks`: Controls symlink realpath resolution in monorepos/pnpm workspace packages. + * - `externalDependencies`: Controls which CSS modules/urls are excluded from bundling. + * - `postcssConfig`: Path to custom PostCSS configuration file. + * - `tailwindConfig`: Path to Tailwind CSS configuration file. + * - `packageVersion`: Invalidates cache across `@angular/build` compiler toolchain updates. + * + * @note Maintainers: If any new build option is added to `BundleStylesheetOptions` or `@angular/build` + * that alters generated stylesheet code or asset outputs, it MUST be added to this hash object. + */ +export function calculateGlobalStylesheetConfigHash( + options: BundleStylesheetOptions, + packageVersion: string = '', +): string { + return createHash('sha256') + .update( + JSON.stringify({ + optimization: options.optimization, + sourcemap: options.sourcemap, + sourcesContent: options.sourcesContent, + includePaths: options.includePaths, + sassOptions: options.sass + ? { + futureDeprecations: options.sass.futureDeprecations, + fatalDeprecations: options.sass.fatalDeprecations, + silenceDeprecations: options.sass.silenceDeprecations, + } + : undefined, + target: options.target, + publicPath: options.publicPath, + outputNames: options.outputNames, + inlineFonts: options.inlineFonts, + preserveSymlinks: options.preserveSymlinks, + externalDependencies: options.externalDependencies, + postcssConfig: options.postcssConfiguration?.configPath + ? options.postcssConfiguration.configPath + : '', + tailwindConfig: options.tailwindConfiguration?.file + ? options.tailwindConfiguration.file + : '', + packageVersion, + }), + ) + .digest('hex'); +} diff --git a/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts new file mode 100644 index 000000000000..4e674b968fdd --- /dev/null +++ b/packages/angular/build/src/tools/esbuild/stylesheets/stylesheet-cache-key_spec.ts @@ -0,0 +1,69 @@ +/** + * @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 type { BundleStylesheetOptions } from './bundle-options'; +import { calculateGlobalStylesheetConfigHash } from './stylesheet-cache-key'; + +describe('Stylesheet Global Config Hash', () => { + const baseOptions: BundleStylesheetOptions = { + workspaceRoot: '/root', + optimization: true, + inlineFonts: false, + sourcemap: true, + outputNames: { bundles: 'styles', media: 'media' }, + target: ['chrome100'], + cacheOptions: { enabled: true, path: '/cache', basePath: '/root' }, + }; + + describe('calculateGlobalStylesheetConfigHash', () => { + it('should generate consistent hashes for identical options', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash({ ...baseOptions }, '1.0.0'); + expect(hash1).toBe(hash2); + }); + + it('should produce different hashes when optimization changes', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, optimization: false }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + + it('should produce different hashes when sourcemap options change', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, sourcemap: false }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + + it('should produce different hashes when sourcesContent changes', () => { + const hash1 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, sourcesContent: true }, + '1.0.0', + ); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, sourcesContent: false }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + + it('should produce different hashes when target browsers change', () => { + const hash1 = calculateGlobalStylesheetConfigHash(baseOptions, '1.0.0'); + const hash2 = calculateGlobalStylesheetConfigHash( + { ...baseOptions, target: ['firefox90'] }, + '1.0.0', + ); + expect(hash1).not.toBe(hash2); + }); + }); +});