Skip to content

feat(vite-plugin): full HMR + reload matrix matching Angular CLI#264

Open
ashley-hunter wants to merge 1 commit intovoidzero-dev:mainfrom
ashley-hunter:feat-hmr-dispatcher-vite-watcher
Open

feat(vite-plugin): full HMR + reload matrix matching Angular CLI#264
ashley-hunter wants to merge 1 commit intovoidzero-dev:mainfrom
ashley-hunter:feat-hmr-dispatcher-vite-watcher

Conversation

@ashley-hunter
Copy link
Copy Markdown
Contributor

@ashley-hunter ashley-hunter commented May 6, 2026

I noticed when using AI tools to modify code (in a project using the vite plugin) the browser didn't update with the changes as I'd expect, while editing the same files manually in an editor worked fine. After some digging it turns out different tools save files in different ways, and the plugin's per-file node:fs.watch doesn't deal with all of them.

@oxc-angular/vite watches each component template/stylesheet with its own fs.watch(file, …) inside configureServer, and the handler only reacts to eventType === 'change'. Tools that save by writing a temp file and renaming over the target — vim's default, IntelliJ's "safe write", and the Edit pipeline in several AI tools — produce 'rename' events that get dropped on the floor. On macOS it gets worse than that: once the file has been replaced by a rename, fs.watch is bound to the original inode which no longer exists, so even subsequent in-place writes won't fire until the dev server restarts. Most editors save in place, which is why manual saves keep working. And because configureServer calls server.watcher.unwatch(file), Vite's own chokidar — which watches the project recursively and handles all of this fine — isn't around as a fallback.

While I was in there I lined the plugin up against @angular/build (CLI's esbuild dev server, Angular 17+) and noticed two more gaps. Inline styles: ['…'] changes fall through to full reload — the CLI HMRs them, same as inline templates. Plain non-component .ts edits don't reload at all: Vite's default propagation accepts at the nearest component boundary, Angular's runtime sees no template/style metadata change and does nothing, and the DOM stays stale.

This PR replaces the custom watcher with a handleHotUpdate dispatcher driven by Vite's chokidar. Inline-style HMR is added symmetric to inline-template. Plain .ts edits full-reload. liveReload: false still disables everything. Final matrix matches @angular/build.

Tests: FileModifier gains a WriteStrategy parameter (in-place, fsync, atomic-rename, truncate-then-write) to guard against this regression specifically. New specs cover the strategy matrix, inline-style HMR, and plain-.ts reload, plus unit coverage for the new dispatcher branches. Three pre-existing hmr-ts.spec.ts tests were silently timing out due to a quote-style mismatch in the modify step — fixed.

Refactor handleHotUpdate into a single dispatcher driven by Vite's own
chokidar, replacing the per-file `node:fs.watch` watcher that lived in
configureServer. The custom watcher missed single fast in-place writes
on macOS (FSEvents coalescing — Claude Code's Edit tool, IntelliJ
"safe write", AI tools generally) and silently dropped 'rename' events
(vim atomic-save). Vite's recursive root watcher handles all of these
reliably, and `handleHotUpdate` is the canonical extension point.

Behavior matrix (now mirrors @angular/build's esbuild dev server):

  external templateUrl     → angular:component-update HMR, no reload
  external styleUrl        → angular:component-update HMR, no reload
  inline `template:` only  → angular:component-update HMR, no reload
  inline `styles:` only    → angular:component-update HMR, no reload (NEW)
  component class body /   → full reload
    imports / decorator
  non-component .ts        → full reload (NEW)
  global stylesheet        → Vite default CSS HMR
  node_modules / *.spec.ts → ignored

Inline-style HMR is added symmetric to the existing inline-template
detection. Cached stripped form of each component .ts file (template:
and styles: decorator fields removed) is diffed against the new content
on each save — if byte-identical, the change is template/styles-only
and we dispatch HMR; otherwise a full reload.

Plain non-component .ts files now full-reload by default (matching
Angular CLI). Without this, Vite's default propagation would accept via
the importing component's HMR boundary without re-rendering, leaving
the DOM stale on every utility/service/constants edit. Gated by
`liveReload`.

Tests:
- Extended FileModifier with a write-strategy parameter
  (writeFile-in-place / fsync / atomic-rename / truncate-then-write)
- New e2e specs:
    * hmr-html-write-strategies.spec.ts (4-strategy matrix)
    * hmr-inline-template.spec.ts
    * hmr-inline-styles.spec.ts
    * hmr-plain-ts.spec.ts
- New unit tests in test/hmr-hot-update.test.ts:
    * full-reload for plain (non-component) .ts
    * ignore .ts files in node_modules
    * no-op when liveReload is disabled
- Updated existing component-resource unit tests to assert the
  angular:component-update ws message is sent
- Fixed 3 pre-existing quote-style bugs in hmr-ts.spec.ts (test
  patterns looked for signal("X") but source uses signal('X'); they
  had been silently passing the modify step and timing out on reload)

Cross-platform: branch 3 uses `normalizedFile.includes('/node_modules/')`
so the substring check works on Windows where ctx.file may contain
backslashes.

README: added an HMR + reload behavior matrix table.
@ashley-hunter
Copy link
Copy Markdown
Contributor Author

Looking into e2e failures - looks like it is a linux only issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant