Skip to content

Commit 521df96

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

File tree

2 files changed

+216
-46
lines changed

2 files changed

+216
-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: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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 (
88+
getRangeText(updatedNode.heritageClauses, updated) !==
89+
getRangeText(staleNode.heritageClauses, stale)
90+
) {
91+
return null;
92+
}
93+
94+
// TODO: Check modifiers
95+
96+
// Check for component class nodes
97+
const meta = compiler.getMeta(updatedNode);
98+
if (meta?.decorator && (meta as { isComponent?: boolean }).isComponent === true) {
99+
const updatedDecorators = ts.getDecorators(updatedNode);
100+
const staleDecorators = ts.getDecorators(staleNode);
101+
if (!staleDecorators || staleDecorators.length !== updatedDecorators?.length) {
102+
return null;
103+
}
104+
105+
// TODO: Check other decorators instead of assuming all multi-decorator components are unsupported
106+
if (staleDecorators.length > 1) {
107+
return null;
108+
}
109+
110+
// Find index of component metadata decorator
111+
const metaDecoratorIndex = updatedDecorators?.indexOf(meta.decorator);
112+
assert(
113+
metaDecoratorIndex !== undefined,
114+
'Component metadata decorator should always be present on component class.',
115+
);
116+
const updatedDecoratorExpression = meta.decorator.expression;
117+
assert(
118+
ts.isCallExpression(updatedDecoratorExpression) &&
119+
updatedDecoratorExpression.arguments.length === 1,
120+
'Component metadata decorator should contain a call expression with a single argument.',
121+
);
122+
123+
const staleDecoratorExpression = staleDecorators[metaDecoratorIndex]?.expression;
124+
if (
125+
!staleDecoratorExpression ||
126+
!ts.isCallExpression(staleDecoratorExpression) ||
127+
staleDecoratorExpression.arguments.length !== 1
128+
) {
129+
return null;
130+
}
131+
132+
// Check decorator name/expression
133+
// NOTE: This would typically be `Component` but can also be a property expression or some other alias.
134+
// To avoid complex checks, this ensures the textual representation does not change. This has a low chance
135+
// of a false positive if the expression is changed to still reference the `Component` type but has different
136+
// text. However, it is rare for `Component` to not be used directly and additionally unlikely that it would
137+
// be changed between edits. A false positive would also only lead to a difference of a full page reload versus
138+
// an HMR update.
139+
if (
140+
updatedDecoratorExpression.expression.getText(updated) !==
141+
staleDecoratorExpression.expression.getText(stale)
142+
) {
143+
return null;
144+
}
145+
146+
// Compare component meta decorator object literals
147+
if (hasUnsupportedMetaUpdates(staleDecoratorExpression, updatedDecoratorExpression)) {
148+
return null;
149+
}
150+
151+
// Compare text of the member nodes to determine if any changes have occurred
152+
if (getRangeText(updatedNode.members, updated) !== getRangeText(staleNode.members, stale)) {
153+
// A change to a member outside a component's metadata is unsupported
154+
return null;
155+
}
156+
157+
// If all previous class checks passed, this class is supported for HMR updates
158+
candidates.push(updatedNode);
159+
continue;
160+
}
161+
}
162+
163+
// Compare text of the statement nodes to determine if any changes have occurred
164+
// TODO: Expand this to check semantic updates for each node kind
165+
const updatedNodeText = updatedNode.getText(updated);
166+
const staleNodeText = staleNode.getText(stale);
167+
if (updatedNodeText !== staleNodeText) {
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+
// TODO: Create lists of added/removed/modified fields and compare those
185+
186+
if (staleObject.properties.length !== updatedObject.properties.length) {
187+
return true;
188+
}
189+
190+
// TODO: Actually check values here
191+
192+
// For testing assume all changes are supported
193+
return false;
194+
}
195+
196+
function getRangeText(
197+
range: ts.ReadonlyTextRange | undefined,
198+
sourceFile: ts.SourceFile,
199+
): string | undefined {
200+
return range && sourceFile.text.substring(range.pos, range.end);
201+
}

0 commit comments

Comments
 (0)