Skip to content

Commit 25a6b22

Browse files
committed
refactor(@angular/build): add initial component HMR source file analysis
1 parent 11289c4 commit 25a6b22

File tree

2 files changed

+249
-46
lines changed

2 files changed

+249
-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: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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 (!equalRangeText(updatedDecoratorExpression, updated, staleDecoratorExpression, stale)) {
145+
return null;
146+
}
147+
148+
// Compare component meta decorator object literals
149+
if (hasUnsupportedMetaUpdates(staleDecoratorExpression, updatedDecoratorExpression)) {
150+
return null;
151+
}
152+
153+
// Compare text of the member nodes to determine if any changes have occurred
154+
if (!equalRangeText(updatedNode.members, updated, staleNode.members, stale)) {
155+
// A change to a member outside a component's metadata is unsupported
156+
return null;
157+
}
158+
159+
// If all previous class checks passed, this class is supported for HMR updates
160+
candidates.push(updatedNode);
161+
continue;
162+
}
163+
}
164+
165+
// Compare text of the statement nodes to determine if any changes have occurred
166+
// TODO: Consider expanding this to check semantic updates for each node kind
167+
if (!equalRangeText(updatedNode, updated, staleNode, stale)) {
168+
// A change to a statement outside a component's metadata is unsupported
169+
return null;
170+
}
171+
}
172+
173+
return candidates;
174+
}
175+
176+
function hasUnsupportedMetaUpdates(stale: ts.CallExpression, updated: ts.CallExpression): boolean {
177+
const staleObject = stale.arguments[0];
178+
const updatedObject = updated.arguments[0];
179+
180+
if (!ts.isObjectLiteralExpression(staleObject) || !ts.isObjectLiteralExpression(updatedObject)) {
181+
return true;
182+
}
183+
184+
const staleLiteralFields = new Map<string, unknown>();
185+
const staleNodeFields: ts.Node[] = [];
186+
187+
for (const property of staleObject.properties) {
188+
if (!ts.isPropertyAssignment(property) || ts.isComputedPropertyName(property.name)) {
189+
staleNodeFields.push(property);
190+
continue;
191+
}
192+
193+
const name = property.name.text;
194+
195+
if (!ts.isLiteralExpression(property.initializer)) {
196+
// staleNodeFields.push(prop)
197+
}
198+
}
199+
200+
// For testing assume all changes are supported
201+
return false;
202+
}
203+
204+
function equalRangeText(
205+
firstRange: ts.ReadonlyTextRange | undefined,
206+
firstSource: ts.SourceFile,
207+
secondRange: ts.ReadonlyTextRange | undefined,
208+
secondSource: ts.SourceFile,
209+
): boolean {
210+
// Check matching undefined values
211+
if (firstRange === undefined) {
212+
return secondRange === undefined;
213+
} else if (secondRange === undefined) {
214+
return firstRange === undefined;
215+
}
216+
217+
// Ensure lengths are equal
218+
const firstLength = firstRange.end - firstRange.pos;
219+
const secondLength = secondRange.end - secondRange.pos;
220+
if (firstLength !== secondLength) {
221+
return false;
222+
}
223+
224+
// Check each character
225+
for (let i = 0; i < firstLength; ++i) {
226+
const firstChar = firstSource.text.charCodeAt(i + firstRange.pos);
227+
const secondChar = secondSource.text.charCodeAt(i + secondRange.pos);
228+
if (firstChar !== secondChar) {
229+
return false;
230+
}
231+
}
232+
233+
return true;
234+
}

0 commit comments

Comments
 (0)