Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/analog-app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default defineConfig(({ mode }) => {
supportAnalogFormat: true,
},
},
liveReload: true,
}),
nxViteTsPaths(),
visualizer() as Plugin,
Expand Down
1 change: 1 addition & 0 deletions apps/ng-app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default defineConfig(({ mode }) => ({
analog({
ssr: false,
static: true,
liveReload: true,
vite: {
experimental: {
supportAnalogFormat: true,
Expand Down
6 changes: 6 additions & 0 deletions packages/platform/src/lib/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions packages/platform/src/lib/platform-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function platformPlugin(opts: Options = {}): Plugin[] {
),
],
additionalContentDirs: platformOptions.additionalContentDirs,
liveReload: platformOptions.liveReload,
...(opts?.vite ?? {}),
}),
serverModePlugin(),
Expand Down
205 changes: 193 additions & 12 deletions packages/vite-plugin-angular/src/lib/angular-vite-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
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,
Plugin,
ViteDevServer,
preprocessCSS,
ResolvedConfig,
Connect,
} from 'vite';

import { createCompilerPlugin } from './compiler-plugin.js';
Expand All @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -73,24 +77,32 @@ export interface PluginOptions {
*/
include?: string[];
additionalContentDirs?: string[];
liveReload?: boolean;
}

interface EmitFileResult {
content?: string;
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<EmitFileResult | undefined>;
type FileEmitter = (
file: string,
source?: ts.SourceFile
) => Promise<EmitFileResult | undefined>;

/**
* TypeScript file extension regex
* Match .(c or m)ts, .ts extensions with an optional ? for query params
* 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[] {
/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<Connect.IncomingMessage>,
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);
Expand All @@ -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)) {
Expand All @@ -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;
}

Expand All @@ -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')) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
: [];
Expand All @@ -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,
Expand All @@ -680,6 +861,6 @@ export function createFileEmitter(

onAfterEmit?.(sourceFile);

return { content, dependencies: [], errors, warnings };
return { content, dependencies: [], errors, warnings, hmrUpdateCode };
};
}
Loading
Loading