diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts index 391d7d3c9f70..0726513dbb4a 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/compiler-plugin.ts @@ -18,6 +18,7 @@ import type { PluginBuild, } from 'esbuild'; import { promises as fs } from 'fs'; +import { platform } from 'os'; import * as path from 'path'; import ts from 'typescript'; import angularApplicationPreset from '../../babel/presets/application'; @@ -120,12 +121,33 @@ function convertTypeScriptDiagnostic( return message; } +const USING_WINDOWS = platform() === 'win32'; +const WINDOWS_SEP_REGEXP = new RegExp(`\\${path.win32.sep}`, 'g'); + +export class SourceFileCache extends Map { + readonly modifiedFiles = new Set(); + + invalidate(files: Iterable): void { + this.modifiedFiles.clear(); + for (let file of files) { + // Normalize separators to allow matching TypeScript Host paths + if (USING_WINDOWS) { + file = file.replace(WINDOWS_SEP_REGEXP, path.posix.sep); + } + + this.delete(file); + this.modifiedFiles.add(file); + } + } +} + export interface CompilerPluginOptions { sourcemap: boolean; tsconfig: string; advancedOptimizations?: boolean; thirdPartySourcemaps?: boolean; fileReplacements?: Record; + sourceFileCache?: SourceFileCache; } // This is a non-watch version of the compiler code from `@ngtools/webpack` augmented for esbuild @@ -262,6 +284,7 @@ export function createCompilerPlugin( // Temporary deep import for host augmentation support const { + augmentHostWithCaching, augmentHostWithReplacements, augmentProgramWithVersioning, } = require('@ngtools/webpack/src/ivy/host'); @@ -271,6 +294,15 @@ export function createCompilerPlugin( augmentHostWithReplacements(host, pluginOptions.fileReplacements); } + // Augment TypeScript Host with source file caching if provided + if (pluginOptions.sourceFileCache) { + augmentHostWithCaching(host, pluginOptions.sourceFileCache); + // Allow the AOT compiler to request the set of changed templates and styles + (host as CompilerHost).getModifiedResourceFiles = function () { + return pluginOptions.sourceFileCache?.modifiedFiles; + }; + } + // Create the Angular specific program that contains the Angular compiler const angularProgram = new compilerCli.NgtscProgram( rootNames, diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/esbuild.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/esbuild.ts index 62f592cd6b54..a7d5dc9fd726 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/esbuild.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/esbuild.ts @@ -9,6 +9,7 @@ import { BuilderContext } from '@angular-devkit/architect'; import { BuildFailure, + BuildInvalidate, BuildOptions, BuildResult, Message, @@ -32,20 +33,25 @@ export function isEsBuildFailure(value: unknown): value is BuildFailure { * All builds use the `write` option with a value of `false` to allow for the output files * build result array to be populated. * - * @param options The esbuild options object to use when building. + * @param optionsOrInvalidate The esbuild options object to use when building or the invalidate object + * returned from an incremental build to perform an additional incremental build. * @returns If output files are generated, the full esbuild BuildResult; if not, the * warnings and errors for the attempted build. */ export async function bundle( - options: BuildOptions, + optionsOrInvalidate: BuildOptions | BuildInvalidate, ): Promise< (BuildResult & { outputFiles: OutputFile[] }) | (BuildFailure & { outputFiles?: never }) > { try { - return await build({ - ...options, - write: false, - }); + if (typeof optionsOrInvalidate === 'function') { + return (await optionsOrInvalidate()) as BuildResult & { outputFiles: OutputFile[] }; + } else { + return await build({ + ...optionsOrInvalidate, + write: false, + }); + } } catch (failure) { // Build failures will throw an exception which contains errors/warnings if (isEsBuildFailure(failure)) { 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 face505e3cfa..36f714362bc5 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 @@ -8,7 +8,7 @@ import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; import * as assert from 'assert'; -import type { Message, OutputFile } from 'esbuild'; +import type { BuildInvalidate, BuildOptions, Message, OutputFile } from 'esbuild'; import * as fs from 'fs/promises'; import * as path from 'path'; import { deleteOutputDir } from '../../utils'; @@ -19,18 +19,56 @@ import { FileInfo } from '../../utils/index-file/augment-index-html'; import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator'; import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker'; import { getSupportedBrowsers } from '../../utils/supported-browsers'; -import { createCompilerPlugin } from './compiler-plugin'; +import { SourceFileCache, createCompilerPlugin } from './compiler-plugin'; import { bundle, logMessages } from './esbuild'; import { logExperimentalWarnings } from './experimental-warnings'; import { NormalizedBrowserOptions, normalizeOptions } from './options'; import { Schema as BrowserBuilderOptions } from './schema'; import { bundleStylesheetText } from './stylesheets'; -import { createWatcher } from './watcher'; +import { ChangedFiles, createWatcher } from './watcher'; + +interface RebuildState { + codeRebuild?: BuildInvalidate; + codeBundleCache?: SourceFileCache; + fileChanges: ChangedFiles; +} + +/** + * Represents the result of a single builder execute call. + */ +class ExecutionResult { + constructor( + private success: boolean, + private codeRebuild?: BuildInvalidate, + private codeBundleCache?: SourceFileCache, + ) {} + + get output() { + return { + success: this.success, + }; + } + + createRebuildState(fileChanges: ChangedFiles): RebuildState { + this.codeBundleCache?.invalidate([...fileChanges.modified, ...fileChanges.removed]); + + return { + codeRebuild: this.codeRebuild, + codeBundleCache: this.codeBundleCache, + fileChanges, + }; + } + + dispose(): void { + this.codeRebuild?.dispose(); + } +} async function execute( options: NormalizedBrowserOptions, context: BuilderContext, -): Promise { + rebuildState?: RebuildState, +): Promise { const startTime = Date.now(); const { @@ -47,9 +85,13 @@ async function execute( getSupportedBrowsers(projectRoot, context.logger), ); + const codeBundleCache = options.watch + ? rebuildState?.codeBundleCache ?? new SourceFileCache() + : undefined; + const [codeResults, styleResults] = await Promise.all([ // Execute esbuild to bundle the application code - bundleCode(options, target), + bundle(rebuildState?.codeRebuild ?? createCodeBundleOptions(options, target, codeBundleCache)), // Execute esbuild to bundle the global stylesheets bundleGlobalStylesheets(options, target), ]); @@ -62,7 +104,7 @@ async function execute( // Return if the bundling failed to generate output files or there are errors if (!codeResults.outputFiles || codeResults.errors.length) { - return { success: false }; + return new ExecutionResult(false, rebuildState?.codeRebuild, codeBundleCache); } // Structure the code bundling output files @@ -93,7 +135,7 @@ async function execute( // Return if the global stylesheet bundling has errors if (styleResults.errors.length) { - return { success: false }; + return new ExecutionResult(false, codeResults.rebuild, codeBundleCache); } // Generate index HTML file @@ -160,13 +202,13 @@ async function execute( } catch (error) { context.logger.error(error instanceof Error ? error.message : `${error}`); - return { success: false }; + return new ExecutionResult(false, codeResults.rebuild, codeBundleCache); } } context.logger.info(`Complete. [${(Date.now() - startTime) / 1000} seconds]`); - return { success: true }; + return new ExecutionResult(true, codeResults.rebuild, codeBundleCache); } function createOutputFileFromText(path: string, text: string): OutputFile { @@ -179,7 +221,11 @@ function createOutputFileFromText(path: string, text: string): OutputFile { }; } -async function bundleCode(options: NormalizedBrowserOptions, target: string[]) { +function createCodeBundleOptions( + options: NormalizedBrowserOptions, + target: string[], + sourceFileCache?: SourceFileCache, +): BuildOptions { const { workspaceRoot, entryPoints, @@ -194,9 +240,10 @@ async function bundleCode(options: NormalizedBrowserOptions, target: string[]) { advancedOptimizations, } = options; - return bundle({ + return { absWorkingDir: workspaceRoot, bundle: true, + incremental: options.watch, format: 'esm', entryPoints, entryNames: outputNames.bundles, @@ -234,6 +281,7 @@ async function bundleCode(options: NormalizedBrowserOptions, target: string[]) { tsconfig, advancedOptimizations, fileReplacements, + sourceFileCache, }, // Component stylesheet options { @@ -255,7 +303,7 @@ async function bundleCode(options: NormalizedBrowserOptions, target: string[]) { ...(optimizationOptions.scripts ? { 'ngDevMode': 'false' } : undefined), 'ngJitMode': 'false', }, - }); + }; } async function bundleGlobalStylesheets(options: NormalizedBrowserOptions, target: string[]) { @@ -383,13 +431,16 @@ export async function* buildEsbuildBrowser( } // Initial build - yield await execute(normalizedOptions, context); + let result = await execute(normalizedOptions, context); + yield result.output; // Finish if watch mode is not enabled if (!initialOptions.watch) { return; } + context.logger.info('Watch mode enabled. Watching for file changes...'); + // Setup a watcher const watcher = createWatcher({ polling: typeof initialOptions.poll === 'number', @@ -416,10 +467,14 @@ export async function* buildEsbuildBrowser( context.logger.info(changes.toDebugString()); } - yield await execute(normalizedOptions, context); + result = await execute(normalizedOptions, context, result.createRebuildState(changes)); + yield result.output; } } finally { + // Stop the watcher await watcher.close(); + // Cleanup incremental rebuild state + result.dispose(); } } diff --git a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts index 941dc867d0f9..29f86f867320 100644 --- a/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts +++ b/packages/angular_devkit/build_angular/src/builders/browser-esbuild/options.ts @@ -133,10 +133,12 @@ export async function normalizeOptions( buildOptimizer, crossOrigin, externalDependencies, + poll, preserveSymlinks, stylePreprocessorOptions, subresourceIntegrity, verbose, + watch, } = options; // Return all the normalized options @@ -145,10 +147,12 @@ export async function normalizeOptions( baseHref, crossOrigin, externalDependencies, + poll, preserveSymlinks, stylePreprocessorOptions, subresourceIntegrity, verbose, + watch, workspaceRoot, entryPoints, optimizationOptions,