From 6b07c3b340459965a244ccef4b7e399fc57c90d4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 30 Mar 2026 22:01:12 +0800 Subject: [PATCH 1/5] fix(vite): stop swallowing HMR updates for non-component resources The plugin's handleHotUpdate was returning [] for all CSS/HTML files, preventing Vite from processing global stylesheets and notifying PostCSS/Tailwind of content changes. Now only component resource files (tracked in resourceToComponent) are swallowed; non-component files flow through Vite's normal HMR. Also emits a synthetic watcher event when component templates change so Tailwind can rescan for new classes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test/hmr-hot-update.test.ts | 224 ++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 29 ++- 2 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 napi/angular-compiler/test/hmr-hot-update.test.ts 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..ad59fbdb9 --- /dev/null +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -0,0 +1,224 @@ +/** + * 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 type { Plugin, ModuleNode, ViteDevServer, HmrContext } from 'vite' +import { describe, it, expect, vi } from 'vitest' +import { normalizePath } from 'vite' + +import { angular } from '../vite-plugin/index.js' + +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 watchedFiles = new Set() + const unwatchedFiles = new Set() + const wsMessages: any[] = [] + const emittedEvents: { event: string; path: string }[] = [] + + return { + watcher: { + unwatch(file: string) { + unwatchedFiles.add(file) + }, + on: vi.fn(), + emit(event: string, path: string) { + emittedEvents.push({ event, path }) + }, + }, + ws: { + send(msg: any) { + wsMessages.push(msg) + }, + on: vi.fn(), + }, + moduleGraph: { + getModuleById: vi.fn(() => null), + invalidateModule: vi.fn(), + }, + middlewares: { + use: vi.fn(), + }, + config: { + root: '/test', + }, + _wsMessages: wsMessages, + _unwatchedFiles: unwatchedFiles, + _emittedEvents: emittedEvents, + } +} + +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 +} + +describe('handleHotUpdate - Issue #185', () => { + it('should let non-component CSS files pass through to Vite HMR', async () => { + const plugin = getAngularPlugin() + + // Configure the plugin (sets up internal state) + if (plugin.configResolved && typeof plugin.configResolved !== 'function') { + throw new Error('Expected configResolved to be a function') + } + if (typeof plugin.configResolved === 'function') { + await plugin.configResolved({ build: {}, isProduction: false } as any) + } + + // Call handleHotUpdate with a global CSS file (not a component resource) + const globalCssFile = normalizePath('/workspace/src/styles.css') + const mockModules = [{ id: globalCssFile, type: 'css' }] + const ctx = createMockHmrContext(globalCssFile, mockModules) + + let result: ModuleNode[] | void | undefined + if (typeof plugin.handleHotUpdate === 'function') { + result = await plugin.handleHotUpdate(ctx) + } + + // Non-component CSS should NOT be swallowed - result should be undefined + // (pass through) or the original modules, NOT an empty array + if (result !== undefined) { + expect(result.length).toBeGreaterThan(0) + } + // If result is undefined, Vite uses ctx.modules (the default), which is correct + }) + + it('should return [] for component resource files that are managed by custom watcher', async () => { + const plugin = getAngularPlugin() + const mockServer = createMockServer() + + // Set up the plugin's internal state by going through the lifecycle + if (typeof plugin.configResolved === 'function') { + await plugin.configResolved({ build: {}, isProduction: false } as any) + } + + // Call configureServer to set up the custom watcher infrastructure + if (typeof plugin.configureServer === 'function') { + await (plugin.configureServer as Function)(mockServer) + } + + // Now we need to transform a component to populate resourceToComponent. + // Transform a component that references an external template + 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') + } + + // Transform the component to populate internal maps + // Note: This may fail if the template/style files don't exist, but it should + // still register the resource paths in resourceToComponent during dependency resolution + try { + await plugin.transform.handler.call( + { + error() {}, + warn() {}, + } as any, + componentSource, + '/workspace/src/app/app.component.ts', + ) + } catch { + // Transform may fail because template files don't exist on disk, + // but resourceToComponent should still be populated + } + + // Test handleHotUpdate with a component resource file + const componentCssFile = normalizePath('/workspace/src/app/app.component.css') + const ctx = createMockHmrContext(componentCssFile, [{ id: componentCssFile }], mockServer) + + let result: ModuleNode[] | void | undefined + if (typeof plugin.handleHotUpdate === 'function') { + result = await plugin.handleHotUpdate(ctx) + } + + // Component resources SHOULD be swallowed (return []) because they're handled + // by the custom fs.watch. If the transform didn't populate resourceToComponent + // (because the files don't exist), the result might pass through - that's also + // acceptable since Vite's default handling would apply. + // The key assertion is in the first test: non-component files must NOT be swallowed. + if (result !== undefined) { + // Either empty (swallowed) or passed through + expect(Array.isArray(result)).toBe(true) + } + }) + + it('should not swallow non-resource HTML files', async () => { + const plugin = getAngularPlugin() + + if (typeof plugin.configResolved === 'function') { + await plugin.configResolved({ build: {}, isProduction: false } as any) + } + + // An HTML file that is NOT a component template (e.g., index.html) + const indexHtml = normalizePath('/workspace/index.html') + const ctx = createMockHmrContext(indexHtml, [{ id: indexHtml }]) + + let result: ModuleNode[] | void | undefined + if (typeof plugin.handleHotUpdate === 'function') { + result = await plugin.handleHotUpdate(ctx) + } + + // Non-component HTML files should pass through + if (result !== undefined) { + expect(result.length).toBeGreaterThan(0) + } + }) + + it('should pass through non-style/template files unchanged', async () => { + const plugin = getAngularPlugin() + + if (typeof plugin.configResolved === 'function') { + await plugin.configResolved({ build: {}, isProduction: false } as any) + } + + // A .ts file that is NOT a component + const utilFile = normalizePath('/workspace/src/utils.ts') + const mockModules = [{ id: utilFile }] + const ctx = createMockHmrContext(utilFile, mockModules) + + let result: ModuleNode[] | void | undefined + if (typeof plugin.handleHotUpdate === 'function') { + result = await plugin.handleHotUpdate(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..0ceec771b 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -377,6 +377,14 @@ export function angular(options: PluginOptions = {}): Plugin[] { if (mod) { server.moduleGraph.invalidateModule(mod) } + + // Emit a synthetic change event on Vite's watcher so that other plugins + // (e.g., @tailwindcss/vite, PostCSS) are notified that this content file + // changed. Without this, tools like Tailwind won't rescan for new utility + // classes added in template files, since we unwatched them from Vite. + // Our handleHotUpdate still returns [] for component resources, preventing + // Vite from triggering a full page reload. + server.watcher.emit('change', file) } } } @@ -640,13 +648,22 @@ 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 From 66f5a8ee1443d8284355b240b29734f37728ae1b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 30 Mar 2026 23:10:54 +0800 Subject: [PATCH 2/5] fix review: remove synthetic watcher emit, fix test setup - Remove server.watcher.emit('change', file) for component resources: Vite treats HTML changes with no module graph entries as full reloads, which would regress template HMR behavior. - Fix test to call config() with command='serve' so watchMode=true and resourceToComponent is actually populated during transform. Tests now use real temp files and assert exact return values. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test/hmr-hot-update.test.ts | 235 +++++++++++------- napi/angular-compiler/vite-plugin/index.ts | 12 +- 2 files changed, 146 insertions(+), 101 deletions(-) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index ad59fbdb9..f2d4892a8 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -9,12 +9,37 @@ * HMR updates for global stylesheets and prevented PostCSS/Tailwind from * processing changes. */ -import type { Plugin, ModuleNode, ViteDevServer, HmrContext } from 'vite' -import { describe, it, expect, vi } from 'vitest' +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', @@ -28,10 +53,8 @@ function getAngularPlugin() { } function createMockServer() { - const watchedFiles = new Set() - const unwatchedFiles = new Set() const wsMessages: any[] = [] - const emittedEvents: { event: string; path: string }[] = [] + const unwatchedFiles = new Set() return { watcher: { @@ -39,9 +62,7 @@ function createMockServer() { unwatchedFiles.add(file) }, on: vi.fn(), - emit(event: string, path: string) { - emittedEvents.push({ event, path }) - }, + emit: vi.fn(), }, ws: { send(msg: any) { @@ -57,11 +78,10 @@ function createMockServer() { use: vi.fn(), }, config: { - root: '/test', + root: tempDir, }, _wsMessages: wsMessages, _unwatchedFiles: unwatchedFiles, - _emittedEvents: emittedEvents, } } @@ -79,20 +99,89 @@ function createMockHmrContext( } as HmrContext } +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) + } + + 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) - // Configure the plugin (sets up internal state) - if (plugin.configResolved && typeof plugin.configResolved !== 'function') { - throw new Error('Expected configResolved to be a function') - } - if (typeof plugin.configResolved === 'function') { - await plugin.configResolved({ build: {}, isProduction: false } as any) - } - - // Call handleHotUpdate with a global CSS file (not a component resource) - const globalCssFile = normalizePath('/workspace/src/styles.css') + // A global CSS file (not referenced by any component's styleUrls) + const globalCssFile = normalizePath(join(tempDir, 'src', 'styles.css')) const mockModules = [{ id: globalCssFile, type: 'css' }] const ctx = createMockHmrContext(globalCssFile, mockModules) @@ -101,113 +190,75 @@ describe('handleHotUpdate - Issue #185', () => { result = await plugin.handleHotUpdate(ctx) } - // Non-component CSS should NOT be swallowed - result should be undefined - // (pass through) or the original modules, NOT an empty array + // 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.length).toBeGreaterThan(0) + expect(result).toEqual(mockModules) } - // If result is undefined, Vite uses ctx.modules (the default), which is correct }) - it('should return [] for component resource files that are managed by custom watcher', async () => { + it('should return [] for component CSS files managed by custom watcher', async () => { const plugin = getAngularPlugin() - const mockServer = createMockServer() + const mockServer = await setupPluginWithServer(plugin) + await transformComponent(plugin) - // Set up the plugin's internal state by going through the lifecycle - if (typeof plugin.configResolved === 'function') { - await plugin.configResolved({ build: {}, isProduction: false } as any) - } + // The component's CSS file IS in resourceToComponent + const componentCssFile = normalizePath(stylePath) + const mockModules = [{ id: componentCssFile }] + const ctx = createMockHmrContext(componentCssFile, mockModules, mockServer) - // Call configureServer to set up the custom watcher infrastructure - if (typeof plugin.configureServer === 'function') { - await (plugin.configureServer as Function)(mockServer) + let result: ModuleNode[] | void | undefined + if (typeof plugin.handleHotUpdate === 'function') { + result = await plugin.handleHotUpdate(ctx) } - // Now we need to transform a component to populate resourceToComponent. - // Transform a component that references an external template - 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') - } + // Component resources MUST be swallowed (return []) + expect(result).toEqual([]) + }) - // Transform the component to populate internal maps - // Note: This may fail if the template/style files don't exist, but it should - // still register the resource paths in resourceToComponent during dependency resolution - try { - await plugin.transform.handler.call( - { - error() {}, - warn() {}, - } as any, - componentSource, - '/workspace/src/app/app.component.ts', - ) - } catch { - // Transform may fail because template files don't exist on disk, - // but resourceToComponent should still be populated - } + it('should return [] for component template HTML files managed by custom watcher', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + await transformComponent(plugin) - // Test handleHotUpdate with a component resource file - const componentCssFile = normalizePath('/workspace/src/app/app.component.css') - const ctx = createMockHmrContext(componentCssFile, [{ id: componentCssFile }], mockServer) + // The component's HTML template IS in resourceToComponent + const componentHtmlFile = normalizePath(templatePath) + const ctx = createMockHmrContext(componentHtmlFile, [{ id: componentHtmlFile }], mockServer) let result: ModuleNode[] | void | undefined if (typeof plugin.handleHotUpdate === 'function') { result = await plugin.handleHotUpdate(ctx) } - // Component resources SHOULD be swallowed (return []) because they're handled - // by the custom fs.watch. If the transform didn't populate resourceToComponent - // (because the files don't exist), the result might pass through - that's also - // acceptable since Vite's default handling would apply. - // The key assertion is in the first test: non-component files must NOT be swallowed. - if (result !== undefined) { - // Either empty (swallowed) or passed through - expect(Array.isArray(result)).toBe(true) - } + // Component templates MUST be swallowed (return []) + expect(result).toEqual([]) }) it('should not swallow non-resource HTML files', async () => { const plugin = getAngularPlugin() + await setupPluginWithServer(plugin) - if (typeof plugin.configResolved === 'function') { - await plugin.configResolved({ build: {}, isProduction: false } as any) - } - - // An HTML file that is NOT a component template (e.g., index.html) - const indexHtml = normalizePath('/workspace/index.html') - const ctx = createMockHmrContext(indexHtml, [{ id: indexHtml }]) + // index.html is NOT a component template + const indexHtml = normalizePath(join(tempDir, 'index.html')) + const mockModules = [{ id: indexHtml }] + const ctx = createMockHmrContext(indexHtml, mockModules) let result: ModuleNode[] | void | undefined if (typeof plugin.handleHotUpdate === 'function') { result = await plugin.handleHotUpdate(ctx) } - // Non-component HTML files should pass through + // Non-component HTML should pass through, not be swallowed if (result !== undefined) { - expect(result.length).toBeGreaterThan(0) + expect(result).toEqual(mockModules) } }) it('should pass through non-style/template files unchanged', async () => { const plugin = getAngularPlugin() + await setupPluginWithServer(plugin) - if (typeof plugin.configResolved === 'function') { - await plugin.configResolved({ build: {}, isProduction: false } as any) - } - - // A .ts file that is NOT a component - const utilFile = normalizePath('/workspace/src/utils.ts') + const utilFile = normalizePath(join(tempDir, 'src', 'utils.ts')) const mockModules = [{ id: utilFile }] const ctx = createMockHmrContext(utilFile, mockModules) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 0ceec771b..f621f7b7b 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -377,14 +377,6 @@ export function angular(options: PluginOptions = {}): Plugin[] { if (mod) { server.moduleGraph.invalidateModule(mod) } - - // Emit a synthetic change event on Vite's watcher so that other plugins - // (e.g., @tailwindcss/vite, PostCSS) are notified that this content file - // changed. Without this, tools like Tailwind won't rescan for new utility - // classes added in template files, since we unwatched them from Vite. - // Our handleHotUpdate still returns [] for component resources, preventing - // Vite from triggering a full page reload. - server.watcher.emit('change', file) } } } @@ -660,7 +652,9 @@ export function angular(options: PluginOptions = {}): Plugin[] { 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)') + debugHmr( + 'ignoring component resource file in handleHotUpdate (handled by custom watcher)', + ) return [] } debugHmr('letting non-component resource file through to Vite HMR: %s', normalizedFile) From d7e277bbf0f252253140aa27331296d5a5940eb6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 30 Mar 2026 23:13:08 +0800 Subject: [PATCH 3/5] fix: resolve oxlint type-check errors in HMR tests Use .call() with plugin context for handleHotUpdate to satisfy TS2684, and remove invalid 'type' property from mock ModuleNode to fix TS2345. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test/hmr-hot-update.test.ts | 37 ++++++++----------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index f2d4892a8..742d08992 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -99,6 +99,16 @@ function createMockHmrContext( } 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: | { @@ -182,13 +192,10 @@ describe('handleHotUpdate - Issue #185', () => { // A global CSS file (not referenced by any component's styleUrls) const globalCssFile = normalizePath(join(tempDir, 'src', 'styles.css')) - const mockModules = [{ id: globalCssFile, type: 'css' }] + const mockModules = [{ id: globalCssFile }] const ctx = createMockHmrContext(globalCssFile, mockModules) - let result: ModuleNode[] | void | undefined - if (typeof plugin.handleHotUpdate === 'function') { - result = await plugin.handleHotUpdate(ctx) - } + 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 @@ -207,10 +214,7 @@ describe('handleHotUpdate - Issue #185', () => { const mockModules = [{ id: componentCssFile }] const ctx = createMockHmrContext(componentCssFile, mockModules, mockServer) - let result: ModuleNode[] | void | undefined - if (typeof plugin.handleHotUpdate === 'function') { - result = await plugin.handleHotUpdate(ctx) - } + const result = await callHandleHotUpdate(plugin, ctx) // Component resources MUST be swallowed (return []) expect(result).toEqual([]) @@ -225,10 +229,7 @@ describe('handleHotUpdate - Issue #185', () => { const componentHtmlFile = normalizePath(templatePath) const ctx = createMockHmrContext(componentHtmlFile, [{ id: componentHtmlFile }], mockServer) - let result: ModuleNode[] | void | undefined - if (typeof plugin.handleHotUpdate === 'function') { - result = await plugin.handleHotUpdate(ctx) - } + const result = await callHandleHotUpdate(plugin, ctx) // Component templates MUST be swallowed (return []) expect(result).toEqual([]) @@ -243,10 +244,7 @@ describe('handleHotUpdate - Issue #185', () => { const mockModules = [{ id: indexHtml }] const ctx = createMockHmrContext(indexHtml, mockModules) - let result: ModuleNode[] | void | undefined - if (typeof plugin.handleHotUpdate === 'function') { - result = await plugin.handleHotUpdate(ctx) - } + const result = await callHandleHotUpdate(plugin, ctx) // Non-component HTML should pass through, not be swallowed if (result !== undefined) { @@ -262,10 +260,7 @@ describe('handleHotUpdate - Issue #185', () => { const mockModules = [{ id: utilFile }] const ctx = createMockHmrContext(utilFile, mockModules) - let result: ModuleNode[] | void | undefined - if (typeof plugin.handleHotUpdate === 'function') { - result = await plugin.handleHotUpdate(ctx) - } + const result = await callHandleHotUpdate(plugin, ctx) // Non-Angular .ts files should pass through with their modules if (result !== undefined) { From e3a9004408ce7726cb85d0e6cf3bb3164da7d9f0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 31 Mar 2026 00:55:37 +0800 Subject: [PATCH 4/5] fix: prune stale resourceToComponent entries and fix Windows CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prune old resource→component mappings before re-registering during transform, so renamed/removed templateUrl/styleUrls no longer cause handleHotUpdate to swallow updates for files that are no longer component resources. - Replace real fs.watch with no-op in tests to avoid EPERM errors on Windows when temp files are cleaned up. resourceToComponent is populated before watchFn runs, so test coverage is preserved. Co-Authored-By: Claude Opus 4.6 (1M context) --- napi/angular-compiler/test/hmr-hot-update.test.ts | 6 ++++++ napi/angular-compiler/vite-plugin/index.ts | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index 742d08992..9a34e5c99 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -154,6 +154,12 @@ async function setupPluginWithServer(plugin: Plugin) { 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 } diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index f621f7b7b..4561e3e36 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -577,6 +577,16 @@ 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. + for (const [resource, owner] of resourceToComponent) { + if (owner === actualId) { + resourceToComponent.delete(resource) + } + } + for (const dep of dependencies) { const normalizedDep = normalizePath(dep) // Track reverse mapping for HMR: resource → component From d3c7bdeea07193914017ffcac7ef621df2ab1e4a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 31 Mar 2026 08:30:24 +0800 Subject: [PATCH 5/5] fix: re-add pruned resources to Vite watcher When a component drops a resource from templateUrl/styleUrls, the file was already unwatched from Vite's chokidar watcher by the custom fs.watch setup. Pruning the resourceToComponent entry made the file invisible to both systems. Now re-add pruned files to Vite's watcher so they can flow through normal HMR if used elsewhere (e.g., as a global stylesheet). Also skip pruning resources that are still in the new dependency set. Co-Authored-By: Claude Opus 4.6 (1M context) --- napi/angular-compiler/vite-plugin/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 4561e3e36..ae3ece188 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -581,9 +581,13 @@ export function angular(options: PluginOptions = {}): Plugin[] { // 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) { + if (owner === actualId && !newDeps.has(resource)) { resourceToComponent.delete(resource) + viteServer.watcher.add(resource) } }