Skip to content

Commit

Permalink
fix(@angular/build): support incremental build file results in watch …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
clydin committed Dec 20, 2024
1 parent 46306ac commit d624b13
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 39 deletions.
102 changes: 91 additions & 11 deletions packages/angular/build/src/builders/application/build-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -49,6 +56,7 @@ export async function* runEsBuildBuildAction(
clearScreen?: boolean;
colors?: boolean;
jsonLogs?: boolean;
incrementalResults?: boolean;
},
): AsyncIterable<Result> {
const {
Expand All @@ -65,6 +73,7 @@ export async function* runEsBuildBuildAction(
preserveSymlinks,
colors,
jsonLogs,
incrementalResults,
} = options;

const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress;
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -198,7 +210,7 @@ export async function* runEsBuildBuildAction(
}
}

async function emitOutputResult(
function emitOutputResult(
{
outputFiles,
assetFiles,
Expand All @@ -210,7 +222,8 @@ async function emitOutputResult(
templateUpdates,
}: ExecutionResult,
outputOptions: NormalizedApplicationBuildOptions['outputOptions'],
): Promise<Result> {
previousOutputInfo?: ReadonlyMap<string, { hash: string; type: BuildOutputFileType }>,
): Result {
if (errors.length > 0) {
return {
kind: ResultKind.Failure,
Expand All @@ -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,
Expand All @@ -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[],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
70 changes: 48 additions & 22 deletions packages/angular/build/src/builders/application/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export async function* buildApplicationInternal(
clearScreen: normalizedOptions.clearScreen,
colors: normalizedOptions.colors,
jsonLogs: normalizedOptions.jsonLogs,
incrementalResults: normalizedOptions.incrementalResults,
logger,
signal,
},
Expand Down Expand Up @@ -157,7 +158,8 @@ export async function* buildApplication(
extensions?: ApplicationBuilderExtensions,
): AsyncIterable<ApplicationBuilderOutput> {
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) {
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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);
7 changes: 7 additions & 0 deletions packages/angular/build/src/builders/application/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -475,6 +481,7 @@ export async function normalizeOptions(
instrumentForCoverage,
security,
templateUpdates: !!options.templateUpdates,
incrementalResults: !!options.incrementalResults,
};
}

Expand Down
2 changes: 1 addition & 1 deletion packages/angular/build/src/builders/application/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ResultFile>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export interface RebuildState {
componentStyleBundler: ComponentStylesheetBundler;
codeBundleCache?: SourceFileCache;
fileChanges: ChangedFiles;
previousOutputHashes: Map<string, string>;
previousOutputInfo: Map<string, { hash: string; type: BuildOutputFileType }>;
templateUpdates?: Map<string, string>;
}

Expand Down Expand Up @@ -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<string, string>): Set<string> {
findChangedFiles(
previousOutputHashes: Map<string, { hash: string; type: BuildOutputFileType }>,
): Set<string> {
const changed = new Set<string>();
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);
}
Expand Down

0 comments on commit d624b13

Please sign in to comment.