Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
224 changes: 224 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,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<string>()
const unwatchedFiles = new Set<string>()
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<ModuleNode>[] = [],
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)
}
})
Comment thread
cursor[bot] marked this conversation as resolved.

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)
}
})
})
29 changes: 23 additions & 6 deletions napi/angular-compiler/vite-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
Brooooooklyn marked this conversation as resolved.
Outdated
}
}
}
Expand Down Expand Up @@ -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)) {
Comment thread
Brooooooklyn marked this conversation as resolved.
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