diff --git a/apps/analog-app/vite.config.ts b/apps/analog-app/vite.config.ts index df67017c8..920c32039 100644 --- a/apps/analog-app/vite.config.ts +++ b/apps/analog-app/vite.config.ts @@ -43,6 +43,7 @@ export default defineConfig(({ mode }) => { supportAnalogFormat: true, }, }, + liveReload: true, }), nxViteTsPaths(), visualizer() as Plugin, diff --git a/apps/ng-app/vite.config.ts b/apps/ng-app/vite.config.ts index 1bfd46716..4c6292004 100644 --- a/apps/ng-app/vite.config.ts +++ b/apps/ng-app/vite.config.ts @@ -20,6 +20,7 @@ export default defineConfig(({ mode }) => ({ analog({ ssr: false, static: true, + liveReload: true, vite: { experimental: { supportAnalogFormat: true, diff --git a/packages/platform/src/lib/options.ts b/packages/platform/src/lib/options.ts index 129909309..c1ce56665 100644 --- a/packages/platform/src/lib/options.ts +++ b/packages/platform/src/lib/options.ts @@ -37,6 +37,12 @@ export interface Options { index?: string; workspaceRoot?: string; content?: ContentPluginOptions; + + /** + * Enables Angular's HMR during development + */ + liveReload?: boolean; + /** * Additional page paths to include */ diff --git a/packages/platform/src/lib/platform-plugin.ts b/packages/platform/src/lib/platform-plugin.ts index 5417f3cd6..e5130c954 100644 --- a/packages/platform/src/lib/platform-plugin.ts +++ b/packages/platform/src/lib/platform-plugin.ts @@ -36,6 +36,7 @@ export function platformPlugin(opts: Options = {}): Plugin[] { ), ], additionalContentDirs: platformOptions.additionalContentDirs, + liveReload: platformOptions.liveReload, ...(opts?.vite ?? {}), }), serverModePlugin(), diff --git a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts index bba61b12c..de4e1c6bf 100644 --- a/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts +++ b/packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts @@ -1,9 +1,10 @@ import { CompilerHost, NgtscProgram } from '@angular/compiler-cli'; -import { dirname, resolve } from 'node:path'; +import { dirname, relative, resolve } from 'node:path'; import * as compilerCli from '@angular/compiler-cli'; import * as ts from 'typescript'; import { createRequire } from 'node:module'; +import { ServerResponse } from 'node:http'; import { ModuleNode, normalizePath, @@ -11,6 +12,7 @@ import { ViteDevServer, preprocessCSS, ResolvedConfig, + Connect, } from 'vite'; import { createCompilerPlugin } from './compiler-plugin.js'; @@ -30,6 +32,7 @@ import { buildOptimizerPlugin } from './angular-build-optimizer-plugin.js'; import { createJitResourceTransformer, SourceFileCache, + angularMajor, } from './utils/devkit.js'; import { angularVitestPlugins } from './angular-vitest-plugin.js'; import { angularStorybookPlugin } from './angular-storybook-plugin.js'; @@ -43,6 +46,7 @@ import { } from './authoring/markdown-transform.js'; import { routerPlugin } from './router-plugin.js'; import { pendingTasksPlugin } from './angular-pending-tasks.plugin.js'; +import { analyzeFileUpdates } from './utils/hmr-candidates.js'; export interface PluginOptions { tsconfig?: string; @@ -73,6 +77,7 @@ export interface PluginOptions { */ include?: string[]; additionalContentDirs?: string[]; + liveReload?: boolean; } interface EmitFileResult { @@ -80,10 +85,15 @@ interface EmitFileResult { map?: string; dependencies: readonly string[]; hash?: Uint8Array; - errors: (string | ts.DiagnosticMessageChain)[]; - warnings: (string | ts.DiagnosticMessageChain)[]; + errors?: (string | ts.DiagnosticMessageChain)[]; + warnings?: (string | ts.DiagnosticMessageChain)[]; + hmrUpdateCode?: string | null; + hmrEligible?: boolean; } -type FileEmitter = (file: string) => Promise; +type FileEmitter = ( + file: string, + source?: ts.SourceFile +) => Promise; /** * TypeScript file extension regex @@ -91,6 +101,8 @@ type FileEmitter = (file: string) => Promise; * Ignore .tsx extensions */ const TS_EXT_REGEX = /\.[cm]?(ts|analog|ag)[^x]?\??/; +const ANGULAR_COMPONENT_PREFIX = '/@ng/component'; +const classNames = new Map(); export function angular(options?: PluginOptions): Plugin[] { /** @@ -122,6 +134,7 @@ export function angular(options?: PluginOptions): Plugin[] { : defaultMarkdownTemplateTransforms, include: options?.include ?? [], additionalContentDirs: options?.additionalContentDirs ?? [], + liveReload: options?.liveReload ?? false, }; // The file emitter created during `onStart` that will be used during the build in `onLoad` callbacks for TS files @@ -160,6 +173,10 @@ export function angular(options?: PluginOptions): Plugin[] { function angularPlugin(): Plugin { let isProd = false; + if (angularMajor < 19 || isTest) { + pluginOptions.liveReload = false; + } + return { name: '@analogjs/vite-plugin-angular', async watchChange() { @@ -232,6 +249,43 @@ export function angular(options?: PluginOptions): Plugin[] { setupCompilation(resolvedConfig); await buildAndAnalyze(); }); + + if (pluginOptions.liveReload) { + const angularComponentMiddleware: Connect.HandleFunction = async ( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction + ) => { + if (req.url === undefined || res.writableEnded) { + return; + } + + if (!req.url.startsWith(ANGULAR_COMPONENT_PREFIX)) { + next(); + + return; + } + + const requestUrl = new URL(req.url, 'http://localhost'); + const componentId = requestUrl.searchParams.get('c'); + + if (!componentId) { + res.statusCode = 400; + res.end(); + + return; + } + + const [fileId] = decodeURIComponent(componentId).split('@'); + const result = await fileEmitter?.(resolve(process.cwd(), fileId)); + + res.setHeader('Content-Type', 'text/javascript'); + res.setHeader('Cache-Control', 'no-cache'); + res.end(`${result?.hmrUpdateCode || ''}`); + }; + + viteServer.middlewares.use(angularComponentMiddleware); + } }, async buildStart() { setupCompilation(resolvedConfig); @@ -253,8 +307,44 @@ export function angular(options?: PluginOptions): Plugin[] { } if (TS_EXT_REGEX.test(ctx.file)) { - sourceFileCache.invalidate([ctx.file.replace(/\?(.*)/, '')]); + let [fileId] = ctx.file.split('?'); + + if ( + pluginOptions.supportAnalogFormat && + ['ag', 'analog', 'agx'].some((ext) => fileId.endsWith(ext)) + ) { + fileId += '.ts'; + } + + const stale = sourceFileCache.get(fileId); + sourceFileCache.invalidate([fileId]); await buildAndAnalyze(); + + const result = await fileEmitter?.(fileId, stale); + + if ( + pluginOptions.liveReload && + !!result?.hmrEligible && + classNames.get(fileId) + ) { + const relativeFileId = `${relative( + process.cwd(), + fileId + )}@${classNames.get(fileId)}`; + + sendHMRComponentUpdate(ctx.server, relativeFileId); + + return ctx.modules.map((mod) => { + if (mod.id === ctx.file) { + return { + ...mod, + isSelfAccepting: true, + } as ModuleNode; + } + + return mod; + }); + } } if (/\.(html|htm|css|less|sass|scss)$/.test(ctx.file)) { @@ -265,21 +355,49 @@ export function angular(options?: PluginOptions): Plugin[] { const isDirect = ctx.modules.find( (mod) => ctx.file === mod.file && mod.id?.includes('?direct') ); - if (isDirect) { return ctx.modules; } const mods: ModuleNode[] = []; + const updates: string[] = []; ctx.modules.forEach((mod) => { mod.importers.forEach((imp) => { - sourceFileCache.invalidate([imp.id as string]); + sourceFileCache.invalidate([imp.id]); ctx.server.moduleGraph.invalidateModule(imp); - mods.push(imp); + + if (pluginOptions.liveReload && classNames.get(imp.id)) { + updates.push(imp.id as string); + } else { + mods.push(imp); + } }); }); await buildAndAnalyze(); + + if (updates.length > 0) { + updates.forEach((updateId) => { + const impRelativeFileId = `${relative( + process.cwd(), + updateId + )}@${classNames.get(updateId)}`; + + sendHMRComponentUpdate(ctx.server, impRelativeFileId); + }); + + return ctx.modules.map((mod) => { + if (mod.id === ctx.file) { + return { + ...mod, + isSelfAccepting: true, + } as ModuleNode; + } + + return mod; + }); + } + return mods; } @@ -295,6 +413,31 @@ export function angular(options?: PluginOptions): Plugin[] { return undefined; }, + async load(id, options) { + if ( + pluginOptions.liveReload && + options?.ssr && + id.startsWith(ANGULAR_COMPONENT_PREFIX) + ) { + const requestUrl = new URL(id.slice(1), 'http://localhost'); + const componentId = requestUrl.searchParams.get('c'); + + if (!componentId) { + return; + } + + const result = await fileEmitter?.( + resolve( + process.cwd(), + decodeURIComponent(componentId).split('@')[0] + ) + ); + + return result?.hmrUpdateCode || ''; + } + + return; + }, async transform(code, id) { // Skip transforming node_modules if (id.includes('node_modules')) { @@ -543,6 +686,13 @@ export function angular(options?: PluginOptions): Plugin[] { tsCompilerOptions.compilationMode = 'experimental-local'; } + if (pluginOptions.liveReload) { + tsCompilerOptions['_enableHmr'] = true; + // Workaround for https://github.com/angular/angular/issues/59310 + // Force extra instructions to be generated for HMR w/defer + tsCompilerOptions['supportTestBed'] = true; + } + rootNames = rn.concat(analogFiles, includeFiles); compilerOptions = tsCompilerOptions; host = ts.createIncrementalCompilerHost(compilerOptions); @@ -636,23 +786,43 @@ export function angular(options?: PluginOptions): Plugin[] { jit ? {} : angularCompiler!.prepareEmit().transformers ), () => [], - angularCompiler! + angularCompiler!, + pluginOptions.liveReload ); } } +function sendHMRComponentUpdate(server: ViteDevServer, id: string) { + server.ws.send('angular:component-update', { + id: encodeURIComponent(id), + timestamp: Date.now(), + }); + + classNames.delete(id); +} + export function createFileEmitter( program: ts.BuilderProgram, transformers: ts.CustomTransformers = {}, onAfterEmit?: (sourceFile: ts.SourceFile) => void, - angularCompiler?: NgtscProgram['compiler'] + angularCompiler?: NgtscProgram['compiler'], + liveReload?: boolean ): FileEmitter { - return async (file: string) => { + return async (file: string, stale?: ts.SourceFile) => { const sourceFile = program.getSourceFile(file); if (!sourceFile) { return undefined; } + if (stale) { + const hmrEligible = !!analyzeFileUpdates( + stale, + sourceFile, + angularCompiler! + ); + return { dependencies: [], hmrEligible }; + } + const diagnostics = angularCompiler ? angularCompiler.getDiagnosticsForFile(sourceFile, 1) : []; @@ -665,6 +835,17 @@ export function createFileEmitter( .filter((d) => d.category === ts.DiagnosticCategory?.Warning) .map((d) => d.messageText); + let hmrUpdateCode: string | null | undefined = undefined; + + if (liveReload) { + for (const node of sourceFile.statements) { + if (ts.isClassDeclaration(node) && node.name != null) { + hmrUpdateCode = angularCompiler?.emitHmrUpdateModule(node); + classNames.set(file, node.name.getText()); + } + } + } + let content: string | undefined; program.emit( sourceFile, @@ -680,6 +861,6 @@ export function createFileEmitter( onAfterEmit?.(sourceFile); - return { content, dependencies: [], errors, warnings }; + return { content, dependencies: [], errors, warnings, hmrUpdateCode }; }; } diff --git a/packages/vite-plugin-angular/src/lib/utils/hmr-candidates.ts b/packages/vite-plugin-angular/src/lib/utils/hmr-candidates.ts new file mode 100644 index 000000000..d4637d8ac --- /dev/null +++ b/packages/vite-plugin-angular/src/lib/utils/hmr-candidates.ts @@ -0,0 +1,367 @@ +/** + * @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, + { compiler }: ng.NgtscProgram, + staleSourceFiles: Map | undefined +): Set { + 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 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. + */ +export 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; +}