Skip to content
Open
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
21 changes: 21 additions & 0 deletions napi/angular-compiler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion napi/angular-compiler/e2e/app/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions napi/angular-compiler/e2e/app/src/app/app.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<main>
<h1>{{ title() }}</h1>
<p class="description">E2E test fixture for HMR testing.</p>
<p class="util-value" data-test-util>{{ utilValue }}</p>
<app-card cardTitle="INPUT_TITLE" [cardValue]="42" />
<app-inline-card />
</main>
28 changes: 28 additions & 0 deletions napi/angular-compiler/e2e/app/src/app/inline-card.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Component } from '@angular/core'

@Component({
selector: 'app-inline-card',
template: `
<section class="inline-card">
<h2>INLINE_TITLE</h2>
<p class="inline-card-body">{{ message }}</p>
</section>
`,
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'
}
5 changes: 5 additions & 0 deletions napi/angular-compiler/e2e/app/src/app/util.ts
Original file line number Diff line number Diff line change
@@ -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'
64 changes: 61 additions & 3 deletions napi/angular-compiler/e2e/fixtures/test-fixture.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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<void> {
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.
Expand All @@ -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<void> {
async modifyFile(
filename: string,
modifier: (content: string) => string,
strategy: WriteStrategy = 'writeFile-in-place',
): Promise<void> {
const filepath = join(FIXTURE_APP, filename)
const content = await readFile(filepath, 'utf-8')

Expand All @@ -27,7 +85,7 @@ export class FileModifier {
}

const modified = modifier(content)
await writeFile(filepath, modified)
await performWrite(filepath, modified, strategy)
}

/**
Expand Down
40 changes: 40 additions & 0 deletions napi/angular-compiler/e2e/tests/hmr-html-write-strategies.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
}
})
38 changes: 38 additions & 0 deletions napi/angular-compiler/e2e/tests/hmr-inline-styles.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
31 changes: 31 additions & 0 deletions napi/angular-compiler/e2e/tests/hmr-inline-template.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
42 changes: 42 additions & 0 deletions napi/angular-compiler/e2e/tests/hmr-plain-ts.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
12 changes: 6 additions & 6 deletions napi/angular-compiler/e2e/tests/hmr-ts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')`,
)
})

Expand All @@ -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
Expand All @@ -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'",
)
})

Expand Down
Loading