Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 276 additions & 0 deletions napi/angular-compiler/test/hmr-hot-update.test.ts
Original file line number Diff line number Diff line change
@@ -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, '<h1>Hello</h1>')
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<string>()

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<ModuleNode>[] = [],
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<ModuleNode[] | void | undefined> {
if (typeof plugin.handleHotUpdate === 'function') {
return (plugin.handleHotUpdate as Function).call(plugin, ctx)
}
return undefined
}

async function callPluginHook<TArgs extends unknown[], TResult>(
hook:
| {
handler: (...args: TArgs) => TResult
}
| ((...args: TArgs) => TResult)
| undefined,
...args: TArgs
): Promise<TResult | undefined> {
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)
}
})
})
37 changes: 31 additions & 6 deletions napi/angular-compiler/vite-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading