11import { CompilerHost , NgtscProgram } from '@angular/compiler-cli' ;
2- import { dirname , resolve } from 'node:path' ;
2+ import { dirname , relative , resolve } from 'node:path' ;
33
44import * as compilerCli from '@angular/compiler-cli' ;
55import * as ts from 'typescript' ;
66import { createRequire } from 'node:module' ;
7+ import { ServerResponse } from 'node:http' ;
78import {
89 ModuleNode ,
910 normalizePath ,
1011 Plugin ,
1112 ViteDevServer ,
1213 preprocessCSS ,
1314 ResolvedConfig ,
15+ Connect ,
1416} from 'vite' ;
1517
1618import { createCompilerPlugin } from './compiler-plugin.js' ;
@@ -30,6 +32,7 @@ import { buildOptimizerPlugin } from './angular-build-optimizer-plugin.js';
3032import {
3133 createJitResourceTransformer ,
3234 SourceFileCache ,
35+ angularMajor ,
3336} from './utils/devkit.js' ;
3437import { angularVitestPlugins } from './angular-vitest-plugin.js' ;
3538import { angularStorybookPlugin } from './angular-storybook-plugin.js' ;
@@ -43,6 +46,7 @@ import {
4346} from './authoring/markdown-transform.js' ;
4447import { routerPlugin } from './router-plugin.js' ;
4548import { pendingTasksPlugin } from './angular-pending-tasks.plugin.js' ;
49+ import { analyzeFileUpdates } from './utils/hmr-candidates.js' ;
4650
4751export interface PluginOptions {
4852 tsconfig ?: string ;
@@ -73,24 +77,32 @@ export interface PluginOptions {
7377 */
7478 include ?: string [ ] ;
7579 additionalContentDirs ?: string [ ] ;
80+ liveReload ?: boolean ;
7681}
7782
7883interface EmitFileResult {
7984 content ?: string ;
8085 map ?: string ;
8186 dependencies : readonly string [ ] ;
8287 hash ?: Uint8Array ;
83- errors : ( string | ts . DiagnosticMessageChain ) [ ] ;
84- warnings : ( string | ts . DiagnosticMessageChain ) [ ] ;
88+ errors ?: ( string | ts . DiagnosticMessageChain ) [ ] ;
89+ warnings ?: ( string | ts . DiagnosticMessageChain ) [ ] ;
90+ hmrUpdateCode ?: string | null ;
91+ hmrEligible ?: boolean ;
8592}
86- type FileEmitter = ( file : string ) => Promise < EmitFileResult | undefined > ;
93+ type FileEmitter = (
94+ file : string ,
95+ source ?: ts . SourceFile
96+ ) => Promise < EmitFileResult | undefined > ;
8797
8898/**
8999 * TypeScript file extension regex
90100 * Match .(c or m)ts, .ts extensions with an optional ? for query params
91101 * Ignore .tsx extensions
92102 */
93103const TS_EXT_REGEX = / \. [ c m ] ? ( t s | a n a l o g | a g ) [ ^ x ] ? \? ? / ;
104+ const ANGULAR_COMPONENT_PREFIX = '/@ng/component' ;
105+ const classNames = new Map ( ) ;
94106
95107export function angular ( options ?: PluginOptions ) : Plugin [ ] {
96108 /**
@@ -122,6 +134,7 @@ export function angular(options?: PluginOptions): Plugin[] {
122134 : defaultMarkdownTemplateTransforms ,
123135 include : options ?. include ?? [ ] ,
124136 additionalContentDirs : options ?. additionalContentDirs ?? [ ] ,
137+ liveReload : options ?. liveReload ?? false ,
125138 } ;
126139
127140 // 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[] {
160173 function angularPlugin ( ) : Plugin {
161174 let isProd = false ;
162175
176+ if ( angularMajor < 19 || isTest ) {
177+ pluginOptions . liveReload = false ;
178+ }
179+
163180 return {
164181 name : '@analogjs/vite-plugin-angular' ,
165182 async watchChange ( ) {
@@ -232,6 +249,43 @@ export function angular(options?: PluginOptions): Plugin[] {
232249 setupCompilation ( resolvedConfig ) ;
233250 await buildAndAnalyze ( ) ;
234251 } ) ;
252+
253+ if ( pluginOptions . liveReload ) {
254+ const angularComponentMiddleware : Connect . HandleFunction = async (
255+ req : Connect . IncomingMessage ,
256+ res : ServerResponse < Connect . IncomingMessage > ,
257+ next : Connect . NextFunction
258+ ) => {
259+ if ( req . url === undefined || res . writableEnded ) {
260+ return ;
261+ }
262+
263+ if ( ! req . url . startsWith ( ANGULAR_COMPONENT_PREFIX ) ) {
264+ next ( ) ;
265+
266+ return ;
267+ }
268+
269+ const requestUrl = new URL ( req . url , 'http://localhost' ) ;
270+ const componentId = requestUrl . searchParams . get ( 'c' ) ;
271+
272+ if ( ! componentId ) {
273+ res . statusCode = 400 ;
274+ res . end ( ) ;
275+
276+ return ;
277+ }
278+
279+ const [ fileId ] = decodeURIComponent ( componentId ) . split ( '@' ) ;
280+ const result = await fileEmitter ?.( resolve ( process . cwd ( ) , fileId ) ) ;
281+
282+ res . setHeader ( 'Content-Type' , 'text/javascript' ) ;
283+ res . setHeader ( 'Cache-Control' , 'no-cache' ) ;
284+ res . end ( `${ result ?. hmrUpdateCode || '' } ` ) ;
285+ } ;
286+
287+ viteServer . middlewares . use ( angularComponentMiddleware ) ;
288+ }
235289 } ,
236290 async buildStart ( ) {
237291 setupCompilation ( resolvedConfig ) ;
@@ -253,8 +307,44 @@ export function angular(options?: PluginOptions): Plugin[] {
253307 }
254308
255309 if ( TS_EXT_REGEX . test ( ctx . file ) ) {
256- sourceFileCache . invalidate ( [ ctx . file . replace ( / \? ( .* ) / , '' ) ] ) ;
310+ let [ fileId ] = ctx . file . split ( '?' ) ;
311+
312+ if (
313+ pluginOptions . supportAnalogFormat &&
314+ [ 'ag' , 'analog' , 'agx' ] . some ( ( ext ) => fileId . endsWith ( ext ) )
315+ ) {
316+ fileId += '.ts' ;
317+ }
318+
319+ const stale = sourceFileCache . get ( fileId ) ;
320+ sourceFileCache . invalidate ( [ fileId ] ) ;
257321 await buildAndAnalyze ( ) ;
322+
323+ const result = await fileEmitter ?.( fileId , stale ) ;
324+
325+ if (
326+ pluginOptions . liveReload &&
327+ ! ! result ?. hmrEligible &&
328+ classNames . get ( fileId )
329+ ) {
330+ const relativeFileId = `${ relative (
331+ process . cwd ( ) ,
332+ fileId
333+ ) } @${ classNames . get ( fileId ) } `;
334+
335+ sendHMRComponentUpdate ( ctx . server , relativeFileId ) ;
336+
337+ return ctx . modules . map ( ( mod ) => {
338+ if ( mod . id === ctx . file ) {
339+ return {
340+ ...mod ,
341+ isSelfAccepting : true ,
342+ } as ModuleNode ;
343+ }
344+
345+ return mod ;
346+ } ) ;
347+ }
258348 }
259349
260350 if ( / \. ( h t m l | h t m | c s s | l e s s | s a s s | s c s s ) $ / . test ( ctx . file ) ) {
@@ -265,21 +355,49 @@ export function angular(options?: PluginOptions): Plugin[] {
265355 const isDirect = ctx . modules . find (
266356 ( mod ) => ctx . file === mod . file && mod . id ?. includes ( '?direct' )
267357 ) ;
268-
269358 if ( isDirect ) {
270359 return ctx . modules ;
271360 }
272361
273362 const mods : ModuleNode [ ] = [ ] ;
363+ const updates : string [ ] = [ ] ;
274364 ctx . modules . forEach ( ( mod ) => {
275365 mod . importers . forEach ( ( imp ) => {
276- sourceFileCache . invalidate ( [ imp . id as string ] ) ;
366+ sourceFileCache . invalidate ( [ imp . id ] ) ;
277367 ctx . server . moduleGraph . invalidateModule ( imp ) ;
278- mods . push ( imp ) ;
368+
369+ if ( pluginOptions . liveReload && classNames . get ( imp . id ) ) {
370+ updates . push ( imp . id as string ) ;
371+ } else {
372+ mods . push ( imp ) ;
373+ }
279374 } ) ;
280375 } ) ;
281376
282377 await buildAndAnalyze ( ) ;
378+
379+ if ( updates . length > 0 ) {
380+ updates . forEach ( ( updateId ) => {
381+ const impRelativeFileId = `${ relative (
382+ process . cwd ( ) ,
383+ updateId
384+ ) } @${ classNames . get ( updateId ) } `;
385+
386+ sendHMRComponentUpdate ( ctx . server , impRelativeFileId ) ;
387+ } ) ;
388+
389+ return ctx . modules . map ( ( mod ) => {
390+ if ( mod . id === ctx . file ) {
391+ return {
392+ ...mod ,
393+ isSelfAccepting : true ,
394+ } as ModuleNode ;
395+ }
396+
397+ return mod ;
398+ } ) ;
399+ }
400+
283401 return mods ;
284402 }
285403
@@ -295,6 +413,31 @@ export function angular(options?: PluginOptions): Plugin[] {
295413
296414 return undefined ;
297415 } ,
416+ async load ( id , options ) {
417+ if (
418+ pluginOptions . liveReload &&
419+ options ?. ssr &&
420+ id . startsWith ( ANGULAR_COMPONENT_PREFIX )
421+ ) {
422+ const requestUrl = new URL ( id . slice ( 1 ) , 'http://localhost' ) ;
423+ const componentId = requestUrl . searchParams . get ( 'c' ) ;
424+
425+ if ( ! componentId ) {
426+ return ;
427+ }
428+
429+ const result = await fileEmitter ?.(
430+ resolve (
431+ process . cwd ( ) ,
432+ decodeURIComponent ( componentId ) . split ( '@' ) [ 0 ]
433+ )
434+ ) ;
435+
436+ return result ?. hmrUpdateCode || '' ;
437+ }
438+
439+ return ;
440+ } ,
298441 async transform ( code , id ) {
299442 // Skip transforming node_modules
300443 if ( id . includes ( 'node_modules' ) ) {
@@ -543,6 +686,13 @@ export function angular(options?: PluginOptions): Plugin[] {
543686 tsCompilerOptions . compilationMode = 'experimental-local' ;
544687 }
545688
689+ if ( pluginOptions . liveReload ) {
690+ tsCompilerOptions [ '_enableHmr' ] = true ;
691+ // Workaround for https://github.com/angular/angular/issues/59310
692+ // Force extra instructions to be generated for HMR w/defer
693+ tsCompilerOptions [ 'supportTestBed' ] = true ;
694+ }
695+
546696 rootNames = rn . concat ( analogFiles , includeFiles ) ;
547697 compilerOptions = tsCompilerOptions ;
548698 host = ts . createIncrementalCompilerHost ( compilerOptions ) ;
@@ -636,23 +786,43 @@ export function angular(options?: PluginOptions): Plugin[] {
636786 jit ? { } : angularCompiler ! . prepareEmit ( ) . transformers
637787 ) ,
638788 ( ) => [ ] ,
639- angularCompiler !
789+ angularCompiler ! ,
790+ pluginOptions . liveReload
640791 ) ;
641792 }
642793}
643794
795+ function sendHMRComponentUpdate ( server : ViteDevServer , id : string ) {
796+ server . ws . send ( 'angular:component-update' , {
797+ id : encodeURIComponent ( id ) ,
798+ timestamp : Date . now ( ) ,
799+ } ) ;
800+
801+ classNames . delete ( id ) ;
802+ }
803+
644804export function createFileEmitter (
645805 program : ts . BuilderProgram ,
646806 transformers : ts . CustomTransformers = { } ,
647807 onAfterEmit ?: ( sourceFile : ts . SourceFile ) => void ,
648- angularCompiler ?: NgtscProgram [ 'compiler' ]
808+ angularCompiler ?: NgtscProgram [ 'compiler' ] ,
809+ liveReload ?: boolean
649810) : FileEmitter {
650- return async ( file : string ) => {
811+ return async ( file : string , stale ?: ts . SourceFile ) => {
651812 const sourceFile = program . getSourceFile ( file ) ;
652813 if ( ! sourceFile ) {
653814 return undefined ;
654815 }
655816
817+ if ( stale ) {
818+ const hmrEligible = ! ! analyzeFileUpdates (
819+ stale ,
820+ sourceFile ,
821+ angularCompiler !
822+ ) ;
823+ return { dependencies : [ ] , hmrEligible } ;
824+ }
825+
656826 const diagnostics = angularCompiler
657827 ? angularCompiler . getDiagnosticsForFile ( sourceFile , 1 )
658828 : [ ] ;
@@ -665,6 +835,17 @@ export function createFileEmitter(
665835 . filter ( ( d ) => d . category === ts . DiagnosticCategory ?. Warning )
666836 . map ( ( d ) => d . messageText ) ;
667837
838+ let hmrUpdateCode : string | null | undefined = undefined ;
839+
840+ if ( liveReload ) {
841+ for ( const node of sourceFile . statements ) {
842+ if ( ts . isClassDeclaration ( node ) && node . name != null ) {
843+ hmrUpdateCode = angularCompiler ?. emitHmrUpdateModule ( node ) ;
844+ classNames . set ( file , node . name . getText ( ) ) ;
845+ }
846+ }
847+ }
848+
668849 let content : string | undefined ;
669850 program . emit (
670851 sourceFile ,
@@ -680,6 +861,6 @@ export function createFileEmitter(
680861
681862 onAfterEmit ?.( sourceFile ) ;
682863
683- return { content, dependencies : [ ] , errors, warnings } ;
864+ return { content, dependencies : [ ] , errors, warnings, hmrUpdateCode } ;
684865 } ;
685866}
0 commit comments