Skip to content

Commit 5669460

Browse files
committed
refactor(@angular/build): add initial component HMR source file analysis
When component template HMR support is enabled (`NG_HMR_TEMPLATES=1`), TypeScript file changes will now be analyzed to determine if Angular component metadata has changed and if the changes can support a hot replacement. Any other changes to a TypeScript file will cause a full page reload to avoid inconsistent state between the code and running application. The analysis currently has an upper limit of 32 modified files at one time to prevent a large of amount of analysis to be performed which may take longer than a full rebuild. This value may be adjusted based on feedback. Component template HMR is currently experimental and may not support all template modifications. Both inline and file-based templates are now supported. However, rebuild times have not yet been optimized.
1 parent 11289c4 commit 5669460

File tree

2 files changed

+287
-46
lines changed

2 files changed

+287
-46
lines changed

packages/angular/build/src/tools/angular/compilation/aot-compilation.ts

Lines changed: 15 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ import {
1919
import { replaceBootstrap } from '../transformers/jit-bootstrap-transformer';
2020
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
2121
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
22+
import { collectHmrCandidates } from './hmr-candidates';
23+
24+
/**
25+
* The modified files count limit for performing component HMR analysis.
26+
* Performing content analysis for a large amount of files can result in longer rebuild times
27+
* than a full rebuild would entail.
28+
*/
29+
const HMR_MODIFIED_FILE_LIMIT = 32;
2230

2331
class AngularCompilationState {
2432
constructor(
@@ -66,9 +74,14 @@ export class AotCompilation extends AngularCompilation {
6674
hostOptions.externalStylesheets ??= new Map();
6775
}
6876

77+
const useHmr =
78+
compilerOptions['_enableHmr'] &&
79+
hostOptions.modifiedFiles &&
80+
hostOptions.modifiedFiles.size <= HMR_MODIFIED_FILE_LIMIT;
81+
6982
// Collect stale source files for HMR analysis of inline component resources
7083
let staleSourceFiles;
71-
if (compilerOptions['_enableHmr'] && hostOptions.modifiedFiles && this.#state) {
84+
if (useHmr && hostOptions.modifiedFiles && this.#state) {
7285
for (const modifiedFile of hostOptions.modifiedFiles) {
7386
const sourceFile = this.#state.typeScriptProgram.getSourceFile(modifiedFile);
7487
if (sourceFile) {
@@ -107,7 +120,7 @@ export class AotCompilation extends AngularCompilation {
107120
await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync());
108121

109122
let templateUpdates;
110-
if (compilerOptions['_enableHmr'] && hostOptions.modifiedFiles && this.#state) {
123+
if (useHmr && hostOptions.modifiedFiles && this.#state) {
111124
const componentNodes = collectHmrCandidates(
112125
hostOptions.modifiedFiles,
113126
angularProgram,
@@ -432,47 +445,3 @@ function findAffectedFiles(
432445

433446
return affectedFiles;
434447
}
435-
436-
function collectHmrCandidates(
437-
modifiedFiles: Set<string>,
438-
{ compiler }: ng.NgtscProgram,
439-
staleSourceFiles: Map<string, ts.SourceFile> | undefined,
440-
): Set<ts.ClassDeclaration> {
441-
const candidates = new Set<ts.ClassDeclaration>();
442-
443-
for (const file of modifiedFiles) {
444-
const templateFileNodes = compiler.getComponentsWithTemplateFile(file);
445-
if (templateFileNodes.size) {
446-
templateFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration));
447-
continue;
448-
}
449-
450-
const styleFileNodes = compiler.getComponentsWithStyleFile(file);
451-
if (styleFileNodes.size) {
452-
styleFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration));
453-
continue;
454-
}
455-
456-
const staleSource = staleSourceFiles?.get(file);
457-
if (staleSource === undefined) {
458-
// Unknown file requires a rebuild so clear out the candidates and stop collecting
459-
candidates.clear();
460-
break;
461-
}
462-
463-
const updatedSource = compiler.getCurrentProgram().getSourceFile(file);
464-
if (updatedSource === undefined) {
465-
// No longer existing program file requires a rebuild so clear out the candidates and stop collecting
466-
candidates.clear();
467-
break;
468-
}
469-
470-
// Compare the stale and updated file for changes
471-
472-
// TODO: Implement -- for now assume a rebuild is needed
473-
candidates.clear();
474-
break;
475-
}
476-
477-
return candidates;
478-
}
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import type ng from '@angular/compiler-cli';
10+
import assert from 'node:assert';
11+
import ts from 'typescript';
12+
13+
export function collectHmrCandidates(
14+
modifiedFiles: Set<string>,
15+
{ compiler }: ng.NgtscProgram,
16+
staleSourceFiles: Map<string, ts.SourceFile> | undefined,
17+
): Set<ts.ClassDeclaration> {
18+
const candidates = new Set<ts.ClassDeclaration>();
19+
20+
for (const file of modifiedFiles) {
21+
const templateFileNodes = compiler.getComponentsWithTemplateFile(file);
22+
if (templateFileNodes.size) {
23+
templateFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration));
24+
continue;
25+
}
26+
27+
const styleFileNodes = compiler.getComponentsWithStyleFile(file);
28+
if (styleFileNodes.size) {
29+
styleFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration));
30+
continue;
31+
}
32+
33+
const staleSource = staleSourceFiles?.get(file);
34+
if (staleSource === undefined) {
35+
// Unknown file requires a rebuild so clear out the candidates and stop collecting
36+
candidates.clear();
37+
break;
38+
}
39+
40+
const updatedSource = compiler.getCurrentProgram().getSourceFile(file);
41+
if (updatedSource === undefined) {
42+
// No longer existing program file requires a rebuild so clear out the candidates and stop collecting
43+
candidates.clear();
44+
break;
45+
}
46+
47+
// Analyze the stale and updated file for changes
48+
const fileCandidates = analyzeFileUpdates(staleSource, updatedSource, compiler);
49+
if (fileCandidates) {
50+
fileCandidates.forEach((node) => candidates.add(node));
51+
} else {
52+
// Unsupported HMR changes present
53+
// Only template and style literal changes are allowed.
54+
candidates.clear();
55+
break;
56+
}
57+
}
58+
59+
return candidates;
60+
}
61+
62+
// null -> need rebuild
63+
function analyzeFileUpdates(
64+
stale: ts.SourceFile,
65+
updated: ts.SourceFile,
66+
compiler: ng.NgtscProgram['compiler'],
67+
): ts.ClassDeclaration[] | null {
68+
if (stale.statements.length !== updated.statements.length) {
69+
return null;
70+
}
71+
72+
const candidates: ts.ClassDeclaration[] = [];
73+
74+
for (let i = 0; i < updated.statements.length; ++i) {
75+
const updatedNode = updated.statements[i];
76+
const staleNode = stale.statements[i];
77+
78+
if (ts.isClassDeclaration(updatedNode)) {
79+
if (!ts.isClassDeclaration(staleNode)) {
80+
return null;
81+
}
82+
83+
// Check class declaration differences (name/heritage/modifiers)
84+
if (updatedNode.name?.text !== staleNode.name?.text) {
85+
return null;
86+
}
87+
if (!equalRangeText(updatedNode.heritageClauses, updated, staleNode.heritageClauses, stale)) {
88+
return null;
89+
}
90+
const updatedModifiers = ts.getModifiers(updatedNode);
91+
const staleModifiers = ts.getModifiers(staleNode);
92+
if (
93+
updatedModifiers?.length !== staleModifiers?.length ||
94+
!updatedModifiers?.every((updatedModifier) =>
95+
staleModifiers?.some((staleModifier) => updatedModifier.kind === staleModifier.kind),
96+
)
97+
) {
98+
return null;
99+
}
100+
101+
// Check for component class nodes
102+
const meta = compiler.getMeta(updatedNode);
103+
if (meta?.decorator && (meta as { isComponent?: boolean }).isComponent === true) {
104+
const updatedDecorators = ts.getDecorators(updatedNode);
105+
const staleDecorators = ts.getDecorators(staleNode);
106+
if (!staleDecorators || staleDecorators.length !== updatedDecorators?.length) {
107+
return null;
108+
}
109+
110+
// TODO: Check other decorators instead of assuming all multi-decorator components are unsupported
111+
if (staleDecorators.length > 1) {
112+
return null;
113+
}
114+
115+
// Find index of component metadata decorator
116+
const metaDecoratorIndex = updatedDecorators?.indexOf(meta.decorator);
117+
assert(
118+
metaDecoratorIndex !== undefined,
119+
'Component metadata decorator should always be present on component class.',
120+
);
121+
const updatedDecoratorExpression = meta.decorator.expression;
122+
assert(
123+
ts.isCallExpression(updatedDecoratorExpression) &&
124+
updatedDecoratorExpression.arguments.length === 1,
125+
'Component metadata decorator should contain a call expression with a single argument.',
126+
);
127+
128+
const staleDecoratorExpression = staleDecorators[metaDecoratorIndex]?.expression;
129+
if (
130+
!staleDecoratorExpression ||
131+
!ts.isCallExpression(staleDecoratorExpression) ||
132+
staleDecoratorExpression.arguments.length !== 1
133+
) {
134+
return null;
135+
}
136+
137+
// Check decorator name/expression
138+
// NOTE: This would typically be `Component` but can also be a property expression or some other alias.
139+
// To avoid complex checks, this ensures the textual representation does not change. This has a low chance
140+
// of a false positive if the expression is changed to still reference the `Component` type but has different
141+
// text. However, it is rare for `Component` to not be used directly and additionally unlikely that it would
142+
// be changed between edits. A false positive would also only lead to a difference of a full page reload versus
143+
// an HMR update.
144+
if (
145+
!equalRangeText(
146+
updatedDecoratorExpression.expression,
147+
updated,
148+
staleDecoratorExpression.expression,
149+
stale,
150+
)
151+
) {
152+
return null;
153+
}
154+
155+
// Compare component meta decorator object literals
156+
if (
157+
hasUnsupportedMetaUpdates(
158+
staleDecoratorExpression,
159+
stale,
160+
updatedDecoratorExpression,
161+
updated,
162+
)
163+
) {
164+
return null;
165+
}
166+
167+
// Compare text of the member nodes to determine if any changes have occurred
168+
if (!equalRangeText(updatedNode.members, updated, staleNode.members, stale)) {
169+
// A change to a member outside a component's metadata is unsupported
170+
return null;
171+
}
172+
173+
// If all previous class checks passed, this class is supported for HMR updates
174+
candidates.push(updatedNode);
175+
continue;
176+
}
177+
}
178+
179+
// Compare text of the statement nodes to determine if any changes have occurred
180+
// TODO: Consider expanding this to check semantic updates for each node kind
181+
if (!equalRangeText(updatedNode, updated, staleNode, stale)) {
182+
// A change to a statement outside a component's metadata is unsupported
183+
return null;
184+
}
185+
}
186+
187+
return candidates;
188+
}
189+
190+
const SUPPORTED_FIELDS = new Set(['template', 'templateUrl', 'styles', 'styleUrl', 'stylesUrl']);
191+
192+
function hasUnsupportedMetaUpdates(
193+
staleCall: ts.CallExpression,
194+
staleSource: ts.SourceFile,
195+
updatedCall: ts.CallExpression,
196+
updatedSource: ts.SourceFile,
197+
): boolean {
198+
const staleObject = staleCall.arguments[0];
199+
const updatedObject = updatedCall.arguments[0];
200+
201+
if (!ts.isObjectLiteralExpression(staleObject) || !ts.isObjectLiteralExpression(updatedObject)) {
202+
return true;
203+
}
204+
205+
const unsupportedFields: ts.Node[] = [];
206+
207+
for (const property of staleObject.properties) {
208+
if (!ts.isPropertyAssignment(property) || ts.isComputedPropertyName(property.name)) {
209+
// Unsupported object literal property
210+
return true;
211+
}
212+
213+
const name = property.name.text;
214+
if (SUPPORTED_FIELDS.has(name)) {
215+
continue;
216+
}
217+
218+
unsupportedFields.push(property.initializer);
219+
}
220+
221+
let i = 0;
222+
for (const property of updatedObject.properties) {
223+
if (!ts.isPropertyAssignment(property) || ts.isComputedPropertyName(property.name)) {
224+
// Unsupported object literal property
225+
return true;
226+
}
227+
228+
const name = property.name.text;
229+
if (SUPPORTED_FIELDS.has(name)) {
230+
continue;
231+
}
232+
233+
// Compare in order
234+
if (!equalRangeText(property.initializer, updatedSource, unsupportedFields[i++], staleSource)) {
235+
return true;
236+
}
237+
}
238+
239+
return i !== unsupportedFields.length;
240+
}
241+
242+
function equalRangeText(
243+
firstRange: ts.ReadonlyTextRange | undefined,
244+
firstSource: ts.SourceFile,
245+
secondRange: ts.ReadonlyTextRange | undefined,
246+
secondSource: ts.SourceFile,
247+
): boolean {
248+
// Check matching undefined values
249+
if (firstRange === undefined) {
250+
return secondRange === undefined;
251+
} else if (secondRange === undefined) {
252+
return firstRange === undefined;
253+
}
254+
255+
// Ensure lengths are equal
256+
const firstLength = firstRange.end - firstRange.pos;
257+
const secondLength = secondRange.end - secondRange.pos;
258+
if (firstLength !== secondLength) {
259+
return false;
260+
}
261+
262+
// Check each character
263+
for (let i = 0; i < firstLength; ++i) {
264+
const firstChar = firstSource.text.charCodeAt(i + firstRange.pos);
265+
const secondChar = secondSource.text.charCodeAt(i + secondRange.pos);
266+
if (firstChar !== secondChar) {
267+
return false;
268+
}
269+
}
270+
271+
return true;
272+
}

0 commit comments

Comments
 (0)