Skip to content

Commit f9c0863

Browse files
Brooooooklynclaude
andauthored
fix(vite): stop swallowing HMR updates for non-component resources (#197)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * fix: prune stale resourceToComponent entries and fix Windows CI - 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent db8978f commit f9c0863

File tree

2 files changed

+307
-6
lines changed

2 files changed

+307
-6
lines changed
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/**
2+
* Tests for handleHotUpdate behavior (Issue #185).
3+
*
4+
* The plugin's handleHotUpdate hook must distinguish between:
5+
* 1. Component resource files (templates/styles) → handled by custom fs.watch, return []
6+
* 2. Non-component files (global CSS, etc.) → let Vite handle normally
7+
*
8+
* Previously, the plugin returned [] for ALL .css/.html files, which swallowed
9+
* HMR updates for global stylesheets and prevented PostCSS/Tailwind from
10+
* processing changes.
11+
*/
12+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
13+
import { tmpdir } from 'node:os'
14+
import { join } from 'node:path'
15+
16+
import type { Plugin, ModuleNode, HmrContext } from 'vite'
17+
import { normalizePath } from 'vite'
18+
import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest'
19+
20+
import { angular } from '../vite-plugin/index.js'
21+
22+
let tempDir: string
23+
let appDir: string
24+
let templatePath: string
25+
let stylePath: string
26+
27+
beforeAll(() => {
28+
tempDir = mkdtempSync(join(tmpdir(), 'hmr-test-'))
29+
appDir = join(tempDir, 'src', 'app')
30+
mkdirSync(appDir, { recursive: true })
31+
32+
templatePath = join(appDir, 'app.component.html')
33+
stylePath = join(appDir, 'app.component.css')
34+
35+
writeFileSync(templatePath, '<h1>Hello</h1>')
36+
writeFileSync(stylePath, 'h1 { color: red; }')
37+
})
38+
39+
afterAll(() => {
40+
rmSync(tempDir, { recursive: true, force: true })
41+
})
42+
43+
function getAngularPlugin() {
44+
const plugin = angular({ liveReload: true }).find(
45+
(candidate) => candidate.name === '@oxc-angular/vite',
46+
)
47+
48+
if (!plugin) {
49+
throw new Error('Failed to find @oxc-angular/vite plugin')
50+
}
51+
52+
return plugin
53+
}
54+
55+
function createMockServer() {
56+
const wsMessages: any[] = []
57+
const unwatchedFiles = new Set<string>()
58+
59+
return {
60+
watcher: {
61+
unwatch(file: string) {
62+
unwatchedFiles.add(file)
63+
},
64+
on: vi.fn(),
65+
emit: vi.fn(),
66+
},
67+
ws: {
68+
send(msg: any) {
69+
wsMessages.push(msg)
70+
},
71+
on: vi.fn(),
72+
},
73+
moduleGraph: {
74+
getModuleById: vi.fn(() => null),
75+
invalidateModule: vi.fn(),
76+
},
77+
middlewares: {
78+
use: vi.fn(),
79+
},
80+
config: {
81+
root: tempDir,
82+
},
83+
_wsMessages: wsMessages,
84+
_unwatchedFiles: unwatchedFiles,
85+
}
86+
}
87+
88+
function createMockHmrContext(
89+
file: string,
90+
modules: Partial<ModuleNode>[] = [],
91+
server?: any,
92+
): HmrContext {
93+
return {
94+
file,
95+
timestamp: Date.now(),
96+
modules: modules as ModuleNode[],
97+
read: async () => '',
98+
server: server ?? createMockServer(),
99+
} as HmrContext
100+
}
101+
102+
async function callHandleHotUpdate(
103+
plugin: Plugin,
104+
ctx: HmrContext,
105+
): Promise<ModuleNode[] | void | undefined> {
106+
if (typeof plugin.handleHotUpdate === 'function') {
107+
return (plugin.handleHotUpdate as Function).call(plugin, ctx)
108+
}
109+
return undefined
110+
}
111+
112+
async function callPluginHook<TArgs extends unknown[], TResult>(
113+
hook:
114+
| {
115+
handler: (...args: TArgs) => TResult
116+
}
117+
| ((...args: TArgs) => TResult)
118+
| undefined,
119+
...args: TArgs
120+
): Promise<TResult | undefined> {
121+
if (!hook) return undefined
122+
if (typeof hook === 'function') return hook(...args)
123+
return hook.handler(...args)
124+
}
125+
126+
/**
127+
* Set up a plugin through the full Vite lifecycle so that internal state
128+
* (watchMode, viteServer, resourceToComponent, componentIds) is populated.
129+
*/
130+
async function setupPluginWithServer(plugin: Plugin) {
131+
const mockServer = createMockServer()
132+
133+
// config() sets watchMode = true when command === 'serve'
134+
await callPluginHook(
135+
plugin.config as Plugin['config'],
136+
{} as any,
137+
{
138+
command: 'serve',
139+
mode: 'development',
140+
} as any,
141+
)
142+
143+
// configResolved() stores the resolved config
144+
await callPluginHook(
145+
plugin.configResolved as Plugin['configResolved'],
146+
{
147+
build: {},
148+
isProduction: false,
149+
} as any,
150+
)
151+
152+
// configureServer() sets up the custom watcher and stores viteServer
153+
if (typeof plugin.configureServer === 'function') {
154+
await (plugin.configureServer as Function)(mockServer)
155+
}
156+
157+
// Replace the real fs.watch-based watcher with a no-op to avoid EPERM
158+
// errors on Windows when temp files are cleaned up. resourceToComponent
159+
// is populated in transform *before* watchFn is called, so the map is
160+
// still correctly populated for handleHotUpdate tests.
161+
;(mockServer as any).__angularWatchTemplate = () => {}
162+
163+
return mockServer
164+
}
165+
166+
/**
167+
* Transform a component that references external template + style files,
168+
* populating resourceToComponent and componentIds.
169+
*/
170+
async function transformComponent(plugin: Plugin) {
171+
const componentFile = join(appDir, 'app.component.ts')
172+
const componentSource = `
173+
import { Component } from '@angular/core';
174+
175+
@Component({
176+
selector: 'app-root',
177+
templateUrl: './app.component.html',
178+
styleUrls: ['./app.component.css'],
179+
})
180+
export class AppComponent {}
181+
`
182+
183+
if (!plugin.transform || typeof plugin.transform === 'function') {
184+
throw new Error('Expected plugin transform handler')
185+
}
186+
187+
await plugin.transform.handler.call(
188+
{ error() {}, warn() {} } as any,
189+
componentSource,
190+
componentFile,
191+
)
192+
}
193+
194+
describe('handleHotUpdate - Issue #185', () => {
195+
it('should let non-component CSS files pass through to Vite HMR', async () => {
196+
const plugin = getAngularPlugin()
197+
await setupPluginWithServer(plugin)
198+
199+
// A global CSS file (not referenced by any component's styleUrls)
200+
const globalCssFile = normalizePath(join(tempDir, 'src', 'styles.css'))
201+
const mockModules = [{ id: globalCssFile }]
202+
const ctx = createMockHmrContext(globalCssFile, mockModules)
203+
204+
const result = await callHandleHotUpdate(plugin, ctx)
205+
206+
// Non-component CSS should NOT be swallowed — either undefined (pass through)
207+
// or the original modules array, but NOT an empty array
208+
if (result !== undefined) {
209+
expect(result).toEqual(mockModules)
210+
}
211+
})
212+
213+
it('should return [] for component CSS files managed by custom watcher', async () => {
214+
const plugin = getAngularPlugin()
215+
const mockServer = await setupPluginWithServer(plugin)
216+
await transformComponent(plugin)
217+
218+
// The component's CSS file IS in resourceToComponent
219+
const componentCssFile = normalizePath(stylePath)
220+
const mockModules = [{ id: componentCssFile }]
221+
const ctx = createMockHmrContext(componentCssFile, mockModules, mockServer)
222+
223+
const result = await callHandleHotUpdate(plugin, ctx)
224+
225+
// Component resources MUST be swallowed (return [])
226+
expect(result).toEqual([])
227+
})
228+
229+
it('should return [] for component template HTML files managed by custom watcher', async () => {
230+
const plugin = getAngularPlugin()
231+
const mockServer = await setupPluginWithServer(plugin)
232+
await transformComponent(plugin)
233+
234+
// The component's HTML template IS in resourceToComponent
235+
const componentHtmlFile = normalizePath(templatePath)
236+
const ctx = createMockHmrContext(componentHtmlFile, [{ id: componentHtmlFile }], mockServer)
237+
238+
const result = await callHandleHotUpdate(plugin, ctx)
239+
240+
// Component templates MUST be swallowed (return [])
241+
expect(result).toEqual([])
242+
})
243+
244+
it('should not swallow non-resource HTML files', async () => {
245+
const plugin = getAngularPlugin()
246+
await setupPluginWithServer(plugin)
247+
248+
// index.html is NOT a component template
249+
const indexHtml = normalizePath(join(tempDir, 'index.html'))
250+
const mockModules = [{ id: indexHtml }]
251+
const ctx = createMockHmrContext(indexHtml, mockModules)
252+
253+
const result = await callHandleHotUpdate(plugin, ctx)
254+
255+
// Non-component HTML should pass through, not be swallowed
256+
if (result !== undefined) {
257+
expect(result).toEqual(mockModules)
258+
}
259+
})
260+
261+
it('should pass through non-style/template files unchanged', async () => {
262+
const plugin = getAngularPlugin()
263+
await setupPluginWithServer(plugin)
264+
265+
const utilFile = normalizePath(join(tempDir, 'src', 'utils.ts'))
266+
const mockModules = [{ id: utilFile }]
267+
const ctx = createMockHmrContext(utilFile, mockModules)
268+
269+
const result = await callHandleHotUpdate(plugin, ctx)
270+
271+
// Non-Angular .ts files should pass through with their modules
272+
if (result !== undefined) {
273+
expect(result).toEqual(mockModules)
274+
}
275+
})
276+
})

napi/angular-compiler/vite-plugin/index.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,20 @@ export function angular(options: PluginOptions = {}): Plugin[] {
577577
// gated by componentIds, which are only populated for client transforms.
578578
if (watchMode && viteServer) {
579579
const watchFn = (viteServer as any).__angularWatchTemplate
580+
581+
// Prune stale entries: if this component previously referenced
582+
// different resources (e.g., templateUrl was renamed), remove the
583+
// old reverse mappings so handleHotUpdate no longer swallows those files.
584+
// Re-add pruned files to Vite's watcher so they can be processed as
585+
// normal assets if used elsewhere (e.g., as a global stylesheet).
586+
const newDeps = new Set(dependencies.map(normalizePath))
587+
for (const [resource, owner] of resourceToComponent) {
588+
if (owner === actualId && !newDeps.has(resource)) {
589+
resourceToComponent.delete(resource)
590+
viteServer.watcher.add(resource)
591+
}
592+
}
593+
580594
for (const dep of dependencies) {
581595
const normalizedDep = normalizePath(dep)
582596
// Track reverse mapping for HMR: resource → component
@@ -640,13 +654,24 @@ export function angular(options: PluginOptions = {}): Plugin[] {
640654
ctx.modules.map((m) => m.id).join(', '),
641655
)
642656

643-
// Template/style files are handled by our custom fs.watch in configureServer.
644-
// We dynamically unwatch them from Vite's watcher during transform, so they shouldn't
645-
// normally trigger handleHotUpdate. If they do appear here (e.g., file not yet transformed
646-
// or from another plugin), return [] to prevent Vite's default handling.
657+
// Component resource files (templates/styles referenced via templateUrl/styleUrls)
658+
// are handled by our custom fs.watch in configureServer. We dynamically unwatch them
659+
// from Vite's watcher during transform, so they shouldn't normally trigger handleHotUpdate.
660+
// If they do appear here (e.g., file not yet transformed or from another plugin),
661+
// return [] to prevent Vite's default handling.
662+
//
663+
// However, non-component files (e.g., global stylesheets imported in main.ts) are NOT
664+
// managed by our custom watcher and must flow through Vite's normal HMR pipeline so that
665+
// PostCSS/Tailwind and other plugins can process them correctly.
647666
if (/\.(html?|css|scss|sass|less)$/.test(ctx.file)) {
648-
debugHmr('ignoring resource file in handleHotUpdate (handled by custom watcher)')
649-
return []
667+
const normalizedFile = normalizePath(ctx.file)
668+
if (resourceToComponent.has(normalizedFile)) {
669+
debugHmr(
670+
'ignoring component resource file in handleHotUpdate (handled by custom watcher)',
671+
)
672+
return []
673+
}
674+
debugHmr('letting non-component resource file through to Vite HMR: %s', normalizedFile)
650675
}
651676

652677
// Handle component file changes

0 commit comments

Comments
 (0)