"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; }