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); }