From d624b13df375288b862dc252a93edbb79bb077c6 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 19 Dec 2024 11:54:31 -0500 Subject: [PATCH] fix(@angular/build): support incremental build file results in watch mode When the application build is in watch mode, incremental build results will now be generated. This allows fine-grained updates of the files in the output directory and supports removal of stale application code files. Note that stale assets will not currently be removed from the output directory. More complex asset change analysis will be evaluated for inclusion in the future to address this asset output behavior. --- .../src/builders/application/build-action.ts | 102 ++++++++++++++++-- .../src/builders/application/execute-build.ts | 2 +- .../build/src/builders/application/index.ts | 70 ++++++++---- .../build/src/builders/application/options.ts | 7 ++ .../build/src/builders/application/results.ts | 2 +- .../tools/esbuild/bundler-execution-result.ts | 12 ++- 6 files changed, 156 insertions(+), 39 deletions(-) diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts index c9089eed4ede..e006dce6afb7 100644 --- a/packages/angular/build/src/builders/application/build-action.ts +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -16,7 +16,14 @@ import { logMessages, withNoProgress, withSpinner } from '../../tools/esbuild/ut import { shouldWatchRoot } from '../../utils/environment-options'; import { NormalizedCachedOptions } from '../../utils/normalize-cache'; import { NormalizedApplicationBuildOptions, NormalizedOutputOptions } from './options'; -import { ComponentUpdateResult, FullResult, Result, ResultKind, ResultMessage } from './results'; +import { + ComponentUpdateResult, + FullResult, + IncrementalResult, + Result, + ResultKind, + ResultMessage, +} from './results'; // Watch workspace for package manager changes const packageWatchFiles = [ @@ -49,6 +56,7 @@ export async function* runEsBuildBuildAction( clearScreen?: boolean; colors?: boolean; jsonLogs?: boolean; + incrementalResults?: boolean; }, ): AsyncIterable { const { @@ -65,6 +73,7 @@ export async function* runEsBuildBuildAction( preserveSymlinks, colors, jsonLogs, + incrementalResults, } = options; const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress; @@ -135,7 +144,7 @@ export async function* runEsBuildBuildAction( // Output the first build results after setting up the watcher to ensure that any code executed // higher in the iterator call stack will trigger the watcher. This is particularly relevant for // unit tests which execute the builder and modify the file system programmatically. - yield await emitOutputResult(result, outputOptions); + yield emitOutputResult(result, outputOptions); // Finish if watch mode is not enabled if (!watcher) { @@ -162,9 +171,8 @@ export async function* runEsBuildBuildAction( // Clear removed files from current watch files changes.removed.forEach((removedPath) => currentWatchFiles.delete(removedPath)); - result = await withProgress('Changes detected. Rebuilding...', () => - action(result.createRebuildState(changes)), - ); + const rebuildState = result.createRebuildState(changes); + result = await withProgress('Changes detected. Rebuilding...', () => action(rebuildState)); // Log all diagnostic (error/warning/logs) messages await logMessages(logger, result, colors, jsonLogs); @@ -188,7 +196,11 @@ export async function* runEsBuildBuildAction( watcher.remove([...staleWatchFiles]); } - yield await emitOutputResult(result, outputOptions); + yield emitOutputResult( + result, + outputOptions, + incrementalResults ? rebuildState.previousOutputInfo : undefined, + ); } } finally { // Stop the watcher and cleanup incremental rebuild state @@ -198,7 +210,7 @@ export async function* runEsBuildBuildAction( } } -async function emitOutputResult( +function emitOutputResult( { outputFiles, assetFiles, @@ -210,7 +222,8 @@ async function emitOutputResult( templateUpdates, }: ExecutionResult, outputOptions: NormalizedApplicationBuildOptions['outputOptions'], -): Promise { + previousOutputInfo?: ReadonlyMap, +): Result { if (errors.length > 0) { return { kind: ResultKind.Failure, @@ -222,11 +235,12 @@ async function emitOutputResult( }; } - // Template updates only exist if no other changes have occurred - if (templateUpdates?.size) { + // Template updates only exist if no other JS changes have occurred + const hasTemplateUpdates = !!templateUpdates?.size; + if (hasTemplateUpdates) { const updateResult: ComponentUpdateResult = { kind: ResultKind.ComponentUpdate, - updates: Array.from(templateUpdates).map(([id, content]) => ({ + updates: Array.from(templateUpdates, ([id, content]) => ({ type: 'template', id, content, @@ -236,6 +250,72 @@ async function emitOutputResult( return updateResult; } + // Use an incremental result if previous output information is available + if (previousOutputInfo) { + const incrementalResult: IncrementalResult = { + kind: ResultKind.Incremental, + warnings: warnings as ResultMessage[], + added: [], + removed: [], + modified: [], + files: {}, + detail: { + externalMetadata, + htmlIndexPath, + htmlBaseHref, + outputOptions, + }, + }; + + // Initially assume all previous output files have been removed + const removedOutputFiles = new Map(previousOutputInfo); + + for (const file of outputFiles) { + removedOutputFiles.delete(file.path); + + const previousHash = previousOutputInfo.get(file.path)?.hash; + let needFile = false; + if (previousHash === undefined) { + needFile = true; + incrementalResult.added.push(file.path); + } else if (previousHash !== file.hash) { + needFile = true; + incrementalResult.modified.push(file.path); + } + + if (needFile) { + incrementalResult.files[file.path] = { + type: file.type, + contents: file.contents, + origin: 'memory', + hash: file.hash, + }; + } + } + + // Include the removed output files + incrementalResult.removed.push( + ...Array.from(removedOutputFiles, ([file, { type }]) => ({ + path: file, + type, + })), + ); + + // Always consider asset files as added to ensure new/modified assets are available. + // TODO: Consider more comprehensive asset analysis. + for (const file of assetFiles) { + incrementalResult.added.push(file.destination); + incrementalResult.files[file.destination] = { + type: BuildOutputFileType.Browser, + inputPath: file.source, + origin: 'disk', + }; + } + + return incrementalResult; + } + + // Otherwise, use a full result const result: FullResult = { kind: ResultKind.Full, warnings: warnings as ResultMessage[], diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index 10d0e297522f..43cbf41d52a6 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -182,7 +182,7 @@ export async function executeBuild( executionResult.outputFiles.push(...outputFiles); const changedFiles = - rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputHashes); + rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo); // Analyze files for bundle budget failures if present let budgetFailures: BudgetCalculatorResult[] | undefined; diff --git a/packages/angular/build/src/builders/application/index.ts b/packages/angular/build/src/builders/application/index.ts index 27d0c03bee77..a8a68d96b88a 100644 --- a/packages/angular/build/src/builders/application/index.ts +++ b/packages/angular/build/src/builders/application/index.ts @@ -126,6 +126,7 @@ export async function* buildApplicationInternal( clearScreen: normalizedOptions.clearScreen, colors: normalizedOptions.colors, jsonLogs: normalizedOptions.jsonLogs, + incrementalResults: normalizedOptions.incrementalResults, logger, signal, }, @@ -157,7 +158,8 @@ export async function* buildApplication( extensions?: ApplicationBuilderExtensions, ): AsyncIterable { let initial = true; - for await (const result of buildApplicationInternal(options, context, extensions)) { + const internalOptions = { ...options, incrementalResults: true }; + for await (const result of buildApplicationInternal(internalOptions, context, extensions)) { const outputOptions = result.detail?.['outputOptions'] as NormalizedOutputOptions | undefined; if (initial) { @@ -179,7 +181,10 @@ export async function* buildApplication( } assert(outputOptions, 'Application output options are required for builder usage.'); - assert(result.kind === ResultKind.Full, 'Application build did not provide a full output.'); + assert( + result.kind === ResultKind.Full || result.kind === ResultKind.Incremental, + 'Application build did not provide a file result output.', + ); // TODO: Restructure output logging to better handle stdout JSON piping if (!useJSONBuildLogs) { @@ -197,26 +202,7 @@ export async function* buildApplication( return; } - let typeDirectory: string; - switch (file.type) { - case BuildOutputFileType.Browser: - case BuildOutputFileType.Media: - typeDirectory = outputOptions.browser; - break; - case BuildOutputFileType.ServerApplication: - case BuildOutputFileType.ServerRoot: - typeDirectory = outputOptions.server; - break; - case BuildOutputFileType.Root: - typeDirectory = ''; - break; - default: - throw new Error( - `Unhandled write for file "${filePath}" with type "${BuildOutputFileType[file.type]}".`, - ); - } - // NOTE: 'base' is a fully resolved path at this point - const fullFilePath = path.join(outputOptions.base, typeDirectory, filePath); + const fullFilePath = generateFullPath(filePath, file.type, outputOptions); // Ensure output subdirectories exist const fileBasePath = path.dirname(fullFilePath); @@ -234,8 +220,48 @@ export async function* buildApplication( } }); + // Delete any removed files if incremental + if (result.kind === ResultKind.Incremental && result.removed?.length) { + await Promise.all( + result.removed.map((file) => { + const fullFilePath = generateFullPath(file.path, file.type, outputOptions); + + return fs.rm(fullFilePath, { force: true, maxRetries: 3 }); + }), + ); + } + yield { success: true }; } } +function generateFullPath( + filePath: string, + type: BuildOutputFileType, + outputOptions: NormalizedOutputOptions, +) { + let typeDirectory: string; + switch (type) { + case BuildOutputFileType.Browser: + case BuildOutputFileType.Media: + typeDirectory = outputOptions.browser; + break; + case BuildOutputFileType.ServerApplication: + case BuildOutputFileType.ServerRoot: + typeDirectory = outputOptions.server; + break; + case BuildOutputFileType.Root: + typeDirectory = ''; + break; + default: + throw new Error( + `Unhandled write for file "${filePath}" with type "${BuildOutputFileType[type]}".`, + ); + } + // NOTE: 'base' is a fully resolved path at this point + const fullFilePath = path.join(outputOptions.base, typeDirectory, filePath); + + return fullFilePath; +} + export default createBuilder(buildApplication); diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 13adfa354d40..fd69191ca969 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -107,6 +107,12 @@ interface InternalOptions { */ templateUpdates?: boolean; + /** + * Enables emitting incremental build results when in watch mode. A full build result will only be emitted + * for the initial build. This option also requires watch to be enabled to have an effect. + */ + incrementalResults?: boolean; + /** * Enables instrumentation to collect code coverage data for specific files. * @@ -475,6 +481,7 @@ export async function normalizeOptions( instrumentForCoverage, security, templateUpdates: !!options.templateUpdates, + incrementalResults: !!options.incrementalResults, }; } diff --git a/packages/angular/build/src/builders/application/results.ts b/packages/angular/build/src/builders/application/results.ts index 842af17dda3f..077237967425 100644 --- a/packages/angular/build/src/builders/application/results.ts +++ b/packages/angular/build/src/builders/application/results.ts @@ -37,7 +37,7 @@ export interface FullResult extends BaseResult { export interface IncrementalResult extends BaseResult { kind: ResultKind.Incremental; added: string[]; - removed: string[]; + removed: { path: string; type: BuildOutputFileType }[]; modified: string[]; files: Record; } diff --git a/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts index d6d2d2a01fd8..61e9c860faef 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-execution-result.ts @@ -27,7 +27,7 @@ export interface RebuildState { componentStyleBundler: ComponentStylesheetBundler; codeBundleCache?: SourceFileCache; fileChanges: ChangedFiles; - previousOutputHashes: Map; + previousOutputInfo: Map; templateUpdates?: Map; } @@ -167,15 +167,19 @@ export class ExecutionResult { codeBundleCache: this.codeBundleCache, componentStyleBundler: this.componentStyleBundler, fileChanges, - previousOutputHashes: new Map(this.outputFiles.map((file) => [file.path, file.hash])), + previousOutputInfo: new Map( + this.outputFiles.map(({ path, hash, type }) => [path, { hash, type }]), + ), templateUpdates: this.templateUpdates, }; } - findChangedFiles(previousOutputHashes: Map): Set { + findChangedFiles( + previousOutputHashes: Map, + ): Set { const changed = new Set(); for (const file of this.outputFiles) { - const previousHash = previousOutputHashes.get(file.path); + const previousHash = previousOutputHashes.get(file.path)?.hash; if (previousHash === undefined || previousHash !== file.hash) { changed.add(file.path); }