From a710a262aed8a6c4a6af48e0ad7f479f0a23212e Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 5 Apr 2023 16:05:39 -0400 Subject: [PATCH] perf(@angular-devkit/build-angular): cache Sass in memory with esbuild watch mode To improve rebuild performance when using Sass stylesheets with the esbuild-based browser application builder in watch mode, Sass stylesheets that are not affected by any file changes will now be cached and directly reused. This avoids performing potentially expensive Sass preprocessing on stylesheets that will not change within a rebuild. (cherry picked from commit 1e78cf99c18a7bbe3b718d8acdea5bf18f83f88b) --- .../angular/compiler-plugin.ts | 12 ++++- .../angular/jit-plugin-callbacks.ts | 3 ++ .../src/builders/browser-esbuild/index.ts | 32 +++++++----- .../browser-esbuild/load-result-cache.ts | 51 +++++++++++++++++++ .../builders/browser-esbuild/sass-plugin.ts | 33 +++++++++--- .../builders/browser-esbuild/stylesheets.ts | 18 ++++--- 6 files changed, 122 insertions(+), 27 deletions(-) create mode 100644 packages/angular_devkit/build_angular/src/builders/browser-esbuild/load-result-cache.ts diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts index ba7a5a47f7bb..f4613bde8eca 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/compiler-plugin.ts @@ -22,6 +22,7 @@ import { pathToFileURL } from 'node:url'; import ts from 'typescript'; import { maxWorkers } from '../../../utils/environment-options'; import { JavaScriptTransformer } from '../javascript-transformer'; +import { LoadResultCache, MemoryLoadResultCache } from '../load-result-cache'; import { logCumulativeDurations, profileAsync, @@ -124,12 +125,14 @@ export class SourceFileCache extends Map { readonly modifiedFiles = new Set(); readonly babelFileCache = new Map(); readonly typeScriptFileCache = new Map(); + readonly loadResultCache = new MemoryLoadResultCache(); invalidate(files: Iterable): void { this.modifiedFiles.clear(); for (let file of files) { this.babelFileCache.delete(file); this.typeScriptFileCache.delete(pathToFileURL(file).href); + this.loadResultCache.invalidate(file); // Normalize separators to allow matching TypeScript Host paths if (USING_WINDOWS) { @@ -150,6 +153,7 @@ export interface CompilerPluginOptions { thirdPartySourcemaps?: boolean; fileReplacements?: Record; sourceFileCache?: SourceFileCache; + loadResultCache?: LoadResultCache; } // eslint-disable-next-line max-lines-per-function @@ -272,6 +276,7 @@ export function createCompilerPlugin( filename, !stylesheetFile, styleOptions, + pluginOptions.loadResultCache, ); const { contents, resourceFiles, errors, warnings } = stylesheetResult; @@ -415,7 +420,12 @@ export function createCompilerPlugin( // Setup bundling of component templates and stylesheets when in JIT mode if (pluginOptions.jit) { - setupJitPluginCallbacks(build, styleOptions, stylesheetResourceFiles); + setupJitPluginCallbacks( + build, + styleOptions, + stylesheetResourceFiles, + pluginOptions.loadResultCache, + ); } build.onEnd((result) => { diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-plugin-callbacks.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-plugin-callbacks.ts index 20adc6e2f157..029a17aaf565 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-plugin-callbacks.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/angular/jit-plugin-callbacks.ts @@ -9,6 +9,7 @@ import type { OutputFile, PluginBuild } from 'esbuild'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; +import { LoadResultCache } from '../load-result-cache'; import { BundleStylesheetOptions, bundleComponentStylesheet } from '../stylesheets'; import { JIT_NAMESPACE_REGEXP, @@ -65,6 +66,7 @@ export function setupJitPluginCallbacks( build: PluginBuild, styleOptions: BundleStylesheetOptions & { inlineStyleLanguage: string }, stylesheetResourceFiles: OutputFile[], + cache?: LoadResultCache, ): void { const root = build.initialOptions.absWorkingDir ?? ''; @@ -110,6 +112,7 @@ export function setupJitPluginCallbacks( entry.path, entry.contents !== undefined, styleOptions, + cache, ); stylesheetResourceFiles.push(...resourceFiles); diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts index 8332d2648edf..43170cb326f8 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/index.ts @@ -27,6 +27,7 @@ import { BundlerContext, logMessages } from './esbuild'; import { logExperimentalWarnings } from './experimental-warnings'; import { createGlobalScriptsBundleOptions } from './global-scripts'; import { extractLicenses } from './license-extractor'; +import { LoadResultCache } from './load-result-cache'; import { NormalizedBrowserOptions, normalizeOptions } from './options'; import { shutdownSassWorkerPool } from './sass-plugin'; import { Schema as BrowserBuilderOptions } from './schema'; @@ -122,7 +123,7 @@ async function execute( new BundlerContext( workspaceRoot, !!options.watch, - createGlobalStylesBundleOptions(options, target, browsers), + createGlobalStylesBundleOptions(options, target, browsers, codeBundleCache?.loadResultCache), ); const globalScriptsBundleContext = new BundlerContext( @@ -390,6 +391,7 @@ function createCodeBundleOptions( advancedOptimizations, fileReplacements, sourceFileCache, + loadResultCache: sourceFileCache?.loadResultCache, }, // Component stylesheet options { @@ -508,6 +510,7 @@ function createGlobalStylesBundleOptions( options: NormalizedBrowserOptions, target: string[], browsers: string[], + cache?: LoadResultCache, ): BuildOptions { const { workspaceRoot, @@ -521,18 +524,21 @@ function createGlobalStylesBundleOptions( tailwindConfiguration, } = options; - const buildOptions = createStylesheetBundleOptions({ - workspaceRoot, - optimization: !!optimizationOptions.styles.minify, - sourcemap: !!sourcemapOptions.styles, - preserveSymlinks, - target, - externalDependencies, - outputNames, - includePaths: stylePreprocessorOptions?.includePaths, - browsers, - tailwindConfiguration, - }); + const buildOptions = createStylesheetBundleOptions( + { + workspaceRoot, + optimization: !!optimizationOptions.styles.minify, + sourcemap: !!sourcemapOptions.styles, + preserveSymlinks, + target, + externalDependencies, + outputNames, + includePaths: stylePreprocessorOptions?.includePaths, + browsers, + tailwindConfiguration, + }, + cache, + ); buildOptions.legalComments = options.extractLicenses ? 'none' : 'eof'; const namespace = 'angular:styles/global'; diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/load-result-cache.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/load-result-cache.ts new file mode 100644 index 000000000000..ef6e4616dcd7 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/load-result-cache.ts @@ -0,0 +1,51 @@ +/** + * @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.io/license + */ + +import type { OnLoadResult } from 'esbuild'; + +export interface LoadResultCache { + get(path: string): OnLoadResult | undefined; + put(path: string, result: OnLoadResult): Promise; +} + +export class MemoryLoadResultCache implements LoadResultCache { + #loadResults = new Map(); + #fileDependencies = new Map>(); + + get(path: string): OnLoadResult | undefined { + return this.#loadResults.get(path); + } + + async put(path: string, result: OnLoadResult): Promise { + this.#loadResults.set(path, result); + if (result.watchFiles) { + for (const watchFile of result.watchFiles) { + let affected = this.#fileDependencies.get(watchFile); + if (affected === undefined) { + affected = new Set(); + this.#fileDependencies.set(watchFile, affected); + } + affected.add(path); + } + } + } + + invalidate(path: string): boolean { + const affected = this.#fileDependencies.get(path); + let found = false; + + if (affected) { + affected.forEach((a) => (found ||= this.#loadResults.delete(a))); + this.#fileDependencies.delete(path); + } + + found ||= this.#loadResults.delete(path); + + return found; + } +} diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts index a9c5dc5bd551..abc63ca7a3ec 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/sass-plugin.ts @@ -16,6 +16,7 @@ import type { FileImporterWithRequestContextOptions, SassWorkerImplementation, } from '../../sass/sass-service'; +import type { LoadResultCache } from './load-result-cache'; export interface SassPluginOptions { sourcemap: boolean; @@ -34,7 +35,7 @@ export function shutdownSassWorkerPool(): void { sassWorkerPool = undefined; } -export function createSassPlugin(options: SassPluginOptions): Plugin { +export function createSassPlugin(options: SassPluginOptions, cache?: LoadResultCache): Plugin { return { name: 'angular-sass', setup(build: PluginBuild): void { @@ -69,17 +70,35 @@ export function createSassPlugin(options: SassPluginOptions): Plugin { `component style name should always be found [${args.path}]`, ); - const [language, , filePath] = args.path.split(';', 3); - const syntax = language === 'sass' ? 'indented' : 'scss'; + let result = cache?.get(data); + if (result === undefined) { + const [language, , filePath] = args.path.split(';', 3); + const syntax = language === 'sass' ? 'indented' : 'scss'; - return compileString(data, filePath, syntax, options, resolveUrl); + result = await compileString(data, filePath, syntax, options, resolveUrl); + if (result.errors === undefined) { + // Cache the result if there were no errors + await cache?.put(data, result); + } + } + + return result; }); build.onLoad({ filter: /\.s[ac]ss$/ }, async (args) => { - const data = await readFile(args.path, 'utf-8'); - const syntax = extname(args.path).toLowerCase() === '.sass' ? 'indented' : 'scss'; + let result = cache?.get(args.path); + if (result === undefined) { + const data = await readFile(args.path, 'utf-8'); + const syntax = extname(args.path).toLowerCase() === '.sass' ? 'indented' : 'scss'; + + result = await compileString(data, args.path, syntax, options, resolveUrl); + if (result.errors === undefined) { + // Cache the result if there were no errors + await cache?.put(args.path, result); + } + } - return compileString(data, args.path, syntax, options, resolveUrl); + return result; }); }, }; diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts index 37be4fe165e4..3a1d9f021e4f 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/stylesheets.ts @@ -12,6 +12,7 @@ import { createCssPlugin } from './css-plugin'; import { createCssResourcePlugin } from './css-resource-plugin'; import { BundlerContext } from './esbuild'; import { createLessPlugin } from './less-plugin'; +import { LoadResultCache } from './load-result-cache'; import { createSassPlugin } from './sass-plugin'; /** @@ -34,6 +35,7 @@ export interface BundleStylesheetOptions { export function createStylesheetBundleOptions( options: BundleStylesheetOptions, + cache?: LoadResultCache, inlineComponentData?: Record, ): BuildOptions & { plugins: NonNullable } { // Ensure preprocessor include paths are absolute based on the workspace root @@ -59,11 +61,14 @@ export function createStylesheetBundleOptions( conditions: ['style', 'sass'], mainFields: ['style', 'sass'], plugins: [ - createSassPlugin({ - sourcemap: !!options.sourcemap, - loadPaths: includePaths, - inlineComponentData, - }), + createSassPlugin( + { + sourcemap: !!options.sourcemap, + loadPaths: includePaths, + inlineComponentData, + }, + cache, + ), createLessPlugin({ sourcemap: !!options.sourcemap, includePaths, @@ -100,11 +105,12 @@ export async function bundleComponentStylesheet( filename: string, inline: boolean, options: BundleStylesheetOptions, + cache?: LoadResultCache, ) { const namespace = 'angular:styles/component'; const entry = [language, componentStyleCounter++, filename].join(';'); - const buildOptions = createStylesheetBundleOptions(options, { [entry]: data }); + const buildOptions = createStylesheetBundleOptions(options, cache, { [entry]: data }); buildOptions.entryPoints = [`${namespace};${entry}`]; buildOptions.plugins.push({ name: 'angular-component-styles',