@angular/build
Version:
Official build system for Angular
270 lines (269 loc) • 13.6 kB
JavaScript
"use strict";
/**
* @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
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.collectHmrCandidates = collectHmrCandidates;
const node_assert_1 = __importDefault(require("node:assert"));
const typescript_1 = __importDefault(require("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.
*/
function collectHmrCandidates(modifiedFiles, { compiler }, staleSourceFiles) {
const candidates = new Set();
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));
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));
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, updated, compiler) {
if (stale.statements.length !== updated.statements.length) {
return null;
}
const candidates = [];
for (let i = 0; i < updated.statements.length; ++i) {
const updatedNode = updated.statements[i];
const staleNode = stale.statements[i];
if (typescript_1.default.isClassDeclaration(updatedNode)) {
if (!typescript_1.default.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 = typescript_1.default.getModifiers(updatedNode);
const staleModifiers = typescript_1.default.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.isComponent === true) {
const updatedDecorators = typescript_1.default.getDecorators(updatedNode);
const staleDecorators = typescript_1.default.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);
(0, node_assert_1.default)(metaDecoratorIndex !== undefined, 'Component metadata decorator should always be present on component class.');
const updatedDecoratorExpression = meta.decorator.expression;
(0, node_assert_1.default)(typescript_1.default.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 ||
!typescript_1.default.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
const analysis = analyzeMetaUpdates(staleDecoratorExpression, stale, updatedDecoratorExpression, updated);
if (analysis === MetaUpdateAnalysis.Unsupported) {
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
if (analysis === MetaUpdateAnalysis.Supported) {
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_FIELD_NAMES = new Set([
'template',
'templateUrl',
'styles',
'styleUrl',
'stylesUrl',
]);
var MetaUpdateAnalysis;
(function (MetaUpdateAnalysis) {
MetaUpdateAnalysis[MetaUpdateAnalysis["Supported"] = 0] = "Supported";
MetaUpdateAnalysis[MetaUpdateAnalysis["Unsupported"] = 1] = "Unsupported";
MetaUpdateAnalysis[MetaUpdateAnalysis["None"] = 2] = "None";
})(MetaUpdateAnalysis || (MetaUpdateAnalysis = {}));
/**
* 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 A MetaUpdateAnalysis enum value.
*/
function analyzeMetaUpdates(staleCall, staleSource, updatedCall, updatedSource) {
const staleObject = staleCall.arguments[0];
const updatedObject = updatedCall.arguments[0];
let hasSupportedUpdate = false;
if (!typescript_1.default.isObjectLiteralExpression(staleObject) || !typescript_1.default.isObjectLiteralExpression(updatedObject)) {
return MetaUpdateAnalysis.Unsupported;
}
const supportedFields = new Map();
const unsupportedFields = [];
for (const property of staleObject.properties) {
if (!typescript_1.default.isPropertyAssignment(property) || typescript_1.default.isComputedPropertyName(property.name)) {
// Unsupported object literal property
return MetaUpdateAnalysis.Unsupported;
}
const name = property.name.text;
if (SUPPORTED_FIELD_NAMES.has(name)) {
supportedFields.set(name, property.initializer);
continue;
}
unsupportedFields.push(property.initializer);
}
let i = 0;
for (const property of updatedObject.properties) {
if (!typescript_1.default.isPropertyAssignment(property) || typescript_1.default.isComputedPropertyName(property.name)) {
// Unsupported object literal property
return MetaUpdateAnalysis.Unsupported;
}
const name = property.name.text;
if (SUPPORTED_FIELD_NAMES.has(name)) {
const staleInitializer = supportedFields.get(name);
// If the supported field was added or has its content changed, there has been a supported update
if (!staleInitializer ||
!equalRangeText(property.initializer, updatedSource, staleInitializer, staleSource)) {
hasSupportedUpdate = true;
}
// Remove the field entry to allow tracking removed fields
supportedFields.delete(name);
continue;
}
// Compare in order
if (!equalRangeText(property.initializer, updatedSource, unsupportedFields[i++], staleSource)) {
return MetaUpdateAnalysis.Unsupported;
}
}
if (i !== unsupportedFields.length) {
return MetaUpdateAnalysis.Unsupported;
}
// Any remaining supported field indicates a field removal. This is also considered a supported update.
hasSupportedUpdate ||= supportedFields.size > 0;
return hasSupportedUpdate ? MetaUpdateAnalysis.Supported : MetaUpdateAnalysis.None;
}
/**
* 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, firstSource, secondRange, secondSource) {
// 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;
}