Skip to content

Commit ba16ad6

Browse files
clydinalan-agius4
authored andcommittedJan 6, 2025·
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.
1 parent e648be6 commit ba16ad6

File tree

8 files changed

+217
-39
lines changed

8 files changed

+217
-39
lines changed
 

‎packages/angular/build/src/builders/application/build-action.ts

+91-11
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ import { logMessages, withNoProgress, withSpinner } from '../../tools/esbuild/ut
1616
import { shouldWatchRoot } from '../../utils/environment-options';
1717
import { NormalizedCachedOptions } from '../../utils/normalize-cache';
1818
import { NormalizedApplicationBuildOptions, NormalizedOutputOptions } from './options';
19-
import { ComponentUpdateResult, FullResult, Result, ResultKind, ResultMessage } from './results';
19+
import {
20+
ComponentUpdateResult,
21+
FullResult,
22+
IncrementalResult,
23+
Result,
24+
ResultKind,
25+
ResultMessage,
26+
} from './results';
2027

2128
// Watch workspace for package manager changes
2229
const packageWatchFiles = [
@@ -49,6 +56,7 @@ export async function* runEsBuildBuildAction(
4956
clearScreen?: boolean;
5057
colors?: boolean;
5158
jsonLogs?: boolean;
59+
incrementalResults?: boolean;
5260
},
5361
): AsyncIterable<Result> {
5462
const {
@@ -65,6 +73,7 @@ export async function* runEsBuildBuildAction(
6573
preserveSymlinks,
6674
colors,
6775
jsonLogs,
76+
incrementalResults,
6877
} = options;
6978

7079
const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress;
@@ -135,7 +144,7 @@ export async function* runEsBuildBuildAction(
135144
// Output the first build results after setting up the watcher to ensure that any code executed
136145
// higher in the iterator call stack will trigger the watcher. This is particularly relevant for
137146
// unit tests which execute the builder and modify the file system programmatically.
138-
yield await emitOutputResult(result, outputOptions);
147+
yield emitOutputResult(result, outputOptions);
139148

140149
// Finish if watch mode is not enabled
141150
if (!watcher) {
@@ -162,9 +171,8 @@ export async function* runEsBuildBuildAction(
162171
// Clear removed files from current watch files
163172
changes.removed.forEach((removedPath) => currentWatchFiles.delete(removedPath));
164173

165-
result = await withProgress('Changes detected. Rebuilding...', () =>
166-
action(result.createRebuildState(changes)),
167-
);
174+
const rebuildState = result.createRebuildState(changes);
175+
result = await withProgress('Changes detected. Rebuilding...', () => action(rebuildState));
168176

169177
// Log all diagnostic (error/warning/logs) messages
170178
await logMessages(logger, result, colors, jsonLogs);
@@ -188,7 +196,11 @@ export async function* runEsBuildBuildAction(
188196
watcher.remove([...staleWatchFiles]);
189197
}
190198

191-
yield await emitOutputResult(result, outputOptions);
199+
yield emitOutputResult(
200+
result,
201+
outputOptions,
202+
incrementalResults ? rebuildState.previousOutputInfo : undefined,
203+
);
192204
}
193205
} finally {
194206
// Stop the watcher and cleanup incremental rebuild state
@@ -198,7 +210,7 @@ export async function* runEsBuildBuildAction(
198210
}
199211
}
200212

201-
async function emitOutputResult(
213+
function emitOutputResult(
202214
{
203215
outputFiles,
204216
assetFiles,
@@ -210,7 +222,8 @@ async function emitOutputResult(
210222
templateUpdates,
211223
}: ExecutionResult,
212224
outputOptions: NormalizedApplicationBuildOptions['outputOptions'],
213-
): Promise<Result> {
225+
previousOutputInfo?: ReadonlyMap<string, { hash: string; type: BuildOutputFileType }>,
226+
): Result {
214227
if (errors.length > 0) {
215228
return {
216229
kind: ResultKind.Failure,
@@ -222,11 +235,12 @@ async function emitOutputResult(
222235
};
223236
}
224237

225-
// Template updates only exist if no other changes have occurred
226-
if (templateUpdates?.size) {
238+
// Template updates only exist if no other JS changes have occurred
239+
const hasTemplateUpdates = !!templateUpdates?.size;
240+
if (hasTemplateUpdates) {
227241
const updateResult: ComponentUpdateResult = {
228242
kind: ResultKind.ComponentUpdate,
229-
updates: Array.from(templateUpdates).map(([id, content]) => ({
243+
updates: Array.from(templateUpdates, ([id, content]) => ({
230244
type: 'template',
231245
id,
232246
content,
@@ -236,6 +250,72 @@ async function emitOutputResult(
236250
return updateResult;
237251
}
238252

253+
// Use an incremental result if previous output information is available
254+
if (previousOutputInfo) {
255+
const incrementalResult: IncrementalResult = {
256+
kind: ResultKind.Incremental,
257+
warnings: warnings as ResultMessage[],
258+
added: [],
259+
removed: [],
260+
modified: [],
261+
files: {},
262+
detail: {
263+
externalMetadata,
264+
htmlIndexPath,
265+
htmlBaseHref,
266+
outputOptions,
267+
},
268+
};
269+
270+
// Initially assume all previous output files have been removed
271+
const removedOutputFiles = new Map(previousOutputInfo);
272+
273+
for (const file of outputFiles) {
274+
removedOutputFiles.delete(file.path);
275+
276+
const previousHash = previousOutputInfo.get(file.path)?.hash;
277+
let needFile = false;
278+
if (previousHash === undefined) {
279+
needFile = true;
280+
incrementalResult.added.push(file.path);
281+
} else if (previousHash !== file.hash) {
282+
needFile = true;
283+
incrementalResult.modified.push(file.path);
284+
}
285+
286+
if (needFile) {
287+
incrementalResult.files[file.path] = {
288+
type: file.type,
289+
contents: file.contents,
290+
origin: 'memory',
291+
hash: file.hash,
292+
};
293+
}
294+
}
295+
296+
// Include the removed output files
297+
incrementalResult.removed.push(
298+
...Array.from(removedOutputFiles, ([file, { type }]) => ({
299+
path: file,
300+
type,
301+
})),
302+
);
303+
304+
// Always consider asset files as added to ensure new/modified assets are available.
305+
// TODO: Consider more comprehensive asset analysis.
306+
for (const file of assetFiles) {
307+
incrementalResult.added.push(file.destination);
308+
incrementalResult.files[file.destination] = {
309+
type: BuildOutputFileType.Browser,
310+
inputPath: file.source,
311+
origin: 'disk',
312+
};
313+
}
314+
315+
return incrementalResult;
316+
}
317+
318+
// Otherwise, use a full result
239319
const result: FullResult = {
240320
kind: ResultKind.Full,
241321
warnings: warnings as ResultMessage[],

‎packages/angular/build/src/builders/application/execute-build.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export async function executeBuild(
182182
executionResult.outputFiles.push(...outputFiles);
183183

184184
const changedFiles =
185-
rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputHashes);
185+
rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo);
186186

187187
// Analyze files for bundle budget failures if present
188188
let budgetFailures: BudgetCalculatorResult[] | undefined;

‎packages/angular/build/src/builders/application/index.ts

+48-22
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export async function* buildApplicationInternal(
126126
clearScreen: normalizedOptions.clearScreen,
127127
colors: normalizedOptions.colors,
128128
jsonLogs: normalizedOptions.jsonLogs,
129+
incrementalResults: normalizedOptions.incrementalResults,
129130
logger,
130131
signal,
131132
},
@@ -157,7 +158,8 @@ export async function* buildApplication(
157158
extensions?: ApplicationBuilderExtensions,
158159
): AsyncIterable<ApplicationBuilderOutput> {
159160
let initial = true;
160-
for await (const result of buildApplicationInternal(options, context, extensions)) {
161+
const internalOptions = { ...options, incrementalResults: true };
162+
for await (const result of buildApplicationInternal(internalOptions, context, extensions)) {
161163
const outputOptions = result.detail?.['outputOptions'] as NormalizedOutputOptions | undefined;
162164

163165
if (initial) {
@@ -179,7 +181,10 @@ export async function* buildApplication(
179181
}
180182

181183
assert(outputOptions, 'Application output options are required for builder usage.');
182-
assert(result.kind === ResultKind.Full, 'Application build did not provide a full output.');
184+
assert(
185+
result.kind === ResultKind.Full || result.kind === ResultKind.Incremental,
186+
'Application build did not provide a file result output.',
187+
);
183188

184189
// TODO: Restructure output logging to better handle stdout JSON piping
185190
if (!useJSONBuildLogs) {
@@ -197,26 +202,7 @@ export async function* buildApplication(
197202
return;
198203
}
199204

200-
let typeDirectory: string;
201-
switch (file.type) {
202-
case BuildOutputFileType.Browser:
203-
case BuildOutputFileType.Media:
204-
typeDirectory = outputOptions.browser;
205-
break;
206-
case BuildOutputFileType.ServerApplication:
207-
case BuildOutputFileType.ServerRoot:
208-
typeDirectory = outputOptions.server;
209-
break;
210-
case BuildOutputFileType.Root:
211-
typeDirectory = '';
212-
break;
213-
default:
214-
throw new Error(
215-
`Unhandled write for file "${filePath}" with type "${BuildOutputFileType[file.type]}".`,
216-
);
217-
}
218-
// NOTE: 'base' is a fully resolved path at this point
219-
const fullFilePath = path.join(outputOptions.base, typeDirectory, filePath);
205+
const fullFilePath = generateFullPath(filePath, file.type, outputOptions);
220206

221207
// Ensure output subdirectories exist
222208
const fileBasePath = path.dirname(fullFilePath);
@@ -234,8 +220,48 @@ export async function* buildApplication(
234220
}
235221
});
236222

223+
// Delete any removed files if incremental
224+
if (result.kind === ResultKind.Incremental && result.removed?.length) {
225+
await Promise.all(
226+
result.removed.map((file) => {
227+
const fullFilePath = generateFullPath(file.path, file.type, outputOptions);
228+
229+
return fs.rm(fullFilePath, { force: true, maxRetries: 3 });
230+
}),
231+
);
232+
}
233+
237234
yield { success: true };
238235
}
239236
}
240237

238+
function generateFullPath(
239+
filePath: string,
240+
type: BuildOutputFileType,
241+
outputOptions: NormalizedOutputOptions,
242+
) {
243+
let typeDirectory: string;
244+
switch (type) {
245+
case BuildOutputFileType.Browser:
246+
case BuildOutputFileType.Media:
247+
typeDirectory = outputOptions.browser;
248+
break;
249+
case BuildOutputFileType.ServerApplication:
250+
case BuildOutputFileType.ServerRoot:
251+
typeDirectory = outputOptions.server;
252+
break;
253+
case BuildOutputFileType.Root:
254+
typeDirectory = '';
255+
break;
256+
default:
257+
throw new Error(
258+
`Unhandled write for file "${filePath}" with type "${BuildOutputFileType[type]}".`,
259+
);
260+
}
261+
// NOTE: 'base' is a fully resolved path at this point
262+
const fullFilePath = path.join(outputOptions.base, typeDirectory, filePath);
263+
264+
return fullFilePath;
265+
}
266+
241267
export default createBuilder(buildApplication);

‎packages/angular/build/src/builders/application/options.ts

+7
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ interface InternalOptions {
107107
*/
108108
templateUpdates?: boolean;
109109

110+
/**
111+
* Enables emitting incremental build results when in watch mode. A full build result will only be emitted
112+
* for the initial build. This option also requires watch to be enabled to have an effect.
113+
*/
114+
incrementalResults?: boolean;
115+
110116
/**
111117
* Enables instrumentation to collect code coverage data for specific files.
112118
*
@@ -475,6 +481,7 @@ export async function normalizeOptions(
475481
instrumentForCoverage,
476482
security,
477483
templateUpdates: !!options.templateUpdates,
484+
incrementalResults: !!options.incrementalResults,
478485
};
479486
}
480487

‎packages/angular/build/src/builders/application/results.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export interface FullResult extends BaseResult {
3737
export interface IncrementalResult extends BaseResult {
3838
kind: ResultKind.Incremental;
3939
added: string[];
40-
removed: string[];
40+
removed: { path: string; type: BuildOutputFileType }[];
4141
modified: string[];
4242
files: Record<string, ResultFile>;
4343
}

‎packages/angular/build/src/tools/esbuild/bundler-execution-result.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface RebuildState {
2727
componentStyleBundler: ComponentStylesheetBundler;
2828
codeBundleCache?: SourceFileCache;
2929
fileChanges: ChangedFiles;
30-
previousOutputHashes: Map<string, string>;
30+
previousOutputInfo: Map<string, { hash: string; type: BuildOutputFileType }>;
3131
templateUpdates?: Map<string, string>;
3232
}
3333

@@ -167,15 +167,19 @@ export class ExecutionResult {
167167
codeBundleCache: this.codeBundleCache,
168168
componentStyleBundler: this.componentStyleBundler,
169169
fileChanges,
170-
previousOutputHashes: new Map(this.outputFiles.map((file) => [file.path, file.hash])),
170+
previousOutputInfo: new Map(
171+
this.outputFiles.map(({ path, hash, type }) => [path, { hash, type }]),
172+
),
171173
templateUpdates: this.templateUpdates,
172174
};
173175
}
174176

175-
findChangedFiles(previousOutputHashes: Map<string, string>): Set<string> {
177+
findChangedFiles(
178+
previousOutputHashes: Map<string, { hash: string; type: BuildOutputFileType }>,
179+
): Set<string> {
176180
const changed = new Set<string>();
177181
for (const file of this.outputFiles) {
178-
const previousHash = previousOutputHashes.get(file.path);
182+
const previousHash = previousOutputHashes.get(file.path)?.hash;
179183
if (previousHash === undefined || previousHash !== file.hash) {
180184
changed.add(file.path);
181185
}

‎tests/legacy-cli/e2e.bzl

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ WEBPACK_IGNORE_TESTS = [
5151
"tests/build/server-rendering/server-routes-*",
5252
"tests/build/wasm-esm.js",
5353
"tests/build/auto-csp*",
54+
"tests/build/incremental-watch.js",
5455
]
5556

5657
def _to_glob(patterns):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import assert from 'node:assert/strict';
2+
import { readdir } from 'node:fs/promises';
3+
import { setTimeout } from 'node:timers/promises';
4+
import { getGlobalVariable } from '../../utils/env';
5+
import { appendToFile, readFile, writeFile } from '../../utils/fs';
6+
import { execAndWaitForOutputToMatch, waitForAnyProcessOutputToMatch } from '../../utils/process';
7+
8+
const buildReadyRegEx = /Application bundle generation complete\./;
9+
10+
export default async function () {
11+
const usingApplicationBuilder = getGlobalVariable('argv')['esbuild'];
12+
assert(
13+
usingApplicationBuilder,
14+
'Incremental watch E2E test should not be executed with Webpack.',
15+
);
16+
17+
// Perform an initial build in watch mode
18+
await execAndWaitForOutputToMatch(
19+
'ng',
20+
['build', '--watch', '--configuration=development'],
21+
buildReadyRegEx,
22+
);
23+
await setTimeout(500);
24+
const initialOutputFiles = await readdir('dist/test-project/browser');
25+
26+
const originalMain = await readFile('src/main.ts');
27+
28+
// Add a dynamic import to create an additional output chunk
29+
await Promise.all([
30+
waitForAnyProcessOutputToMatch(buildReadyRegEx),
31+
await writeFile(
32+
'src/a.ts',
33+
`
34+
export function sayHi() {
35+
console.log('hi');
36+
}
37+
`,
38+
),
39+
appendToFile('src/main.ts', `\nimport('./a').then((m) => m.sayHi());`),
40+
]);
41+
await setTimeout(500);
42+
const intermediateOutputFiles = await readdir('dist/test-project/browser');
43+
assert(
44+
initialOutputFiles.length < intermediateOutputFiles.length,
45+
'Additional chunks should be present',
46+
);
47+
48+
// Remove usage of dynamic import which should remove the additional output chunk
49+
await Promise.all([
50+
waitForAnyProcessOutputToMatch(buildReadyRegEx),
51+
writeFile('src/main.ts', originalMain),
52+
]);
53+
await setTimeout(500);
54+
const finalOutputFiles = await readdir('dist/test-project/browser');
55+
assert.equal(
56+
initialOutputFiles.length,
57+
finalOutputFiles.length,
58+
'Final chunk count should be equal to initial chunk count.',
59+
);
60+
}

0 commit comments

Comments
 (0)
Please sign in to comment.