Skip to content

Commit 77abb4d

Browse files
committed
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 fe1ae69 commit 77abb4d

File tree

4 files changed

+164
-38
lines changed

4 files changed

+164
-38
lines changed

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

Lines changed: 107 additions & 14 deletions
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* emitOutputResults(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* emitOutputResults(
200+
result,
201+
outputOptions,
202+
incrementalResults ? rebuildState.previousOutputHashes : 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+
async function* emitOutputResults(
202214
{
203215
outputFiles,
204216
assetFiles,
@@ -210,32 +222,113 @@ async function emitOutputResult(
210222
templateUpdates,
211223
}: ExecutionResult,
212224
outputOptions: NormalizedApplicationBuildOptions['outputOptions'],
213-
): Promise<Result> {
225+
previousOutputHashes?: ReadonlyMap<string, string>,
226+
): AsyncIterable<Result> {
214227
if (errors.length > 0) {
215-
return {
228+
yield {
216229
kind: ResultKind.Failure,
217230
errors: errors as ResultMessage[],
218231
warnings: warnings as ResultMessage[],
219232
detail: {
220233
outputOptions,
221234
},
222235
};
236+
237+
// Only one failure result if there are errors
238+
return;
223239
}
224240

225-
// Template updates only exist if no other changes have occurred
226-
if (templateUpdates?.size) {
241+
// Template updates only exist if no other JS changes have occurred
242+
const hasTemplateUpdates = !!templateUpdates?.size;
243+
if (hasTemplateUpdates) {
227244
const updateResult: ComponentUpdateResult = {
228245
kind: ResultKind.ComponentUpdate,
229-
updates: Array.from(templateUpdates).map(([id, content]) => ({
246+
updates: Array.from(templateUpdates, ([id, content]) => ({
230247
type: 'template',
231248
id,
232249
content,
233250
})),
234251
};
235252

236-
return updateResult;
253+
yield updateResult;
254+
}
255+
256+
// Use an incremental result if previous output information is available
257+
if (previousOutputHashes) {
258+
const incrementalResult: IncrementalResult = {
259+
kind: ResultKind.Incremental,
260+
warnings: warnings as ResultMessage[],
261+
added: [],
262+
removed: [],
263+
modified: [],
264+
files: {},
265+
detail: {
266+
externalMetadata,
267+
htmlIndexPath,
268+
htmlBaseHref,
269+
outputOptions,
270+
},
271+
};
272+
273+
// Initially assume all previous output files have been removed
274+
const removedOutputFiles = new Set(previousOutputHashes.keys());
275+
276+
for (const file of outputFiles) {
277+
removedOutputFiles.delete(file.path);
278+
279+
// Temporarily ignore JS files until Angular compiler plugin refactor to allow
280+
// bypassing application code bundling for template affecting only changes.
281+
// TODO: Remove once refactor is complete.
282+
if (hasTemplateUpdates && /\.[cm]?js$/.test(file.path)) {
283+
continue;
284+
}
285+
286+
const previousHash = previousOutputHashes.get(file.path);
287+
let needFile = false;
288+
if (previousHash === undefined) {
289+
needFile = true;
290+
incrementalResult.added.push(file.path);
291+
} else if (previousHash !== file.hash) {
292+
needFile = true;
293+
incrementalResult.modified.push(file.path);
294+
}
295+
296+
if (needFile) {
297+
incrementalResult.files[file.path] = {
298+
type: file.type,
299+
contents: file.contents,
300+
origin: 'memory',
301+
hash: file.hash,
302+
};
303+
}
304+
}
305+
306+
// Include the removed output files
307+
incrementalResult.removed.push(
308+
...Array.from(removedOutputFiles, (file) => ({
309+
path: file,
310+
// FIXME: use actual file type
311+
type: BuildOutputFileType.Browser,
312+
})),
313+
);
314+
315+
// Always consider asset files as added to ensure new/modified assets are available.
316+
// TODO: Consider more comprehensive asset analysis.
317+
for (const file of assetFiles) {
318+
incrementalResult.added.push(file.destination);
319+
incrementalResult.files[file.destination] = {
320+
type: BuildOutputFileType.Browser,
321+
inputPath: file.source,
322+
origin: 'disk',
323+
};
324+
}
325+
326+
yield incrementalResult;
327+
328+
return;
237329
}
238330

331+
// Otherwise, use a full result
239332
const result: FullResult = {
240333
kind: ResultKind.Full,
241334
warnings: warnings as ResultMessage[],
@@ -263,5 +356,5 @@ async function emitOutputResult(
263356
};
264357
}
265358

266-
return result;
359+
yield result;
267360
}

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

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
NormalizedOutputOptions,
2626
normalizeOptions,
2727
} from './options';
28-
import { Result, ResultKind } from './results';
28+
import { Result, ResultFile, ResultKind } from './results';
2929
import { Schema as ApplicationBuilderOptions } from './schema';
3030

3131
export type { ApplicationBuilderOptions };
@@ -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

Lines changed: 7 additions & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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
}

0 commit comments

Comments
 (0)