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