diff --git a/napi/angular-compiler/package.json b/napi/angular-compiler/package.json
index 7039d5dd6..b441d4083 100644
--- a/napi/angular-compiler/package.json
+++ b/napi/angular-compiler/package.json
@@ -75,6 +75,7 @@
"@playwright/test": "^1.58.0",
"@types/node": "catalog:",
"oxfmt": "catalog:",
+ "sass": "^1.93.2",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
diff --git a/napi/angular-compiler/test/style-deps-hmr.test.ts b/napi/angular-compiler/test/style-deps-hmr.test.ts
new file mode 100644
index 000000000..e0286c892
--- /dev/null
+++ b/napi/angular-compiler/test/style-deps-hmr.test.ts
@@ -0,0 +1,203 @@
+/**
+ * Tests HMR for transitive style dependencies.
+ *
+ * A component styleUrl compiled through a CSS preprocessor can pull in shared
+ * files (Sass partials via `@use` / `@import` / `meta.load-css`, Less imports,
+ * ...). `preprocessCSS` reports them in `deps`; the plugin must invalidate the
+ * compiled style and dispatch component HMR when one of them changes, for every
+ * component whose style is built on top of it.
+ */
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import type { Plugin, ModuleNode, HmrContext } from 'vite'
+import { resolveConfig } 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 sharedScssPath: string
+let firstComponentPath: string
+let secondComponentPath: string
+
+const componentSource = (selector: string, styleUrl: string) => `
+ import { Component } from '@angular/core';
+
+ @Component({
+ selector: '${selector}',
+ template: '
Hello
',
+ styleUrls: ['./${styleUrl}'],
+ })
+ export class AppComponent {}
+`
+
+beforeAll(() => {
+ // realpath: Sass canonicalizes loaded URLs (macOS /var -> /private/var),
+ // and watcher events use canonical paths too.
+ tempDir = realpathSync(mkdtempSync(join(tmpdir(), 'style-deps-hmr-test-')))
+ appDir = join(tempDir, 'src', 'app')
+ mkdirSync(appDir, { recursive: true })
+
+ sharedScssPath = join(appDir, '_shared.scss')
+ firstComponentPath = join(appDir, 'first.component.ts')
+ secondComponentPath = join(appDir, 'second.component.ts')
+
+ writeFileSync(sharedScssPath, 'h1 { color: red; }')
+ writeFileSync(join(appDir, 'first.component.scss'), "@use './shared';")
+ writeFileSync(join(appDir, 'second.component.scss'), "@use './shared';")
+ writeFileSync(firstComponentPath, componentSource('app-first', 'first.component.scss'))
+ writeFileSync(secondComponentPath, componentSource('app-second', 'second.component.scss'))
+})
+
+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[] = []
+
+ return {
+ watcher: {
+ unwatch: vi.fn(),
+ 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,
+ }
+}
+
+function createMockHmrContext(file: string, server: any): HmrContext {
+ return {
+ file,
+ timestamp: Date.now(),
+ modules: [{ id: file } as ModuleNode],
+ read: async () => '',
+ server,
+ } 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)
+}
+
+async function setupPluginWithServer(plugin: Plugin) {
+ const mockServer = createMockServer()
+
+ await callPluginHook(
+ plugin.config as Plugin['config'],
+ {} as any,
+ {
+ command: 'serve',
+ mode: 'development',
+ } as any,
+ )
+
+ // A real resolved config: preprocessCSS needs one to run Sass and report
+ // the partials it loaded in `deps`.
+ const resolved = await resolveConfig(
+ { configFile: false, root: tempDir, logLevel: 'silent' },
+ 'serve',
+ )
+ await callPluginHook(plugin.configResolved as Plugin['configResolved'], resolved as any)
+
+ if (typeof plugin.configureServer === 'function') {
+ await (plugin.configureServer as Function)(mockServer)
+ }
+
+ ;(mockServer as any).__angularWatchTemplate = () => {}
+
+ return mockServer
+}
+
+async function transformComponent(plugin: Plugin, source: string, path: string) {
+ if (!plugin.transform || typeof plugin.transform === 'function') {
+ throw new Error('Expected plugin transform handler')
+ }
+
+ await plugin.transform.handler.call({ error() {}, warn() {} } as any, source, path)
+}
+
+describe('handleHotUpdate for transitive style dependencies', () => {
+ it('dispatches HMR to every component whose style uses a changed Sass partial', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+
+ await transformComponent(
+ plugin,
+ componentSource('app-first', 'first.component.scss'),
+ firstComponentPath,
+ )
+ await transformComponent(
+ plugin,
+ componentSource('app-second', 'second.component.scss'),
+ secondComponentPath,
+ )
+
+ const ctx = createMockHmrContext(sharedScssPath, mockServer)
+ const result = await (plugin.handleHotUpdate as Function).call(plugin, ctx)
+
+ // Handled by the plugin: no modules left for Vite's default pipeline.
+ expect(result).toEqual([])
+
+ // Both owning components received a component-update event.
+ const updates = mockServer._wsMessages.filter(
+ (msg) => msg?.event === 'angular:component-update',
+ )
+ const updatedIds = updates.map((msg) => decodeURIComponent(msg.data.id))
+ expect(updatedIds.some((id) => id.startsWith(firstComponentPath))).toBe(true)
+ expect(updatedIds.some((id) => id.startsWith(secondComponentPath))).toBe(true)
+ })
+
+ it('leaves untracked stylesheets to Vite', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+
+ const untracked = join(appDir, 'not-a-dep.scss')
+ writeFileSync(untracked, 'h2 { color: blue; }')
+ const ctx = createMockHmrContext(untracked, mockServer)
+ const result = await (plugin.handleHotUpdate as Function).call(plugin, ctx)
+
+ expect(result).toBe(ctx.modules)
+ })
+})
diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts
index 31df1f320..ae54d182a 100644
--- a/napi/angular-compiler/vite-plugin/index.ts
+++ b/napi/angular-compiler/vite-plugin/index.ts
@@ -245,6 +245,14 @@ export function angular(options: PluginOptions = {}): Plugin[] {
// Cache for resolved resources
const resourceCache = new Map()
+ // Preprocessor dependencies of each compiled style (Sass partials pulled in
+ // through `@use`/`@import`/`meta.load-css`, Less imports, ...), plus the
+ // reverse map from each dependency to the styles compiled from it, so that
+ // editing a shared partial invalidates and re-dispatches every component
+ // style built on top of it.
+ const styleDepsCache = new Map()
+ const styleDepOwners = new Map>()
+
// Component IDs (`filePath@ClassName`) queued for HMR delivery. Populated by
// `handleHotUpdate` when an external resource or inline template/style change
// is detected, and consumed by the `@ng/component` HTTP endpoint, which reads
@@ -336,6 +344,10 @@ export function angular(options: PluginOptions = {}): Plugin[] {
try {
const processed = await preprocessCSS(content, stylePath, resolvedConfig as any)
content = processed.code
+ styleDepsCache.set(
+ stylePath,
+ processed.deps ? Array.from(processed.deps, (dep) => normalizePath(dep)) : [],
+ )
} catch (e) {
console.warn(`Failed to preprocess style: ${stylePath}`, e)
}
@@ -346,6 +358,16 @@ export function angular(options: PluginOptions = {}): Plugin[] {
continue
}
}
+
+ const normalizedStylePath = normalizePath(stylePath)
+ for (const dep of styleDepsCache.get(stylePath) ?? []) {
+ if (dep === normalizedStylePath) continue
+ dependencies.push(dep)
+ let owners = styleDepOwners.get(dep)
+ if (!owners) styleDepOwners.set(dep, (owners = new Set()))
+ owners.add(stylePath)
+ }
+
styles[styleUrl] = [content]
}
@@ -834,9 +856,27 @@ export function angular(options: PluginOptions = {}): Plugin[] {
// Vite's default CSS HMR pipeline so PostCSS/Tailwind etc. still
// process them.
if (/\.(html?|css|scss|sass|less)$/.test(ctx.file)) {
+ // Shared preprocessor dependency (e.g. a Sass partial): rebuild every
+ // style compiled from it and HMR each owning component.
+ if (styleDepOwners.has(normalizedFile)) {
+ let handled = false
+ for (const stylePath of styleDepOwners.get(normalizedFile)!) {
+ resourceCache.delete(stylePath)
+ styleDepsCache.delete(stylePath)
+ const componentFile = resourceToComponent.get(normalizePath(stylePath))
+ if (componentFile && dispatchAllComponentsInFile(componentFile)) {
+ debugHmr('style dep HMR: %s -> %s -> %s', normalizedFile, stylePath, componentFile)
+ handled = true
+ }
+ }
+ if (handled) {
+ return []
+ }
+ }
if (resourceToComponent.has(normalizedFile)) {
const componentFile = resourceToComponent.get(normalizedFile)!
resourceCache.delete(normalizedFile)
+ styleDepsCache.delete(ctx.file)
// resourceToComponent only tracks one owner per resource; if a
// templateUrl/styleUrl is shared across multiple components in
// the same file, only the registered owner receives HMR.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0fd9c8b37..84eb3e32c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -90,6 +90,9 @@ importers:
oxfmt:
specifier: 'catalog:'
version: 0.60.0
+ sass:
+ specifier: ^1.93.2
+ version: 1.101.7
typescript:
specifier: 'catalog:'
version: 6.0.3