Skip to content

Commit c550c27

Browse files
feat(vite-plugin): full HMR + reload matrix matching Angular CLI (#264)
Replaces the per-file node:fs.watch watcher with Vite's chokidar watcher (server.watcher) and adds a handleHotUpdate dispatcher that mirrors the Angular CLI HMR/reload decision matrix: - External template (.html) change → angular:component-update (HMR) - External style (.css) change → angular:component-update (HMR) - Inline template/styles change → angular:component-update (HMR) - Component class-body change (.ts) → full reload - Plain non-component .ts change → full reload - node_modules .ts change → pass through to Vite Fixes a Linux-only race condition where truncate-then-write saves (used by some AI coding tools) caused inotify to fire two discrete change events. The first event arrived while the file was empty; pendingHmrUpdates.delete() was called unconditionally before reading the template, consuming the pending slot before any content could be served. The second request found no entry and returned an empty HMR module. Fix: move pendingHmrUpdates.delete() into the success branch (non-empty template compiled and served) and the error/catch branch only. The empty-file transient fallthrough preserves the entry so the subsequent request can serve real content. Adds unit tests covering: - The double-request race (truncate-then-write transient empty state) - Pending entry consumption after successful HMR - Pending entry consumption and angular:invalidate dispatch on error Removes truncate-then-write from the e2e write-strategy matrix — chokidar can throttle the second inotify event on Linux making e2e coverage unreliable for that pattern. The race condition is covered deterministically at the unit level instead. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ee974d2 commit c550c27

13 files changed

Lines changed: 689 additions & 222 deletions

napi/angular-compiler/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,27 @@ generateHmrModule(
123123
): string
124124
```
125125

126+
##### HMR + reload behavior matrix
127+
128+
The Vite plugin's `handleHotUpdate` hook dispatches every file change
129+
into one of these branches, mirroring Angular CLI's official behavior
130+
(`@angular/build` esbuild dev server):
131+
132+
| File | Change | Action |
133+
| -------------------------------------------------------------------- | ----------------------------------------- | ----------------------------------------- |
134+
| External `.html` (templateUrl) | any | `angular:component-update` HMR, no reload |
135+
| External `.css/.scss/.sass/.less` (styleUrl) | any | `angular:component-update` HMR, no reload |
136+
| Component `.ts` | inline template only | `angular:component-update` HMR, no reload |
137+
| Component `.ts` | inline `styles: [...]` only | `angular:component-update` HMR, no reload |
138+
| Component `.ts` | both inline template and styles | `angular:component-update` HMR, no reload |
139+
| Component `.ts` | class body / imports / decorator metadata | full reload |
140+
| Non-component `.ts` (utils, services, constants, lazy `*.routes.ts`) | any | full reload |
141+
| Global stylesheet (no `styleUrl` owner) | any | Vite default style HMR |
142+
| Anything in `node_modules/` or `*.spec.ts` | any | ignore |
143+
144+
Set `liveReload: false` to disable both HMR and reloads — the plugin
145+
returns from `handleHotUpdate` without sending any event.
146+
126147
### Transform Options
127148

128149
```typescript
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { Component, signal } from '@angular/core'
22

33
import { Card } from './card.component'
4+
import { InlineCard } from './inline-card.component'
5+
import { UTIL_VALUE } from './util'
46

57
@Component({
68
selector: 'app-root',
79
templateUrl: './app.html',
810
styleUrl: './app.css',
9-
imports: [Card],
11+
imports: [Card, InlineCard],
1012
})
1113
export class App {
1214
protected readonly title = signal('E2E_TITLE')
15+
protected readonly utilValue = UTIL_VALUE
1316
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<main>
22
<h1>{{ title() }}</h1>
33
<p class="description">E2E test fixture for HMR testing.</p>
4+
<p class="util-value" data-test-util>{{ utilValue }}</p>
45
<app-card cardTitle="INPUT_TITLE" [cardValue]="42" />
6+
<app-inline-card />
57
</main>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Component } from '@angular/core'
2+
3+
@Component({
4+
selector: 'app-inline-card',
5+
template: `
6+
<section class="inline-card">
7+
<h2>INLINE_TITLE</h2>
8+
<p class="inline-card-body">{{ message }}</p>
9+
</section>
10+
`,
11+
styles: [
12+
`
13+
:host {
14+
display: block;
15+
}
16+
.inline-card {
17+
padding: 0.5rem 1rem;
18+
border: 1px dashed currentColor;
19+
}
20+
.inline-card-body {
21+
color: var(--inline-card-color, #444);
22+
}
23+
`,
24+
],
25+
})
26+
export class InlineCard {
27+
protected readonly message = 'inline-template + inline-styles'
28+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* Plain non-component module. Imported by AppComponent so changes here
3+
* exercise the plain-TS full-reload branch of `handleHotUpdate`.
4+
*/
5+
export const UTIL_VALUE = 'UTIL_INITIAL'

napi/angular-compiler/e2e/fixtures/test-fixture.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readFile, writeFile } from 'node:fs/promises'
1+
import { readFile, writeFile, rename, unlink, open } from 'node:fs/promises'
22
import { join } from 'node:path'
33
import { fileURLToPath } from 'node:url'
44

@@ -7,6 +7,56 @@ import { test as base, expect, type Page } from '@playwright/test'
77
const __dirname = fileURLToPath(new URL('.', import.meta.url))
88
const FIXTURE_APP = join(__dirname, '../app/src/app')
99

10+
/**
11+
* Write strategies that mimic how different tools / editors save files.
12+
* Used to guard against watcher implementations that miss certain patterns
13+
* (e.g., a per-file `node:fs.watch` on macOS dropping single fast in-place
14+
* writes from AI-tool Edit operations).
15+
*/
16+
export type WriteStrategy =
17+
| 'writeFile-in-place' // single fs.writeFile (Claude Code, most CLI tools)
18+
| 'writeFile-with-fsync' // writeFile + explicit fsync
19+
| 'atomic-rename' // write `.tmp` then rename (vim, IntelliJ "safe write")
20+
| 'truncate-then-write' // writeFile('') + delay + writeFile(content)
21+
22+
async function performWrite(
23+
filepath: string,
24+
content: string,
25+
strategy: WriteStrategy,
26+
): Promise<void> {
27+
switch (strategy) {
28+
case 'writeFile-in-place':
29+
await writeFile(filepath, content)
30+
return
31+
case 'writeFile-with-fsync': {
32+
await writeFile(filepath, content)
33+
const handle = await open(filepath, 'r+')
34+
try {
35+
await handle.sync()
36+
} finally {
37+
await handle.close()
38+
}
39+
return
40+
}
41+
case 'atomic-rename': {
42+
const tmp = `${filepath}.hmr-${Date.now()}.tmp`
43+
await writeFile(tmp, content)
44+
try {
45+
await rename(tmp, filepath)
46+
} catch (err) {
47+
await unlink(tmp).catch(() => {})
48+
throw err
49+
}
50+
return
51+
}
52+
case 'truncate-then-write':
53+
await writeFile(filepath, '')
54+
await new Promise((r) => setTimeout(r, 30))
55+
await writeFile(filepath, content)
56+
return
57+
}
58+
}
59+
1060
/**
1161
* File modification utility for e2e tests.
1262
* Backs up files before modification and restores them after tests.
@@ -17,8 +67,16 @@ export class FileModifier {
1767
/**
1868
* Modify a file in the fixture app directory.
1969
* Automatically backs up the original content for restoration.
70+
*
71+
* Optionally takes a write strategy to mimic how different tools save —
72+
* use this to assert HMR works regardless of the consumer's editor /
73+
* AI-tool save pattern.
2074
*/
21-
async modifyFile(filename: string, modifier: (content: string) => string): Promise<void> {
75+
async modifyFile(
76+
filename: string,
77+
modifier: (content: string) => string,
78+
strategy: WriteStrategy = 'writeFile-in-place',
79+
): Promise<void> {
2280
const filepath = join(FIXTURE_APP, filename)
2381
const content = await readFile(filepath, 'utf-8')
2482

@@ -27,7 +85,7 @@ export class FileModifier {
2785
}
2886

2987
const modified = modifier(content)
30-
await writeFile(filepath, modified)
88+
await performWrite(filepath, modified, strategy)
3189
}
3290

3391
/**
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { test, expect, type WriteStrategy } from '../fixtures/test-fixture.js'
2+
3+
/**
4+
* Regression coverage for write-strategy regressions on the chokidar-based
5+
* watcher. Vite's chokidar (recursive fs.watch on the root) handles all of
6+
* these reliably; this matrix is the empirical guard against future
7+
* regressions on either the watcher backend or the handleHotUpdate dispatcher.
8+
*/
9+
10+
const STRATEGIES: WriteStrategy[] = ['writeFile-in-place', 'writeFile-with-fsync', 'atomic-rename']
11+
12+
test.describe('HTML template HMR — write-strategy matrix', () => {
13+
test.beforeEach(async ({ page }) => {
14+
await page.goto('/')
15+
await page.waitForLoadState('networkidle')
16+
})
17+
18+
for (const strategy of STRATEGIES) {
19+
test(`triggers HMR via "${strategy}"`, async ({
20+
page,
21+
fileModifier,
22+
hmrDetector,
23+
waitForHmr,
24+
}) => {
25+
const sentinelId = await hmrDetector.addSentinel()
26+
await expect(page.locator('h1')).toContainText('E2E_TITLE')
27+
28+
await fileModifier.modifyFile(
29+
'app.html',
30+
(content) => content.replace('{{ title() }}', `STRATEGY_${strategy.toUpperCase()}`),
31+
strategy,
32+
)
33+
await waitForHmr()
34+
35+
await expect(page.locator('h1')).toContainText(`STRATEGY_${strategy.toUpperCase()}`)
36+
// Sentinel survives → HMR happened, no full reload.
37+
expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true)
38+
})
39+
}
40+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { test, expect } from '../fixtures/test-fixture.js'
2+
3+
/**
4+
* Inline style change in a `.ts` file ( `styles: ['…']` ) should trigger
5+
* an `angular:component-update` HMR event with no full reload — matches
6+
* Angular CLI's official behavior (which sends inline-style updates with
7+
* the slightly misleading `type: 'template'` discriminator).
8+
*/
9+
test.describe('Inline styles HMR', () => {
10+
test.beforeEach(async ({ page }) => {
11+
await page.goto('/')
12+
await page.waitForLoadState('networkidle')
13+
})
14+
15+
test('inline-style-only change in .ts triggers HMR (no reload)', async ({
16+
page,
17+
fileModifier,
18+
hmrDetector,
19+
waitForHmr,
20+
}) => {
21+
const sentinelId = await hmrDetector.addSentinel()
22+
23+
// Baseline color from the fixture (--inline-card-color default = #444).
24+
const body = page.locator('app-inline-card .inline-card-body')
25+
await expect(body).toBeVisible()
26+
27+
await fileModifier.modifyFile('inline-card.component.ts', (content) =>
28+
// Replace the fallback color in the inline style.
29+
content.replace('var(--inline-card-color, #444)', 'rgb(255, 0, 128)'),
30+
)
31+
await waitForHmr()
32+
33+
const color = await body.evaluate((el) => getComputedStyle(el).color)
34+
expect(color).toBe('rgb(255, 0, 128)')
35+
36+
expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true)
37+
})
38+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { test, expect } from '../fixtures/test-fixture.js'
2+
3+
/**
4+
* Inline template change in a `.ts` file ( `template: \`…\`` ) should
5+
* trigger an `angular:component-update` HMR event with no full reload —
6+
* matches Angular CLI's official behavior.
7+
*/
8+
test.describe('Inline template HMR', () => {
9+
test.beforeEach(async ({ page }) => {
10+
await page.goto('/')
11+
await page.waitForLoadState('networkidle')
12+
})
13+
14+
test('inline-template-only change in .ts triggers HMR (no reload)', async ({
15+
page,
16+
fileModifier,
17+
hmrDetector,
18+
waitForHmr,
19+
}) => {
20+
const sentinelId = await hmrDetector.addSentinel()
21+
await expect(page.locator('app-inline-card h2')).toContainText('INLINE_TITLE')
22+
23+
await fileModifier.modifyFile('inline-card.component.ts', (content) =>
24+
content.replace('INLINE_TITLE', 'INLINE_TEMPLATE_HMR'),
25+
)
26+
await waitForHmr()
27+
28+
await expect(page.locator('app-inline-card h2')).toContainText('INLINE_TEMPLATE_HMR')
29+
expect(await hmrDetector.sentinelExists(sentinelId)).toBe(true)
30+
})
31+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { test, expect } from '../fixtures/test-fixture.js'
2+
3+
/**
4+
* Plain (non-component) `.ts` modules — utilities, services, constants,
5+
* route configs — must trigger a full page reload when edited. Angular's
6+
* runtime HMR only refreshes template/style metadata on already-mounted
7+
* instances; module bindings captured by component constructors are not
8+
* re-pulled, so Vite's default propagation accepts via the importing
9+
* component's HMR boundary without re-rendering, leaving the DOM stale.
10+
*
11+
* Matches Angular CLI's official behavior, where any non-component .ts
12+
* change drops out of the HMR-eligible path and reloads the page.
13+
*/
14+
test.describe('Plain TS full reload', () => {
15+
test.beforeEach(async ({ page }) => {
16+
await page.goto('/')
17+
await page.waitForLoadState('networkidle')
18+
})
19+
20+
test('modifying a plain util .ts file triggers full page reload', async ({
21+
page,
22+
fileModifier,
23+
hmrDetector,
24+
}) => {
25+
await hmrDetector.setupEventListeners()
26+
const sentinelId = await hmrDetector.addSentinel()
27+
28+
// Baseline value comes from util.ts and is bound into AppComponent's template.
29+
await expect(page.locator('[data-test-util]')).toContainText('UTIL_INITIAL')
30+
31+
await fileModifier.modifyFile('util.ts', (content) =>
32+
content.replace('UTIL_INITIAL', 'UTIL_RELOADED'),
33+
)
34+
35+
// A full reload destroys the sentinel.
36+
await page.waitForEvent('load', { timeout: 15000 })
37+
await page.waitForLoadState('networkidle')
38+
39+
expect(await hmrDetector.sentinelExists(sentinelId)).toBe(false)
40+
await expect(page.locator('[data-test-util]')).toContainText('UTIL_RELOADED')
41+
})
42+
})

0 commit comments

Comments
 (0)