Skip to content

Commit 9c44299

Browse files
committed
refactor(@angular/build): add initial component HMR source file analysis
1 parent e01d329 commit 9c44299

File tree

2 files changed

+208
-46
lines changed

2 files changed

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

0 commit comments

Comments
 (0)