Skip to content

Commit 0602a8f

Browse files
feat(vite-plugin-angular): introduce support for Angular v19 HMR/live reload (#1523)
1 parent 9540662 commit 0602a8f

File tree

6 files changed

+569
-12
lines changed

6 files changed

+569
-12
lines changed

apps/analog-app/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default defineConfig(({ mode }) => {
4343
supportAnalogFormat: true,
4444
},
4545
},
46+
liveReload: true,
4647
}),
4748
nxViteTsPaths(),
4849
visualizer() as Plugin,

apps/ng-app/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default defineConfig(({ mode }) => ({
2020
analog({
2121
ssr: false,
2222
static: true,
23+
liveReload: true,
2324
vite: {
2425
experimental: {
2526
supportAnalogFormat: true,

packages/platform/src/lib/options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export interface Options {
3737
index?: string;
3838
workspaceRoot?: string;
3939
content?: ContentPluginOptions;
40+
41+
/**
42+
* Enables Angular's HMR during development
43+
*/
44+
liveReload?: boolean;
45+
4046
/**
4147
* Additional page paths to include
4248
*/

packages/platform/src/lib/platform-plugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function platformPlugin(opts: Options = {}): Plugin[] {
3636
),
3737
],
3838
additionalContentDirs: platformOptions.additionalContentDirs,
39+
liveReload: platformOptions.liveReload,
3940
...(opts?.vite ?? {}),
4041
}),
4142
serverModePlugin(),

packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts

Lines changed: 193 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { CompilerHost, NgtscProgram } from '@angular/compiler-cli';
2-
import { dirname, resolve } from 'node:path';
2+
import { dirname, relative, resolve } from 'node:path';
33

44
import * as compilerCli from '@angular/compiler-cli';
55
import * as ts from 'typescript';
66
import { createRequire } from 'node:module';
7+
import { ServerResponse } from 'node:http';
78
import {
89
ModuleNode,
910
normalizePath,
1011
Plugin,
1112
ViteDevServer,
1213
preprocessCSS,
1314
ResolvedConfig,
15+
Connect,
1416
} from 'vite';
1517

1618
import { createCompilerPlugin } from './compiler-plugin.js';
@@ -30,6 +32,7 @@ import { buildOptimizerPlugin } from './angular-build-optimizer-plugin.js';
3032
import {
3133
createJitResourceTransformer,
3234
SourceFileCache,
35+
angularMajor,
3336
} from './utils/devkit.js';
3437
import { angularVitestPlugins } from './angular-vitest-plugin.js';
3538
import { angularStorybookPlugin } from './angular-storybook-plugin.js';
@@ -43,6 +46,7 @@ import {
4346
} from './authoring/markdown-transform.js';
4447
import { routerPlugin } from './router-plugin.js';
4548
import { pendingTasksPlugin } from './angular-pending-tasks.plugin.js';
49+
import { analyzeFileUpdates } from './utils/hmr-candidates.js';
4650

4751
export interface PluginOptions {
4852
tsconfig?: string;
@@ -73,24 +77,32 @@ export interface PluginOptions {
7377
*/
7478
include?: string[];
7579
additionalContentDirs?: string[];
80+
liveReload?: boolean;
7681
}
7782

7883
interface 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
*/
93103
const TS_EXT_REGEX = /\.[cm]?(ts|analog|ag)[^x]?\??/;
104+
const ANGULAR_COMPONENT_PREFIX = '/@ng/component';
105+
const classNames = new Map();
94106

95107
export 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 (/\.(html|htm|css|less|sass|scss)$/.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+
644804
export 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

Comments
 (0)