mirror of
https://github.com/angular/angular-cli.git
synced 2025-05-16 02:24:10 +08:00
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.
This commit is contained in:
parent
a8ea9cf6ad
commit
378624d3f7
@ -19,6 +19,14 @@ import {
|
||||
import { replaceBootstrap } from '../transformers/jit-bootstrap-transformer';
|
||||
import { createWorkerTransformer } from '../transformers/web-worker-transformer';
|
||||
import { AngularCompilation, DiagnosticModes, EmitFileResult } from './angular-compilation';
|
||||
import { collectHmrCandidates } from './hmr-candidates';
|
||||
|
||||
/**
|
||||
* The modified files count limit for performing component HMR analysis.
|
||||
* Performing content analysis for a large amount of files can result in longer rebuild times
|
||||
* than a full rebuild would entail.
|
||||
*/
|
||||
const HMR_MODIFIED_FILE_LIMIT = 32;
|
||||
|
||||
class AngularCompilationState {
|
||||
constructor(
|
||||
@ -66,9 +74,14 @@ export class AotCompilation extends AngularCompilation {
|
||||
hostOptions.externalStylesheets ??= new Map();
|
||||
}
|
||||
|
||||
const useHmr =
|
||||
compilerOptions['_enableHmr'] &&
|
||||
hostOptions.modifiedFiles &&
|
||||
hostOptions.modifiedFiles.size <= HMR_MODIFIED_FILE_LIMIT;
|
||||
|
||||
// Collect stale source files for HMR analysis of inline component resources
|
||||
let staleSourceFiles;
|
||||
if (compilerOptions['_enableHmr'] && hostOptions.modifiedFiles && this.#state) {
|
||||
if (useHmr && hostOptions.modifiedFiles && this.#state) {
|
||||
for (const modifiedFile of hostOptions.modifiedFiles) {
|
||||
const sourceFile = this.#state.typeScriptProgram.getSourceFile(modifiedFile);
|
||||
if (sourceFile) {
|
||||
@ -107,7 +120,7 @@ export class AotCompilation extends AngularCompilation {
|
||||
await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync());
|
||||
|
||||
let templateUpdates;
|
||||
if (compilerOptions['_enableHmr'] && hostOptions.modifiedFiles && this.#state) {
|
||||
if (useHmr && hostOptions.modifiedFiles && this.#state) {
|
||||
const componentNodes = collectHmrCandidates(
|
||||
hostOptions.modifiedFiles,
|
||||
angularProgram,
|
||||
@ -432,47 +445,3 @@ function findAffectedFiles(
|
||||
|
||||
return affectedFiles;
|
||||
}
|
||||
|
||||
function collectHmrCandidates(
|
||||
modifiedFiles: Set<string>,
|
||||
{ compiler }: ng.NgtscProgram,
|
||||
staleSourceFiles: Map<string, ts.SourceFile> | undefined,
|
||||
): Set<ts.ClassDeclaration> {
|
||||
const candidates = new Set<ts.ClassDeclaration>();
|
||||
|
||||
for (const file of modifiedFiles) {
|
||||
const templateFileNodes = compiler.getComponentsWithTemplateFile(file);
|
||||
if (templateFileNodes.size) {
|
||||
templateFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration));
|
||||
continue;
|
||||
}
|
||||
|
||||
const styleFileNodes = compiler.getComponentsWithStyleFile(file);
|
||||
if (styleFileNodes.size) {
|
||||
styleFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration));
|
||||
continue;
|
||||
}
|
||||
|
||||
const staleSource = staleSourceFiles?.get(file);
|
||||
if (staleSource === undefined) {
|
||||
// Unknown file requires a rebuild so clear out the candidates and stop collecting
|
||||
candidates.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
const updatedSource = compiler.getCurrentProgram().getSourceFile(file);
|
||||
if (updatedSource === undefined) {
|
||||
// No longer existing program file requires a rebuild so clear out the candidates and stop collecting
|
||||
candidates.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
// Compare the stale and updated file for changes
|
||||
|
||||
// TODO: Implement -- for now assume a rebuild is needed
|
||||
candidates.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
@ -0,0 +1,314 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import type ng from '@angular/compiler-cli';
|
||||
import assert from 'node:assert';
|
||||
import ts from 'typescript';
|
||||
|
||||
/**
|
||||
* Analyzes one or more modified files for changes to determine if any
|
||||
* class declarations for Angular components are candidates for hot
|
||||
* module replacement (HMR). If any source files are also modified but
|
||||
* are not candidates then all candidates become invalid. This invalidation
|
||||
* ensures that a full rebuild occurs and the running application stays
|
||||
* synchronized with the code.
|
||||
* @param modifiedFiles A set of modified files to analyze.
|
||||
* @param param1 An Angular compiler instance
|
||||
* @param staleSourceFiles A map of paths to previous source file instances.
|
||||
* @returns A set of HMR candidate component class declarations.
|
||||
*/
|
||||
export function collectHmrCandidates(
|
||||
modifiedFiles: Set<string>,
|
||||
{ compiler }: ng.NgtscProgram,
|
||||
staleSourceFiles: Map<string, ts.SourceFile> | undefined,
|
||||
): Set<ts.ClassDeclaration> {
|
||||
const candidates = new Set<ts.ClassDeclaration>();
|
||||
|
||||
for (const file of modifiedFiles) {
|
||||
// If the file is a template for component(s), add component classes as candidates
|
||||
const templateFileNodes = compiler.getComponentsWithTemplateFile(file);
|
||||
if (templateFileNodes.size) {
|
||||
templateFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration));
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the file is a style for component(s), add component classes as candidates
|
||||
const styleFileNodes = compiler.getComponentsWithStyleFile(file);
|
||||
if (styleFileNodes.size) {
|
||||
styleFileNodes.forEach((node) => candidates.add(node as ts.ClassDeclaration));
|
||||
continue;
|
||||
}
|
||||
|
||||
const staleSource = staleSourceFiles?.get(file);
|
||||
if (staleSource === undefined) {
|
||||
// Unknown file requires a rebuild so clear out the candidates and stop collecting
|
||||
candidates.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
const updatedSource = compiler.getCurrentProgram().getSourceFile(file);
|
||||
if (updatedSource === undefined) {
|
||||
// No longer existing program file requires a rebuild so clear out the candidates and stop collecting
|
||||
candidates.clear();
|
||||
break;
|
||||
}
|
||||
|
||||
// Analyze the stale and updated file for changes
|
||||
const fileCandidates = analyzeFileUpdates(staleSource, updatedSource, compiler);
|
||||
if (fileCandidates) {
|
||||
fileCandidates.forEach((node) => candidates.add(node));
|
||||
} else {
|
||||
// Unsupported HMR changes present
|
||||
// Only template and style literal changes are allowed.
|
||||
candidates.clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes the updates of a source file for potential HMR component class candidates.
|
||||
* A source file can contain candidates if only the Angular component metadata of a class
|
||||
* has been changed and the metadata changes are only of supported fields.
|
||||
* @param stale The stale (previous) source file instance.
|
||||
* @param updated The updated source file instance.
|
||||
* @param compiler An Angular compiler instance.
|
||||
* @returns An array of candidate class declarations; or `null` if unsupported changes are present.
|
||||
*/
|
||||
function analyzeFileUpdates(
|
||||
stale: ts.SourceFile,
|
||||
updated: ts.SourceFile,
|
||||
compiler: ng.NgtscProgram['compiler'],
|
||||
): ts.ClassDeclaration[] | null {
|
||||
if (stale.statements.length !== updated.statements.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates: ts.ClassDeclaration[] = [];
|
||||
|
||||
for (let i = 0; i < updated.statements.length; ++i) {
|
||||
const updatedNode = updated.statements[i];
|
||||
const staleNode = stale.statements[i];
|
||||
|
||||
if (ts.isClassDeclaration(updatedNode)) {
|
||||
if (!ts.isClassDeclaration(staleNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check class declaration differences (name/heritage/modifiers)
|
||||
if (updatedNode.name?.text !== staleNode.name?.text) {
|
||||
return null;
|
||||
}
|
||||
if (!equalRangeText(updatedNode.heritageClauses, updated, staleNode.heritageClauses, stale)) {
|
||||
return null;
|
||||
}
|
||||
const updatedModifiers = ts.getModifiers(updatedNode);
|
||||
const staleModifiers = ts.getModifiers(staleNode);
|
||||
if (
|
||||
updatedModifiers?.length !== staleModifiers?.length ||
|
||||
!updatedModifiers?.every((updatedModifier) =>
|
||||
staleModifiers?.some((staleModifier) => updatedModifier.kind === staleModifier.kind),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for component class nodes
|
||||
const meta = compiler.getMeta(updatedNode);
|
||||
if (meta?.decorator && (meta as { isComponent?: boolean }).isComponent === true) {
|
||||
const updatedDecorators = ts.getDecorators(updatedNode);
|
||||
const staleDecorators = ts.getDecorators(staleNode);
|
||||
if (!staleDecorators || staleDecorators.length !== updatedDecorators?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Check other decorators instead of assuming all multi-decorator components are unsupported
|
||||
if (staleDecorators.length > 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find index of component metadata decorator
|
||||
const metaDecoratorIndex = updatedDecorators?.indexOf(meta.decorator);
|
||||
assert(
|
||||
metaDecoratorIndex !== undefined,
|
||||
'Component metadata decorator should always be present on component class.',
|
||||
);
|
||||
const updatedDecoratorExpression = meta.decorator.expression;
|
||||
assert(
|
||||
ts.isCallExpression(updatedDecoratorExpression) &&
|
||||
updatedDecoratorExpression.arguments.length === 1,
|
||||
'Component metadata decorator should contain a call expression with a single argument.',
|
||||
);
|
||||
|
||||
// Check the matching stale index for the component decorator
|
||||
const staleDecoratorExpression = staleDecorators[metaDecoratorIndex]?.expression;
|
||||
if (
|
||||
!staleDecoratorExpression ||
|
||||
!ts.isCallExpression(staleDecoratorExpression) ||
|
||||
staleDecoratorExpression.arguments.length !== 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check decorator name/expression
|
||||
// NOTE: This would typically be `Component` but can also be a property expression or some other alias.
|
||||
// To avoid complex checks, this ensures the textual representation does not change. This has a low chance
|
||||
// of a false positive if the expression is changed to still reference the `Component` type but has different
|
||||
// text. However, it is rare for `Component` to not be used directly and additionally unlikely that it would
|
||||
// be changed between edits. A false positive would also only lead to a difference of a full page reload versus
|
||||
// an HMR update.
|
||||
if (
|
||||
!equalRangeText(
|
||||
updatedDecoratorExpression.expression,
|
||||
updated,
|
||||
staleDecoratorExpression.expression,
|
||||
stale,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compare component meta decorator object literals
|
||||
if (
|
||||
hasUnsupportedMetaUpdates(
|
||||
staleDecoratorExpression,
|
||||
stale,
|
||||
updatedDecoratorExpression,
|
||||
updated,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compare text of the member nodes to determine if any changes have occurred
|
||||
if (!equalRangeText(updatedNode.members, updated, staleNode.members, stale)) {
|
||||
// A change to a member outside a component's metadata is unsupported
|
||||
return null;
|
||||
}
|
||||
|
||||
// If all previous class checks passed, this class is supported for HMR updates
|
||||
candidates.push(updatedNode);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Compare text of the statement nodes to determine if any changes have occurred
|
||||
// TODO: Consider expanding this to check semantic updates for each node kind
|
||||
if (!equalRangeText(updatedNode, updated, staleNode, stale)) {
|
||||
// A change to a statement outside a component's metadata is unsupported
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of Angular component metadata fields that are supported by HMR updates.
|
||||
*/
|
||||
const SUPPORTED_FIELDS = new Set(['template', 'templateUrl', 'styles', 'styleUrl', 'stylesUrl']);
|
||||
|
||||
/**
|
||||
* Analyzes the metadata fields of a decorator call expression for unsupported HMR updates.
|
||||
* Only updates to supported fields can be present for HMR to be viable.
|
||||
* @param staleCall A call expression instance.
|
||||
* @param staleSource The source file instance containing the stale call instance.
|
||||
* @param updatedCall A call expression instance.
|
||||
* @param updatedSource The source file instance containing the updated call instance.
|
||||
* @returns true, if unsupported metadata updates are present; false, otherwise.
|
||||
*/
|
||||
function hasUnsupportedMetaUpdates(
|
||||
staleCall: ts.CallExpression,
|
||||
staleSource: ts.SourceFile,
|
||||
updatedCall: ts.CallExpression,
|
||||
updatedSource: ts.SourceFile,
|
||||
): boolean {
|
||||
const staleObject = staleCall.arguments[0];
|
||||
const updatedObject = updatedCall.arguments[0];
|
||||
|
||||
if (!ts.isObjectLiteralExpression(staleObject) || !ts.isObjectLiteralExpression(updatedObject)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const unsupportedFields: ts.Node[] = [];
|
||||
|
||||
for (const property of staleObject.properties) {
|
||||
if (!ts.isPropertyAssignment(property) || ts.isComputedPropertyName(property.name)) {
|
||||
// Unsupported object literal property
|
||||
return true;
|
||||
}
|
||||
|
||||
const name = property.name.text;
|
||||
if (SUPPORTED_FIELDS.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unsupportedFields.push(property.initializer);
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
for (const property of updatedObject.properties) {
|
||||
if (!ts.isPropertyAssignment(property) || ts.isComputedPropertyName(property.name)) {
|
||||
// Unsupported object literal property
|
||||
return true;
|
||||
}
|
||||
|
||||
const name = property.name.text;
|
||||
if (SUPPORTED_FIELDS.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compare in order
|
||||
if (!equalRangeText(property.initializer, updatedSource, unsupportedFields[i++], staleSource)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return i !== unsupportedFields.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the text from a provided range in a source file to the text of a range in a second source file.
|
||||
* The comparison avoids making any intermediate string copies.
|
||||
* @param firstRange A text range within the first source file.
|
||||
* @param firstSource A source file instance.
|
||||
* @param secondRange A text range within the second source file.
|
||||
* @param secondSource A source file instance.
|
||||
* @returns true, if the text from both ranges is equal; false, otherwise.
|
||||
*/
|
||||
function equalRangeText(
|
||||
firstRange: ts.ReadonlyTextRange | undefined,
|
||||
firstSource: ts.SourceFile,
|
||||
secondRange: ts.ReadonlyTextRange | undefined,
|
||||
secondSource: ts.SourceFile,
|
||||
): boolean {
|
||||
// Check matching undefined values
|
||||
if (!firstRange || !secondRange) {
|
||||
return firstRange === secondRange;
|
||||
}
|
||||
|
||||
// Ensure lengths are equal
|
||||
const firstLength = firstRange.end - firstRange.pos;
|
||||
const secondLength = secondRange.end - secondRange.pos;
|
||||
if (firstLength !== secondLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each character
|
||||
for (let i = 0; i < firstLength; ++i) {
|
||||
const firstChar = firstSource.text.charCodeAt(i + firstRange.pos);
|
||||
const secondChar = secondSource.text.charCodeAt(i + secondRange.pos);
|
||||
if (firstChar !== secondChar) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user