diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts
new file mode 100644
index 000000000..9a34e5c99
--- /dev/null
+++ b/napi/angular-compiler/test/hmr-hot-update.test.ts
@@ -0,0 +1,276 @@
+/**
+ * Tests for handleHotUpdate behavior (Issue #185).
+ *
+ * The plugin's handleHotUpdate hook must distinguish between:
+ * 1. Component resource files (templates/styles) → handled by custom fs.watch, return []
+ * 2. Non-component files (global CSS, etc.) → let Vite handle normally
+ *
+ * Previously, the plugin returned [] for ALL .css/.html files, which swallowed
+ * HMR updates for global stylesheets and prevented PostCSS/Tailwind from
+ * processing changes.
+ */
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import type { Plugin, ModuleNode, HmrContext } from 'vite'
+import { normalizePath } from 'vite'
+import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest'
+
+import { angular } from '../vite-plugin/index.js'
+
+let tempDir: string
+let appDir: string
+let templatePath: string
+let stylePath: string
+
+beforeAll(() => {
+ tempDir = mkdtempSync(join(tmpdir(), 'hmr-test-'))
+ appDir = join(tempDir, 'src', 'app')
+ mkdirSync(appDir, { recursive: true })
+
+ templatePath = join(appDir, 'app.component.html')
+ stylePath = join(appDir, 'app.component.css')
+
+ writeFileSync(templatePath, '
Hello
')
+ writeFileSync(stylePath, 'h1 { color: red; }')
+})
+
+afterAll(() => {
+ rmSync(tempDir, { recursive: true, force: true })
+})
+
+function getAngularPlugin() {
+ const plugin = angular({ liveReload: true }).find(
+ (candidate) => candidate.name === '@oxc-angular/vite',
+ )
+
+ if (!plugin) {
+ throw new Error('Failed to find @oxc-angular/vite plugin')
+ }
+
+ return plugin
+}
+
+function createMockServer() {
+ const wsMessages: any[] = []
+ const unwatchedFiles = new Set()
+
+ return {
+ watcher: {
+ unwatch(file: string) {
+ unwatchedFiles.add(file)
+ },
+ on: vi.fn(),
+ emit: vi.fn(),
+ },
+ ws: {
+ send(msg: any) {
+ wsMessages.push(msg)
+ },
+ on: vi.fn(),
+ },
+ moduleGraph: {
+ getModuleById: vi.fn(() => null),
+ invalidateModule: vi.fn(),
+ },
+ middlewares: {
+ use: vi.fn(),
+ },
+ config: {
+ root: tempDir,
+ },
+ _wsMessages: wsMessages,
+ _unwatchedFiles: unwatchedFiles,
+ }
+}
+
+function createMockHmrContext(
+ file: string,
+ modules: Partial[] = [],
+ server?: any,
+): HmrContext {
+ return {
+ file,
+ timestamp: Date.now(),
+ modules: modules as ModuleNode[],
+ read: async () => '',
+ server: server ?? createMockServer(),
+ } as HmrContext
+}
+
+async function callHandleHotUpdate(
+ plugin: Plugin,
+ ctx: HmrContext,
+): Promise {
+ if (typeof plugin.handleHotUpdate === 'function') {
+ return (plugin.handleHotUpdate as Function).call(plugin, ctx)
+ }
+ return undefined
+}
+
+async function callPluginHook(
+ hook:
+ | {
+ handler: (...args: TArgs) => TResult
+ }
+ | ((...args: TArgs) => TResult)
+ | undefined,
+ ...args: TArgs
+): Promise {
+ if (!hook) return undefined
+ if (typeof hook === 'function') return hook(...args)
+ return hook.handler(...args)
+}
+
+/**
+ * Set up a plugin through the full Vite lifecycle so that internal state
+ * (watchMode, viteServer, resourceToComponent, componentIds) is populated.
+ */
+async function setupPluginWithServer(plugin: Plugin) {
+ const mockServer = createMockServer()
+
+ // config() sets watchMode = true when command === 'serve'
+ await callPluginHook(
+ plugin.config as Plugin['config'],
+ {} as any,
+ {
+ command: 'serve',
+ mode: 'development',
+ } as any,
+ )
+
+ // configResolved() stores the resolved config
+ await callPluginHook(
+ plugin.configResolved as Plugin['configResolved'],
+ {
+ build: {},
+ isProduction: false,
+ } as any,
+ )
+
+ // configureServer() sets up the custom watcher and stores viteServer
+ if (typeof plugin.configureServer === 'function') {
+ await (plugin.configureServer as Function)(mockServer)
+ }
+
+ // Replace the real fs.watch-based watcher with a no-op to avoid EPERM
+ // errors on Windows when temp files are cleaned up. resourceToComponent
+ // is populated in transform *before* watchFn is called, so the map is
+ // still correctly populated for handleHotUpdate tests.
+ ;(mockServer as any).__angularWatchTemplate = () => {}
+
+ return mockServer
+}
+
+/**
+ * Transform a component that references external template + style files,
+ * 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,
+ )
+}
+
+describe('handleHotUpdate - Issue #185', () => {
+ it('should let non-component CSS files pass through to Vite HMR', async () => {
+ const plugin = getAngularPlugin()
+ await setupPluginWithServer(plugin)
+
+ // A global CSS file (not referenced by any component's styleUrls)
+ const globalCssFile = normalizePath(join(tempDir, 'src', 'styles.css'))
+ const mockModules = [{ id: globalCssFile }]
+ const ctx = createMockHmrContext(globalCssFile, mockModules)
+
+ const result = await callHandleHotUpdate(plugin, ctx)
+
+ // Non-component CSS should NOT be swallowed — either undefined (pass through)
+ // or the original modules array, but NOT an empty array
+ if (result !== undefined) {
+ expect(result).toEqual(mockModules)
+ }
+ })
+
+ it('should return [] for component CSS files managed by custom watcher', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+ await transformComponent(plugin)
+
+ // The component's CSS file IS in resourceToComponent
+ const componentCssFile = normalizePath(stylePath)
+ const mockModules = [{ id: componentCssFile }]
+ const ctx = createMockHmrContext(componentCssFile, mockModules, mockServer)
+
+ const result = await callHandleHotUpdate(plugin, ctx)
+
+ // Component resources MUST be swallowed (return [])
+ expect(result).toEqual([])
+ })
+
+ it('should return [] for component template HTML files managed by custom watcher', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+ await transformComponent(plugin)
+
+ // The component's HTML template IS in resourceToComponent
+ const componentHtmlFile = normalizePath(templatePath)
+ const ctx = createMockHmrContext(componentHtmlFile, [{ id: componentHtmlFile }], mockServer)
+
+ const result = await callHandleHotUpdate(plugin, ctx)
+
+ // Component templates MUST be swallowed (return [])
+ expect(result).toEqual([])
+ })
+
+ it('should not swallow non-resource HTML files', async () => {
+ const plugin = getAngularPlugin()
+ await setupPluginWithServer(plugin)
+
+ // index.html is NOT a component template
+ const indexHtml = normalizePath(join(tempDir, 'index.html'))
+ const mockModules = [{ id: indexHtml }]
+ const ctx = createMockHmrContext(indexHtml, mockModules)
+
+ const result = await callHandleHotUpdate(plugin, ctx)
+
+ // Non-component HTML should pass through, not be swallowed
+ if (result !== undefined) {
+ expect(result).toEqual(mockModules)
+ }
+ })
+
+ it('should pass through non-style/template files unchanged', async () => {
+ const plugin = getAngularPlugin()
+ await setupPluginWithServer(plugin)
+
+ const utilFile = normalizePath(join(tempDir, 'src', 'utils.ts'))
+ const mockModules = [{ id: utilFile }]
+ const ctx = createMockHmrContext(utilFile, mockModules)
+
+ const result = await callHandleHotUpdate(plugin, ctx)
+
+ // Non-Angular .ts files should pass through with their modules
+ if (result !== undefined) {
+ expect(result).toEqual(mockModules)
+ }
+ })
+})
diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts
index 067345cc0..ae3ece188 100644
--- a/napi/angular-compiler/vite-plugin/index.ts
+++ b/napi/angular-compiler/vite-plugin/index.ts
@@ -577,6 +577,20 @@ export function angular(options: PluginOptions = {}): Plugin[] {
// gated by componentIds, which are only populated for client transforms.
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).
+ 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)
+ }
+ }
+
for (const dep of dependencies) {
const normalizedDep = normalizePath(dep)
// Track reverse mapping for HMR: resource → component
@@ -640,13 +654,24 @@ export function angular(options: PluginOptions = {}): Plugin[] {
ctx.modules.map((m) => m.id).join(', '),
)
- // Template/style files 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.
+ // 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.
if (/\.(html?|css|scss|sass|less)$/.test(ctx.file)) {
- debugHmr('ignoring resource file in handleHotUpdate (handled by custom watcher)')
- return []
+ const normalizedFile = normalizePath(ctx.file)
+ if (resourceToComponent.has(normalizedFile)) {
+ debugHmr(
+ 'ignoring component resource file in handleHotUpdate (handled by custom watcher)',
+ )
+ return []
+ }
+ debugHmr('letting non-component resource file through to Vite HMR: %s', normalizedFile)
}
// Handle component file changes