diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index f63c921a3..65f9b6e66 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -123,6 +123,27 @@ generateHmrModule( ): string ``` +##### HMR + reload behavior matrix + +The Vite plugin's `handleHotUpdate` hook dispatches every file change +into one of these branches, mirroring Angular CLI's official behavior +(`@angular/build` esbuild dev server): + +| File | Change | Action | +| -------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------- | +| External `.html` (templateUrl) | any | `angular:component-update` HMR, no reload | +| External `.css/.scss/.sass/.less` (styleUrl) | any | `angular:component-update` HMR, no reload | +| Component `.ts` | inline template only | `angular:component-update` HMR, no reload | +| Component `.ts` | inline `styles: [...]` only | `angular:component-update` HMR, no reload | +| Component `.ts` | both inline template and styles | `angular:component-update` HMR, no reload | +| Component `.ts` | class body / imports / decorator metadata | full reload | +| Non-component `.ts` (utils, services, constants, lazy `*.routes.ts`) | any | full reload | +| Global stylesheet (no `styleUrl` owner) | any | Vite default style HMR | +| Anything in `node_modules/` or `*.spec.ts` | any | ignore | + +Set `liveReload: false` to disable both HMR and reloads — the plugin +returns from `handleHotUpdate` without sending any event. + ### Transform Options ```typescript diff --git a/napi/angular-compiler/e2e/app/src/app/app.component.ts b/napi/angular-compiler/e2e/app/src/app/app.component.ts index 491979aa8..17d0c89b4 100644 --- a/napi/angular-compiler/e2e/app/src/app/app.component.ts +++ b/napi/angular-compiler/e2e/app/src/app/app.component.ts @@ -1,13 +1,16 @@ import { Component, signal } from '@angular/core' import { Card } from './card.component' +import { InlineCard } from './inline-card.component' +import { UTIL_VALUE } from './util' @Component({ selector: 'app-root', templateUrl: './app.html', styleUrl: './app.css', - imports: [Card], + imports: [Card, InlineCard], }) export class App { protected readonly title = signal('E2E_TITLE') + protected readonly utilValue = UTIL_VALUE } diff --git a/napi/angular-compiler/e2e/app/src/app/app.html b/napi/angular-compiler/e2e/app/src/app/app.html index f01634f6c..3a05a5126 100644 --- a/napi/angular-compiler/e2e/app/src/app/app.html +++ b/napi/angular-compiler/e2e/app/src/app/app.html @@ -1,5 +1,7 @@ {{ title() }} E2E test fixture for HMR testing. + {{ utilValue }} + diff --git a/napi/angular-compiler/e2e/app/src/app/inline-card.component.ts b/napi/angular-compiler/e2e/app/src/app/inline-card.component.ts new file mode 100644 index 000000000..d5ad7d520 --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/inline-card.component.ts @@ -0,0 +1,28 @@ +import { Component } from '@angular/core' + +@Component({ + selector: 'app-inline-card', + template: ` + + INLINE_TITLE + {{ message }} + + `, + styles: [ + ` + :host { + display: block; + } + .inline-card { + padding: 0.5rem 1rem; + border: 1px dashed currentColor; + } + .inline-card-body { + color: var(--inline-card-color, #444); + } + `, + ], +}) +export class InlineCard { + protected readonly message = 'inline-template + inline-styles' +} diff --git a/napi/angular-compiler/e2e/app/src/app/util.ts b/napi/angular-compiler/e2e/app/src/app/util.ts new file mode 100644 index 000000000..7beb98270 --- /dev/null +++ b/napi/angular-compiler/e2e/app/src/app/util.ts @@ -0,0 +1,5 @@ +/** + * Plain non-component module. Imported by AppComponent so changes here + * exercise the plain-TS full-reload branch of `handleHotUpdate`. + */ +export const UTIL_VALUE = 'UTIL_INITIAL' diff --git a/napi/angular-compiler/e2e/fixtures/test-fixture.ts b/napi/angular-compiler/e2e/fixtures/test-fixture.ts index ec4065c34..79c0c880a 100644 --- a/napi/angular-compiler/e2e/fixtures/test-fixture.ts +++ b/napi/angular-compiler/e2e/fixtures/test-fixture.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from 'node:fs/promises' +import { readFile, writeFile, rename, unlink, open } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -7,6 +7,56 @@ import { test as base, expect, type Page } from '@playwright/test' const __dirname = fileURLToPath(new URL('.', import.meta.url)) const FIXTURE_APP = join(__dirname, '../app/src/app') +/** + * Write strategies that mimic how different tools / editors save files. + * Used to guard against watcher implementations that miss certain patterns + * (e.g., a per-file `node:fs.watch` on macOS dropping single fast in-place + * writes from AI-tool Edit operations). + */ +export type WriteStrategy = + | 'writeFile-in-place' // single fs.writeFile (Claude Code, most CLI tools) + | 'writeFile-with-fsync' // writeFile + explicit fsync + | 'atomic-rename' // write `.tmp` then rename (vim, IntelliJ "safe write") + | 'truncate-then-write' // writeFile('') + delay + writeFile(content) + +async function performWrite( + filepath: string, + content: string, + strategy: WriteStrategy, +): Promise { + switch (strategy) { + case 'writeFile-in-place': + await writeFile(filepath, content) + return + case 'writeFile-with-fsync': { + await writeFile(filepath, content) + const handle = await open(filepath, 'r+') + try { + await handle.sync() + } finally { + await handle.close() + } + return + } + case 'atomic-rename': { + const tmp = `${filepath}.hmr-${Date.now()}.tmp` + await writeFile(tmp, content) + try { + await rename(tmp, filepath) + } catch (err) { + await unlink(tmp).catch(() => {}) + throw err + } + return + } + case 'truncate-then-write': + await writeFile(filepath, '') + await new Promise((r) => setTimeout(r, 30)) + await writeFile(filepath, content) + return + } +} + /** * File modification utility for e2e tests. * Backs up files before modification and restores them after tests. @@ -17,8 +67,16 @@ export class FileModifier { /** * Modify a file in the fixture app directory. * Automatically backs up the original content for restoration. + * + * Optionally takes a write strategy to mimic how different tools save — + * use this to assert HMR works regardless of the consumer's editor / + * AI-tool save pattern. */ - async modifyFile(filename: string, modifier: (content: string) => string): Promise { + async modifyFile( + filename: string, + modifier: (content: string) => string, + strategy: WriteStrategy = 'writeFile-in-place', + ): Promise { const filepath = join(FIXTURE_APP, filename) const content = await readFile(filepath, 'utf-8') @@ -27,7 +85,7 @@ export class FileModifier { } const modified = modifier(content) - await writeFile(filepath, modified) + await performWrite(filepath, modified, strategy) } /** diff --git a/napi/angular-compiler/e2e/tests/hmr-html-write-strategies.spec.ts b/napi/angular-compiler/e2e/tests/hmr-html-write-strategies.spec.ts new file mode 100644 index 000000000..f9c298d76 --- /dev/null +++ b/napi/angular-compiler/e2e/tests/hmr-html-write-strategies.spec.ts @@ -0,0 +1,40 @@ +import { test, expect, type WriteStrategy } from '../fixtures/test-fixture.js' + +/** + * Regression coverage for write-strategy regressions on the chokidar-based + * watcher. Vite's chokidar (recursive fs.watch on the root) handles all of + * these reliably; this matrix is the empirical guard against future + * regressions on either the watcher backend or the handleHotUpdate dispatcher. + */ + +const STRATEGIES: WriteStrategy[] = ['writeFile-in-place', 'writeFile-with-fsync', 'atomic-rename'] + +test.describe('HTML template HMR — write-strategy matrix', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + for (const strategy of STRATEGIES) { + test(`triggers HMR via "${strategy}"`, async ({ + page, + fileModifier, + hmrDetector, + waitForHmr, + }) => { + const sentinelId = await hmrDetector.addSentinel() + await expect(page.locator('h1')).toContainText('E2E_TITLE') + + await fileModifier.modifyFile( + 'app.html', + (content) => content.replace('{{ title() }}', `STRATEGY_${strategy.toUpperCase()}`), + strategy, + ) + await waitForHmr() + + await expect(page.locator('h1')).toContainText(`STRATEGY_${strategy.toUpperCase()}`) + // Sentinel survives → HMR happened, no full reload. + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + }) + } +}) diff --git a/napi/angular-compiler/e2e/tests/hmr-inline-styles.spec.ts b/napi/angular-compiler/e2e/tests/hmr-inline-styles.spec.ts new file mode 100644 index 000000000..442258568 --- /dev/null +++ b/napi/angular-compiler/e2e/tests/hmr-inline-styles.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '../fixtures/test-fixture.js' + +/** + * Inline style change in a `.ts` file ( `styles: ['…']` ) should trigger + * an `angular:component-update` HMR event with no full reload — matches + * Angular CLI's official behavior (which sends inline-style updates with + * the slightly misleading `type: 'template'` discriminator). + */ +test.describe('Inline styles HMR', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('inline-style-only change in .ts triggers HMR (no reload)', async ({ + page, + fileModifier, + hmrDetector, + waitForHmr, + }) => { + const sentinelId = await hmrDetector.addSentinel() + + // Baseline color from the fixture (--inline-card-color default = #444). + const body = page.locator('app-inline-card .inline-card-body') + await expect(body).toBeVisible() + + await fileModifier.modifyFile('inline-card.component.ts', (content) => + // Replace the fallback color in the inline style. + content.replace('var(--inline-card-color, #444)', 'rgb(255, 0, 128)'), + ) + await waitForHmr() + + const color = await body.evaluate((el) => getComputedStyle(el).color) + expect(color).toBe('rgb(255, 0, 128)') + + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + }) +}) diff --git a/napi/angular-compiler/e2e/tests/hmr-inline-template.spec.ts b/napi/angular-compiler/e2e/tests/hmr-inline-template.spec.ts new file mode 100644 index 000000000..41c4aa121 --- /dev/null +++ b/napi/angular-compiler/e2e/tests/hmr-inline-template.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '../fixtures/test-fixture.js' + +/** + * Inline template change in a `.ts` file ( `template: \`…\`` ) should + * trigger an `angular:component-update` HMR event with no full reload — + * matches Angular CLI's official behavior. + */ +test.describe('Inline template HMR', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('inline-template-only change in .ts triggers HMR (no reload)', async ({ + page, + fileModifier, + hmrDetector, + waitForHmr, + }) => { + const sentinelId = await hmrDetector.addSentinel() + await expect(page.locator('app-inline-card h2')).toContainText('INLINE_TITLE') + + await fileModifier.modifyFile('inline-card.component.ts', (content) => + content.replace('INLINE_TITLE', 'INLINE_TEMPLATE_HMR'), + ) + await waitForHmr() + + await expect(page.locator('app-inline-card h2')).toContainText('INLINE_TEMPLATE_HMR') + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true) + }) +}) diff --git a/napi/angular-compiler/e2e/tests/hmr-plain-ts.spec.ts b/napi/angular-compiler/e2e/tests/hmr-plain-ts.spec.ts new file mode 100644 index 000000000..8bae3804c --- /dev/null +++ b/napi/angular-compiler/e2e/tests/hmr-plain-ts.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '../fixtures/test-fixture.js' + +/** + * Plain (non-component) `.ts` modules — utilities, services, constants, + * route configs — must trigger a full page reload when edited. Angular's + * runtime HMR only refreshes template/style metadata on already-mounted + * instances; module bindings captured by component constructors are not + * re-pulled, so Vite's default propagation accepts via the importing + * component's HMR boundary without re-rendering, leaving the DOM stale. + * + * Matches Angular CLI's official behavior, where any non-component .ts + * change drops out of the HMR-eligible path and reloads the page. + */ +test.describe('Plain TS full reload', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForLoadState('networkidle') + }) + + test('modifying a plain util .ts file triggers full page reload', async ({ + page, + fileModifier, + hmrDetector, + }) => { + await hmrDetector.setupEventListeners() + const sentinelId = await hmrDetector.addSentinel() + + // Baseline value comes from util.ts and is bound into AppComponent's template. + await expect(page.locator('[data-test-util]')).toContainText('UTIL_INITIAL') + + await fileModifier.modifyFile('util.ts', (content) => + content.replace('UTIL_INITIAL', 'UTIL_RELOADED'), + ) + + // A full reload destroys the sentinel. + await page.waitForEvent('load', { timeout: 15000 }) + await page.waitForLoadState('networkidle') + + expect(await hmrDetector.sentinelExists(sentinelId)).toBe(false) + await expect(page.locator('[data-test-util]')).toContainText('UTIL_RELOADED') + }) +}) diff --git a/napi/angular-compiler/e2e/tests/hmr-ts.spec.ts b/napi/angular-compiler/e2e/tests/hmr-ts.spec.ts index 2c843e360..9def92d01 100644 --- a/napi/angular-compiler/e2e/tests/hmr-ts.spec.ts +++ b/napi/angular-compiler/e2e/tests/hmr-ts.spec.ts @@ -44,9 +44,9 @@ test.describe('TypeScript Component Full Reload', () => { // Add a new signal property await fileModifier.modifyFile('app.component.ts', (content) => { return content.replace( - 'protected readonly title = signal("E2E_TITLE");', - `protected readonly title = signal("E2E_TITLE"); - protected readonly newProperty = signal("NEW_PROPERTY");`, + "protected readonly title = signal('E2E_TITLE')", + `protected readonly title = signal('E2E_TITLE') + protected readonly newProperty = signal('NEW_PROPERTY')`, ) }) @@ -67,7 +67,7 @@ test.describe('TypeScript Component Full Reload', () => { // Modify the decorator (change selector) await fileModifier.modifyFile('app.component.ts', (content) => { - return content.replace('selector: "app-root"', 'selector: "app-root-modified"') + return content.replace("selector: 'app-root'", "selector: 'app-root-modified'") }) // Wait for reload @@ -88,8 +88,8 @@ test.describe('TypeScript Component Full Reload', () => { // Add a new import await fileModifier.modifyFile('app.component.ts', (content) => { return content.replace( - 'import { Component, signal } from "@angular/core";', - 'import { Component, signal, computed } from "@angular/core";', + "import { Component, signal } from '@angular/core'", + "import { Component, signal, computed } from '@angular/core'", ) }) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index 9a34e5c99..893a8922e 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -9,7 +9,7 @@ * HMR updates for global stylesheets and prevented PostCSS/Tailwind from * processing changes. */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -23,6 +23,18 @@ let tempDir: string let appDir: string let templatePath: string let stylePath: string +let componentPath: string + +const COMPONENT_SOURCE = ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'], + }) + export class AppComponent {} +` beforeAll(() => { tempDir = mkdtempSync(join(tmpdir(), 'hmr-test-')) @@ -31,9 +43,11 @@ beforeAll(() => { templatePath = join(appDir, 'app.component.html') stylePath = join(appDir, 'app.component.css') + componentPath = join(appDir, 'app.component.ts') writeFileSync(templatePath, 'Hello') writeFileSync(stylePath, 'h1 { color: red; }') + writeFileSync(componentPath, COMPONENT_SOURCE) }) afterAll(() => { @@ -168,29 +182,129 @@ async function setupPluginWithServer(plugin: Plugin) { * populating resourceToComponent and componentIds. */ async function transformComponent(plugin: Plugin) { - const componentFile = join(appDir, 'app.component.ts') - const componentSource = ` - import { Component } from '@angular/core'; - - @Component({ - selector: 'app-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.css'], - }) - export class AppComponent {} - ` - if (!plugin.transform || typeof plugin.transform === 'function') { throw new Error('Expected plugin transform handler') } await plugin.transform.handler.call( { error() {}, warn() {} } as any, - componentSource, - componentFile, + COMPONENT_SOURCE, + componentPath, ) } +/** + * Invoke the Angular component middleware with a synthetic req/res pair and + * return the response body. Resolves once `res.end()` is called. + */ +async function invokeAngularMiddleware( + middleware: (...args: any[]) => void, + componentId: string, +): Promise { + const encoded = encodeURIComponent(componentId) + const req = { url: `/@ng/component?c=${encoded}&t=${Date.now()}` } + let responseBody = '' + const res = { + setHeader() {}, + statusCode: 200, + end(data: string = '') { + responseBody = data ?? '' + }, + } + await new Promise((resolve) => { + const wrappedRes = { + ...res, + end(data: string = '') { + res.end(data) + resolve() + }, + } + middleware(req, wrappedRes, resolve) + }) + return responseBody +} + +describe('pendingHmrUpdates race condition', () => { + it('preserves pending entry when template file is transiently empty (truncate-then-write race)', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + await transformComponent(plugin) + + // Trigger handleHotUpdate for the template → adds componentFile to pendingHmrUpdates + const componentHtmlFile = normalizePath(templatePath) + const ctx = createMockHmrContext(componentHtmlFile, [{ id: componentHtmlFile }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + // Extract the encoded component ID that handleHotUpdate broadcast via WS + const updateMsg = mockServer._wsMessages.find( + (m: any) => m.event === 'angular:component-update', + ) + expect(updateMsg, 'expected angular:component-update to be dispatched').toBeDefined() + const componentId = decodeURIComponent(updateMsg.data.id) + + const middleware = (mockServer.middlewares.use as ReturnType).mock.calls[0]?.[0] + expect(middleware, 'expected middleware to be registered').toBeDefined() + + // Simulate the truncate phase: file is transiently empty + writeFileSync(templatePath, '') + + // First HTTP request — file is empty, must return '' but MUST NOT consume + // the pending entry so the next request can serve real content. + const firstBody = await invokeAngularMiddleware(middleware, componentId) + expect(firstBody).toBe('') + + // Restore real content (simulates the second write completing) + writeFileSync(templatePath, 'Hello') + + // Second HTTP request — pending entry must still be present → HMR module returned + const secondBody = await invokeAngularMiddleware(middleware, componentId) + expect(secondBody, 'expected HMR module to be returned on second request').not.toBe('') + + // Third request — pending entry must have been consumed by the second request + const thirdBody = await invokeAngularMiddleware(middleware, componentId) + expect(thirdBody, 'expected pending entry to be consumed after successful HMR').toBe('') + }) + + it('consumes pending entry and dispatches angular:invalidate on compile error', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + await transformComponent(plugin) + + const componentHtmlFile = normalizePath(templatePath) + const ctx = createMockHmrContext(componentHtmlFile, [{ id: componentHtmlFile }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + const updateMsg = mockServer._wsMessages.find( + (m: any) => m.event === 'angular:component-update', + ) + expect(updateMsg, 'expected angular:component-update to be dispatched').toBeDefined() + const componentId = decodeURIComponent(updateMsg.data.id) + + const middleware = (mockServer.middlewares.use as ReturnType).mock.calls[0]?.[0] + expect(middleware, 'expected middleware to be registered').toBeDefined() + + // Rename the component .ts file so readFile(resolvedId) throws ENOENT, + // guaranteeing the catch path fires without corrupting templatePath. + const hiddenPath = componentPath + '.hidden' + renameSync(componentPath, hiddenPath) + try { + const errorBody = await invokeAngularMiddleware(middleware, componentId) + expect(errorBody).toBe('') + + // angular:invalidate must have been dispatched + expect(mockServer._wsMessages).toContainEqual( + expect.objectContaining({ type: 'custom', event: 'angular:invalidate' }), + ) + + // Pending entry must have been consumed — subsequent request returns '' + const afterErrorBody = await invokeAngularMiddleware(middleware, componentId) + expect(afterErrorBody, 'expected pending entry to be consumed after error').toBe('') + } finally { + renameSync(hiddenPath, componentPath) + } + }) +}) + describe('handleHotUpdate - Issue #185', () => { it('should let non-component CSS files pass through to Vite HMR', async () => { const plugin = getAngularPlugin() @@ -210,7 +324,7 @@ describe('handleHotUpdate - Issue #185', () => { } }) - it('should return [] for component CSS files managed by custom watcher', async () => { + it('should dispatch angular:component-update for component CSS files', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithServer(plugin) await transformComponent(plugin) @@ -222,11 +336,14 @@ describe('handleHotUpdate - Issue #185', () => { const result = await callHandleHotUpdate(plugin, ctx) - // Component resources MUST be swallowed (return []) + // Component resources MUST be swallowed (return []) and dispatch HMR. expect(result).toEqual([]) + expect(mockServer._wsMessages).toContainEqual( + expect.objectContaining({ type: 'custom', event: 'angular:component-update' }), + ) }) - it('should return [] for component template HTML files managed by custom watcher', async () => { + it('should dispatch angular:component-update for component template HTML files', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithServer(plugin) await transformComponent(plugin) @@ -237,8 +354,11 @@ describe('handleHotUpdate - Issue #185', () => { const result = await callHandleHotUpdate(plugin, ctx) - // Component templates MUST be swallowed (return []) + // Component templates MUST be swallowed (return []) and dispatch HMR. expect(result).toEqual([]) + expect(mockServer._wsMessages).toContainEqual( + expect.objectContaining({ type: 'custom', event: 'angular:component-update' }), + ) }) it('should not swallow non-resource HTML files', async () => { @@ -258,19 +378,55 @@ describe('handleHotUpdate - Issue #185', () => { } }) - it('should pass through non-style/template files unchanged', async () => { + it('should trigger full-reload for plain (non-component) .ts files', async () => { const plugin = getAngularPlugin() - await setupPluginWithServer(plugin) + const mockServer = await setupPluginWithServer(plugin) + // src/utils.ts is a plain TS module — Angular's runtime HMR can't + // refresh captured module bindings, so the only correct fallback is a + // full reload (matches Angular CLI behavior). const utilFile = normalizePath(join(tempDir, 'src', 'utils.ts')) const mockModules = [{ id: utilFile }] - const ctx = createMockHmrContext(utilFile, mockModules) + const ctx = createMockHmrContext(utilFile, mockModules, mockServer) + + const result = await callHandleHotUpdate(plugin, ctx) + + expect(result).toEqual([]) + expect(mockServer._wsMessages).toContainEqual(expect.objectContaining({ type: 'full-reload' })) + }) + + it('should ignore .ts files in node_modules', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + const depFile = normalizePath(join(tempDir, 'node_modules', 'foo', 'index.ts')) + const mockModules = [{ id: depFile }] + const ctx = createMockHmrContext(depFile, mockModules, mockServer) const result = await callHandleHotUpdate(plugin, ctx) - // Non-Angular .ts files should pass through with their modules + // Should fall through to Vite's default HMR — never trigger a reload + // for vendor code. + expect(mockServer._wsMessages).not.toContainEqual( + expect.objectContaining({ type: 'full-reload' }), + ) if (result !== undefined) { expect(result).toEqual(mockModules) } }) + + it('should not act when liveReload is disabled', async () => { + const plugin = angular({ liveReload: false }).find( + (candidate) => candidate.name === '@oxc-angular/vite', + )! + const mockServer = await setupPluginWithServer(plugin) + + const utilFile = normalizePath(join(tempDir, 'src', 'utils.ts')) + const ctx = createMockHmrContext(utilFile, [{ id: utilFile }], mockServer) + + await callHandleHotUpdate(plugin, ctx) + + // No HMR or full-reload should be sent when liveReload is off. + expect(mockServer._wsMessages).toHaveLength(0) + }) }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 87f641a85..3ccdca735 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -8,7 +8,7 @@ * - Hot Module Replacement (HMR) */ -import { watch, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { ServerResponse } from 'node:http' import { dirname, resolve } from 'node:path' @@ -191,12 +191,25 @@ export function angular(options: PluginOptions = {}): Plugin[] { // Cache for resolved resources const resourceCache = new Map() - // Track component files with pending HMR updates (set by fs.watch, checked by HMR endpoint) + // Component files queued for HMR delivery. Populated by `handleHotUpdate` + // when an external resource or inline template/style change is detected, + // and consumed by the `@ng/component` HTTP endpoint, which reads it to + // decide whether to serve the update module or an empty response. const pendingHmrUpdates = new Set() // Cache inline template content per .ts file for detecting template-only changes const inlineTemplateCache = new Map() + // Cache the inline styles array per .ts file. Used by the HMR endpoint to + // serve fresh inline styles to ɵɵreplaceMetadata when the styles change. + const inlineStylesCache = new Map() + + // Cache the source of each component .ts file with its `template:` and + // `styles:` decorator fields stripped. If the stripped form is byte-identical + // before and after a save, we know only the template / styles changed and + // can dispatch an HMR update instead of a full reload. + const componentMetadataCache = new Map() + function getMinifyComponentStyles(context?: { environment?: { config?: { build?: ResolvedConfig['build'] } } }): boolean { @@ -330,65 +343,20 @@ export function angular(options: PluginOptions = {}): Plugin[] { configureServer(server) { viteServer = server - // Track watched template files - const watchedTemplates = new Set() - - // Use fs.watch for template files instead of Vite's watcher - // This bypasses Vite's internal handling which causes full reloads - const watchTemplateFile = (file: string) => { - if (watchedTemplates.has(file)) return - watchedTemplates.add(file) - - // Dynamically unwatch from Vite's watcher - this is more precise than static glob patterns - // and handles any file naming convention (app.css, app.component.css, styles.scss, etc.) - server.watcher.unwatch(file) - debugHmr('unwatched from Vite, adding custom watch: %s', file) - - watch(file, { persistent: true }, async (eventType) => { - if (eventType === 'change') { - const normalizedFile = normalizePath(file) - debugHmr('resource file change: %s', normalizedFile) - - // Invalidate resource cache - resourceCache.delete(normalizedFile) - - // Handle template/style file changes for HMR - if (pluginOptions.liveReload) { - const componentFile = resourceToComponent.get(normalizedFile) - if (componentFile && componentIds.has(componentFile)) { - debugHmr('resource change triggers HMR: %s -> %s', normalizedFile, componentFile) - - // Mark this component as having a pending HMR update so the - // HMR endpoint serves the update module instead of an empty response. - pendingHmrUpdates.add(componentFile) - - // Send HMR update event - const componentId = `${componentFile}@${componentIds.get(componentFile)}` - const encodedId = encodeURIComponent(componentId) - debugHmr('sending WS event: id=%s', encodedId) - // Vite expects { type: "custom", event, data } format for custom HMR events - const eventData = { id: encodedId, timestamp: Date.now() } - server.ws.send({ - type: 'custom', - event: 'angular:component-update', - data: eventData, - }) - - // Invalidate Vite's module transform cache so that a full page reload - // picks up the new template/style content instead of serving stale output. - const mod = server.moduleGraph.getModuleById(componentFile) - if (mod) { - server.moduleGraph.invalidateModule(mod) - } - } - } - } - debugHmr('added custom fs.watch for resource: %s', file) - }) - } - - // Expose the function so transform can call it - ;(server as any).__angularWatchTemplate = watchTemplateFile + // No custom file watcher — Vite's chokidar already watches every file + // it knows about, and `transform()` registers component templates and + // styles in `resourceToComponent` so they end up in Vite's module + // graph. All FS-event dispatch happens in `handleHotUpdate` below. + // + // Earlier versions of this plugin used `node:fs.watch(file, …)` per + // resource and called `server.watcher.unwatch(file)` to suppress + // Vite's default behavior. That setup misses single-`writeFile` events + // on macOS (the AI-tool/IDE pattern that hits FSEvents coalescing + // bugs) and silently drops 'rename' events from atomic-rename saves + // (vim, IntelliJ). The `handleHotUpdate` hook is the canonical Vite + // plugin extension point for what we need; using it lets Vite's + // single watcher do its job, simplifying the plugin and matching how + // Angular CLI's `@angular/build` esbuild dev server is structured. // Listen for angular:invalidate events from client // When Angular's runtime HMR update fails, it sends this event to trigger a full reload @@ -438,18 +406,18 @@ export function angular(options: PluginOptions = {}): Plugin[] { const fileId = decodedComponentId.slice(0, atIndex) const resolvedId = resolve(process.cwd(), fileId) - // Only return HMR update module if there's a pending update from our - // custom fs.watch handler. On initial page load, there are no pending - // updates, so we return an empty response. This prevents ɵɵreplaceMetadata - // from being called unnecessarily during initial load, which would - // re-create views and cause errors with @Required() decorators. + // Only return an HMR update module if `handleHotUpdate` queued + // one for this component. On initial page load there are no + // pending updates, so we return an empty response. This prevents + // ɵɵreplaceMetadata from being called unnecessarily during + // initial load, which would re-create views and cause errors + // with @Required() decorators. if (!pendingHmrUpdates.has(fileId)) { res.setHeader('Content-Type', 'text/javascript') res.setHeader('Cache-Control', 'no-cache') res.end('') return } - pendingHmrUpdates.delete(fileId) try { const source = await readFile(resolvedId, 'utf-8') @@ -471,7 +439,10 @@ export function angular(options: PluginOptions = {}): Plugin[] { if (templateContent) { const className = componentIds.get(resolvedId) ?? 'Component' - // Read fresh style content for all style URLs + // Read fresh style content. External styleUrls are read from + // disk and run through Vite's preprocessCSS (so SCSS/LESS + // resolve correctly); inline styles are extracted from the + // .ts source as plain CSS strings. let styles: string[] | null = null if (styleUrls.length > 0) { const styleContents: string[] = [] @@ -495,6 +466,12 @@ export function angular(options: PluginOptions = {}): Plugin[] { if (styleContents.length > 0) { styles = styleContents } + } else { + // No external styleUrls — fall back to inline `styles: […]`. + const inlineStyles = extractInlineStyles(source) + if (inlineStyles !== null && inlineStyles.length > 0) { + styles = inlineStyles + } } const result = compileForHmrSync(templateContent, className, resolvedId, styles, { @@ -502,6 +479,12 @@ export function angular(options: PluginOptions = {}): Plugin[] { minifyComponentStyles: getMinifyComponentStyles(), }) + // Only consume the pending slot once we have real content to + // serve. If we deleted unconditionally and the file was + // transiently empty (truncate phase of an atomic write on + // Linux), the next inotify event's request would find no + // pending entry and deliver no HMR. + pendingHmrUpdates.delete(fileId) res.setHeader('Content-Type', 'text/javascript') res.setHeader('Cache-Control', 'no-cache') res.end(result.hmrModule) @@ -512,6 +495,10 @@ export function angular(options: PluginOptions = {}): Plugin[] { const errorMessage = error.message + (error.stack ? '\n' + error.stack : '') console.error('[Angular HMR] Update failed:', errorMessage) + // Consume the pending slot on error to prevent repeated failed + // compilations on every subsequent browser request. + pendingHmrUpdates.delete(fileId) + // Send angular:invalidate event to trigger graceful full reload // This matches Angular's HMR error fallback pattern server.ws.send({ @@ -526,7 +513,12 @@ export function angular(options: PluginOptions = {}): Plugin[] { return } - // No template content found + // Template content was empty or null — either the file is in a + // transient state during a multi-step write (truncate phase), or + // the template was legitimately removed. In both cases, preserve + // the pending entry: a transient empty resolves on the next watcher + // event; a permanent removal is bounded — the next successful save + // will consume the entry. res.setHeader('Content-Type', 'text/javascript') res.setHeader('Cache-Control', 'no-cache') res.end('') @@ -572,25 +564,20 @@ export function angular(options: PluginOptions = {}): Plugin[] { const isSSR = !!options?.ssr // Track dependencies for resource cache invalidation and HMR. - // DON'T use addWatchFile - it creates modules in Vite's graph! - // Instead, use our custom watcher that doesn't create modules. - // Note: watchers are registered for both client AND SSR transforms - // because the fs.watch callback invalidates resourceCache (needed by - // both). The HMR-specific behavior inside the callback is separately - // gated by componentIds, which are only populated for client transforms. + // We don't call addWatchFile (which would create modules in Vite's + // graph) or maintain a custom watcher — Vite's chokidar already + // sees these files via its normal HMR pipeline, and our + // `handleHotUpdate` hook below dispatches based on + // `resourceToComponent` membership. if (watchMode && viteServer) { - const watchFn = (viteServer as any).__angularWatchTemplate - - // Prune stale entries: if this component previously referenced - // different resources (e.g., templateUrl was renamed), remove the - // old reverse mappings so handleHotUpdate no longer swallows those files. - // Re-add pruned files to Vite's watcher so they can be processed as - // normal assets if used elsewhere (e.g., as a global stylesheet). + // Prune stale reverse mappings: if this component previously + // referenced different resources (e.g., templateUrl was renamed), + // drop the old entries so `handleHotUpdate` stops treating them + // as component-owned. const newDeps = new Set(dependencies.map(normalizePath)) for (const [resource, owner] of resourceToComponent) { if (owner === actualId && !newDeps.has(resource)) { resourceToComponent.delete(resource) - viteServer.watcher.add(resource) } } @@ -598,10 +585,6 @@ export function angular(options: PluginOptions = {}): Plugin[] { const normalizedDep = normalizePath(dep) // Track reverse mapping for HMR: resource → component resourceToComponent.set(normalizedDep, actualId) - // Add to our custom watcher - if (watchFn) { - watchFn(normalizedDep) - } } } @@ -640,11 +623,20 @@ export function angular(options: PluginOptions = {}): Plugin[] { debugHmr('registered: %s -> %s', actualId, className) } - // Cache inline template content for detecting template-only changes in handleHotUpdate + // Cache inline template / styles for detecting template-only or + // styles-only changes in handleHotUpdate, and the metadata-stripped + // source for cheaply diffing whether anything else changed. const inlineTemplate = extractInlineTemplate(code) if (inlineTemplate !== null) { inlineTemplateCache.set(actualId, inlineTemplate) } + const inlineStyles = extractInlineStyles(code) + if (inlineStyles !== null) { + inlineStylesCache.set(actualId, inlineStyles) + } else { + inlineStylesCache.delete(actualId) + } + componentMetadataCache.set(actualId, stripComponentMetadata(code)) } return { @@ -657,132 +649,143 @@ export function angular(options: PluginOptions = {}): Plugin[] { if (!pluginOptions.liveReload) return debugHmr('handleHotUpdate file=%s', ctx.file) - debugHmr( - 'ctx.modules=%d ids=%s', - ctx.modules.length, - ctx.modules.map((m) => m.id).join(', '), - ) - // Component resource files (templates/styles referenced via templateUrl/styleUrls) - // are handled by our custom fs.watch in configureServer. We dynamically unwatch them - // from Vite's watcher during transform, so they shouldn't normally trigger handleHotUpdate. - // If they do appear here (e.g., file not yet transformed or from another plugin), - // return [] to prevent Vite's default handling. - // - // However, non-component files (e.g., global stylesheets imported in main.ts) are NOT - // managed by our custom watcher and must flow through Vite's normal HMR pipeline so that - // PostCSS/Tailwind and other plugins can process them correctly. + const normalizedFile = normalizePath(ctx.file) + + // Helper: dispatch a component HMR update for the owning component + // (used by both external resource and inline template/style branches). + // Returns true if dispatched. + const dispatchComponentUpdate = (componentFile: string): boolean => { + if (!componentIds.has(componentFile)) return false + // The HMR HTTP endpoint reads this set to decide whether to serve + // the update module or an empty response. + pendingHmrUpdates.add(componentFile) + + // Invalidate the component's module so the next request reads fresh + // template/style content. + const mod = ctx.server.moduleGraph.getModuleById(componentFile) + if (mod) ctx.server.moduleGraph.invalidateModule(mod) + + const componentId = `${componentFile}@${componentIds.get(componentFile)}` + const encodedId = encodeURIComponent(componentId) + debugHmr('sending angular:component-update id=%s', encodedId) + ctx.server.ws.send({ + type: 'custom', + event: 'angular:component-update', + data: { id: encodedId, timestamp: Date.now() }, + }) + return true + } + + // ------------------------------------------------------------ + // Branch 1: external component resource (templateUrl / styleUrl) + // ------------------------------------------------------------ + // Files like `foo.component.html` or `foo.component.scss` referenced + // by a component get HMR — Angular's runtime hot-swaps templates and + // styles without re-instantiating the component. Non-component + // resources (e.g. global stylesheets in main.ts) fall through to + // Vite's default CSS HMR pipeline so PostCSS/Tailwind etc. still + // process them. if (/\.(html?|css|scss|sass|less)$/.test(ctx.file)) { - const normalizedFile = normalizePath(ctx.file) if (resourceToComponent.has(normalizedFile)) { - debugHmr( - 'ignoring component resource file in handleHotUpdate (handled by custom watcher)', - ) - return [] + const componentFile = resourceToComponent.get(normalizedFile)! + resourceCache.delete(normalizedFile) + if (dispatchComponentUpdate(componentFile)) { + debugHmr('external resource HMR: %s -> %s', normalizedFile, componentFile) + return [] + } } - debugHmr('letting non-component resource file through to Vite HMR: %s', normalizedFile) + // Not a tracked component resource — let Vite handle it. + return ctx.modules } - // Handle component file changes - const isComponent = ANGULAR_TS_REGEX.test(ctx.file) - const hasComponentId = componentIds.has(ctx.file) - debugHmr( - 'component check: isComponent=%s hasComponentId=%s file=%s', - isComponent, - hasComponentId, - ctx.file, - ) - debugHmr('componentIds keys: %O', Array.from(componentIds.keys())) - - if (isComponent && hasComponentId) { - // If there's a pending HMR update for this component, the .ts module - // was invalidated by our fs.watch handler (template/style change), not - // by an actual .ts file edit. Skip the full reload — HMR handles it. + // ------------------------------------------------------------ + // Branch 2: component .ts (has @Component decorator) + // ------------------------------------------------------------ + // The transform pass populates componentIds for every component .ts. + // A change here is either: + // (a) only the inline `template:` and/or `styles:` fields changed + // → HMR (no reload), matching Angular CLI's behavior. + // (b) anything else (class body, imports, other decorator metadata) + // → full reload, since Angular's runtime can't safely hot-swap + // class definitions. + const isTsFile = ANGULAR_TS_REGEX.test(ctx.file) + if (isTsFile && componentIds.has(ctx.file)) { + // If a pending update was already registered for this component + // (e.g. an external template change just invalidated the .ts module + // via the graph), the resource branch has it covered. if (pendingHmrUpdates.has(ctx.file)) { - debugHmr('skipping full reload — pending HMR update from template/style change') + debugHmr('component .ts: pending HMR already queued, skip') return [] } - // Check if only the inline template changed — if so, use HMR instead of full reload. - // For external templates this is handled by fs.watch, but inline templates are part - // of the .ts file and need explicit diffing. - const cachedTemplate = inlineTemplateCache.get(ctx.file) - if (cachedTemplate !== undefined) { + // Strip-based check: if the source with `template:` and `styles:` + // decorator fields stripped is byte-identical to the cached stripped + // form, the diff is contained entirely in those fields and we can + // HMR. This covers inline-template-only, inline-style-only, and + // both-at-once changes uniformly. + const cachedStripped = componentMetadataCache.get(ctx.file) + if (cachedStripped !== undefined) { let newContent: string try { newContent = readFileSync(ctx.file, 'utf-8') } catch { newContent = '' } - const newTemplate = extractInlineTemplate(newContent) - - if (newTemplate !== null && newTemplate !== cachedTemplate) { - // Template changed — check if ONLY the template changed - const TMPL_RE = /template\s*:\s*`([\s\S]*?)`/ - const newWithout = newContent.replace(TMPL_RE, 'template: ``') - const oldReconstructed = newContent - .replace(newTemplate, cachedTemplate) - .replace(TMPL_RE, 'template: ``') - - if (newWithout === oldReconstructed) { - debugHmr('inline template-only change detected, using HMR for %s', ctx.file) - - // Update cache - inlineTemplateCache.set(ctx.file, newTemplate) - - // Mark as pending so the HMR endpoint serves the update module - pendingHmrUpdates.add(ctx.file) - - // Invalidate Vite's module graph - const componentModule = ctx.server.moduleGraph.getModuleById(ctx.file) - if (componentModule) { - ctx.server.moduleGraph.invalidateModule(componentModule) - } - - // Send HMR event (same as external template changes) - const className = componentIds.get(ctx.file) - const componentId = `${ctx.file}@${className}` - const encodedId = encodeURIComponent(componentId) - ctx.server.ws.send({ - type: 'custom', - event: 'angular:component-update', - data: { id: encodedId, timestamp: Date.now() }, - }) - - return [] + const newStripped = stripComponentMetadata(newContent) + if (newStripped === cachedStripped) { + debugHmr('inline template/styles-only change, dispatching HMR for %s', ctx.file) + const newTemplate = extractInlineTemplate(newContent) + if (newTemplate !== null) inlineTemplateCache.set(ctx.file, newTemplate) + const newStyles = extractInlineStyles(newContent) + if (newStyles !== null) { + inlineStylesCache.set(ctx.file, newStyles) + } else { + inlineStylesCache.delete(ctx.file) } + componentMetadataCache.set(ctx.file, newStripped) + dispatchComponentUpdate(ctx.file) + return [] } } - debugHmr('triggering full reload for component file change') - // Component FILE changes require a full reload because: - // - Class definition changes can't be hot-swapped safely - // - Constructor, methods, signals, and state changes need a fresh start - // - Only template/style changes support HMR (handled by fs.watch separately) - // - // This matches Angular's official behavior - they only support HMR for - // template and style changes, not component class changes. - - // Invalidate the component module + // Anything else in a component .ts is a full reload. + debugHmr('component .ts: triggering full reload for %s', ctx.file) const componentModule = ctx.server.moduleGraph.getModuleById(ctx.file) - if (componentModule) { - ctx.server.moduleGraph.invalidateModule(componentModule) - } - - // Clear any cached resources - resourceCache.delete(normalizePath(ctx.file)) - - // Trigger full reload - debugHmr('sending full-reload WebSocket message for %s', ctx.file) - ctx.server.ws.send({ - type: 'full-reload', - path: ctx.file, - }) - debugHmr('full-reload message sent') + if (componentModule) ctx.server.moduleGraph.invalidateModule(componentModule) + resourceCache.delete(normalizedFile) + ctx.server.ws.send({ type: 'full-reload', path: ctx.file }) + return [] + } + // ------------------------------------------------------------ + // Branch 3: plain (non-component) .ts + // ------------------------------------------------------------ + // Utility modules, services, constants, route configs, type-only + // files. Angular's runtime HMR only refreshes template/style + // metadata on already-mounted instances; constants and bindings + // captured by component constructors are not re-pulled. Vite's + // default propagation accepts via the importing component's HMR + // boundary without re-rendering — leaving the DOM stale. Match + // Angular CLI's official behavior and full-reload. + // + // Use `normalizedFile` for the node_modules check — on Windows + // `ctx.file` may contain backslashes; `normalizePath` converts to + // forward slashes so the substring match works cross-platform. + if (isTsFile && !normalizedFile.includes('/node_modules/')) { + debugHmr('plain .ts: triggering full reload for %s', ctx.file) + for (const mod of ctx.modules) { + ctx.server.moduleGraph.invalidateModule(mod) + } + ctx.server.ws.send({ type: 'full-reload', path: ctx.file }) return [] } + // ------------------------------------------------------------ + // Branch 4: anything else + // ------------------------------------------------------------ + // Non-Angular files (json, images, etc.). Let Vite's default HMR + // handle them. return ctx.modules }, } @@ -857,4 +860,44 @@ function extractInlineTemplate(code: string): string | null { return null } +/** + * Extract inline styles array from @Component decorator. + * + * Handles `styles: [\`…\`]`, `styles: ['…']`, and `styles: ["…"]` and + * combinations thereof. Returns null if no `styles:` array is present. + */ +function extractInlineStyles(code: string): string[] | null { + const arrMatch = code.match(/styles\s*:\s*\[([\s\S]*?)\]/) + if (!arrMatch) return null + const body = arrMatch[1] + // Match each string literal in the array body. Order matters for HMR + // delivery since styles are positional. + const stringRe = /`([\s\S]*?)`|'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)"/g + const styles: string[] = [] + let m: RegExpExecArray | null + while ((m = stringRe.exec(body)) !== null) { + styles.push(m[1] ?? m[2] ?? m[3] ?? '') + } + return styles.length > 0 ? styles : null +} + +/** + * Replace the inline `template:` and `styles:` decorator fields with empty + * placeholders. Used to detect "only template and/or styles changed" — if + * the stripped form of the old and new source is byte-identical, the diff + * is contained within those fields and we can dispatch an HMR component + * update instead of a full reload. + * + * Note: each replace targets the FIRST occurrence only. This assumes one + * `@Component` decorator per file (Angular convention). Files with multiple + * components fall through to full-reload, which is the safe default. + */ +function stripComponentMetadata(code: string): string { + return code + .replace(/template\s*:\s*`[\s\S]*?`/, 'template:``') + .replace(/template\s*:\s*'(?:\\.|[^'\\])*'/, "template:''") + .replace(/template\s*:\s*"(?:\\.|[^"\\])*"/, 'template:""') + .replace(/styles\s*:\s*\[[\s\S]*?\]/, 'styles:[]') +} + export { angular as default }
E2E test fixture for HMR testing.
{{ utilValue }}
{{ message }}