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/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts
index fc7048aa3..36ae2f361 100644
--- a/napi/angular-compiler/test/hmr-hot-update.test.ts
+++ b/napi/angular-compiler/test/hmr-hot-update.test.ts
@@ -2,7 +2,9 @@
* 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 []
+ * 1. Component resource files (templates/styles) → dispatch component HMR and
+ * keep Vite's modules flowing (a resource can also be imported by a global
+ * stylesheet, which must still hot-update)
* 2. Non-component files (global CSS, etc.) → let Vite handle normally
*
* Previously, the plugin returned [] for ALL .css/.html files, which swallowed
@@ -591,8 +593,9 @@ describe('handleHotUpdate - Issue #185', () => {
const result = await callHandleHotUpdate(plugin, ctx)
- // Component resources MUST be swallowed (return []) and dispatch HMR.
- expect(result).toEqual([])
+ // Component HMR is dispatched, and Vite's modules are preserved for the
+ // default pipeline (e.g. a global stylesheet importing the same file).
+ expect(result).toEqual(mockModules)
expect(mockServer._wsMessages).toContainEqual(
expect.objectContaining({ type: 'custom', event: 'angular:component-update' }),
)
@@ -605,12 +608,14 @@ describe('handleHotUpdate - Issue #185', () => {
// The component's HTML template IS in resourceToComponent
const componentHtmlFile = normalizePath(templatePath)
- const ctx = createMockHmrContext(componentHtmlFile, [{ id: componentHtmlFile }], mockServer)
+ const mockModules = [{ id: componentHtmlFile }]
+ const ctx = createMockHmrContext(componentHtmlFile, mockModules, mockServer)
const result = await callHandleHotUpdate(plugin, ctx)
- // Component templates MUST be swallowed (return []) and dispatch HMR.
- expect(result).toEqual([])
+ // Component HMR is dispatched, and Vite's modules are preserved for the
+ // default pipeline.
+ expect(result).toEqual(mockModules)
expect(mockServer._wsMessages).toContainEqual(
expect.objectContaining({ type: 'custom', event: 'angular:component-update' }),
)
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..2db7faf8f
--- /dev/null
+++ b/napi/angular-compiler/test/style-deps-hmr.test.ts
@@ -0,0 +1,553 @@
+/**
+ * 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: {
+ add: vi.fn(),
+ 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, modules: ModuleNode[] = []): HmrContext {
+ return {
+ file,
+ timestamp: Date.now(),
+ modules,
+ 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)
+}
+
+function componentUpdateCount(server: any): number {
+ return server._wsMessages.filter((msg: any) => msg?.event === 'angular:component-update').length
+}
+
+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)
+ })
+
+ it('re-registers style deps when a style file switches its imports via HMR', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+
+ // Dedicated fixtures so this test never interferes with the shared ones.
+ const stylePath = join(appDir, 'switch.component.scss')
+ const firstDepPath = join(appDir, '_switch-shared.scss')
+ const secondDepPath = join(appDir, '_switch-other.scss')
+ const componentPath = join(appDir, 'switch.component.ts')
+
+ writeFileSync(firstDepPath, 'h1 { color: red; }')
+ writeFileSync(secondDepPath, 'h2 { color: green; }')
+ writeFileSync(stylePath, "@use './switch-shared';")
+ writeFileSync(componentPath, componentSource('app-switch', 'switch.component.scss'))
+
+ await transformComponent(
+ plugin,
+ componentSource('app-switch', 'switch.component.scss'),
+ componentPath,
+ )
+
+ // Sanity: the initially registered dep is tracked.
+ let result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(firstDepPath, mockServer),
+ )
+ expect(result).toEqual([])
+ expect(componentUpdateCount(mockServer)).toBe(1)
+
+ // Dev edits the style to import a different partial; HMR rebuilds it.
+ writeFileSync(stylePath, "@use './switch-other';")
+ result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(stylePath, mockServer),
+ )
+ expect(result).toEqual([])
+ expect(componentUpdateCount(mockServer)).toBe(2)
+
+ // The newly imported partial must now be registered as a dep: editing it
+ // dispatches a third update (previously it fell through to Vite).
+ result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(secondDepPath, mockServer),
+ )
+ expect(result).toEqual([])
+ expect(componentUpdateCount(mockServer)).toBe(3)
+ })
+
+ it('re-registers nested style deps when a partial switches its own imports via HMR', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+
+ // style -> partial -> nested import. Dedicated fixtures so this test never
+ // interferes with the shared ones.
+ const stylePath = join(appDir, 'nested.component.scss')
+ const partialPath = join(appDir, '_nested-a.scss')
+ const firstDepPath = join(appDir, '_nested-x.scss')
+ const secondDepPath = join(appDir, '_nested-y.scss')
+ const componentPath = join(appDir, 'nested.component.ts')
+
+ writeFileSync(firstDepPath, 'h1 { color: red; }')
+ writeFileSync(secondDepPath, 'h2 { color: green; }')
+ writeFileSync(partialPath, "@use './nested-x';")
+ writeFileSync(stylePath, "@use './nested-a';")
+ writeFileSync(componentPath, componentSource('app-nested', 'nested.component.scss'))
+
+ await transformComponent(
+ plugin,
+ componentSource('app-nested', 'nested.component.scss'),
+ componentPath,
+ )
+
+ // Sanity: the initially registered transitive dep is tracked.
+ let result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(firstDepPath, mockServer),
+ )
+ expect(result).toEqual([])
+ expect(componentUpdateCount(mockServer)).toBe(1)
+
+ // Dev edits the partial to import a different nested file; HMR rebuilds.
+ writeFileSync(partialPath, "@use './nested-y';")
+ result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(partialPath, mockServer),
+ )
+ expect(result).toEqual([])
+ expect(componentUpdateCount(mockServer)).toBe(2)
+
+ // The newly imported nested file must now be registered as a dep: editing
+ // it dispatches a third update (previously it fell through to Vite).
+ result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(secondDepPath, mockServer),
+ )
+ expect(result).toEqual([])
+ expect(componentUpdateCount(mockServer)).toBe(3)
+ })
+
+ it('dispatches HMR for a style that is both a shared dep and a direct styleUrl', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+
+ // a.component.scss imports shared.component.scss; component B references
+ // shared.component.scss directly as its styleUrl.
+ const aStylePath = join(appDir, 'a.component.scss')
+ const sharedStylePath = join(appDir, 'shared.component.scss')
+ const aComponentPath = join(appDir, 'a.component.ts')
+ const bComponentPath = join(appDir, 'b.component.ts')
+
+ writeFileSync(aStylePath, "@use './shared.component';")
+ writeFileSync(sharedStylePath, 'h1 { color: red; }')
+ writeFileSync(aComponentPath, componentSource('app-a', 'a.component.scss'))
+ writeFileSync(bComponentPath, componentSource('app-b', 'shared.component.scss'))
+
+ await transformComponent(
+ plugin,
+ componentSource('app-b', 'shared.component.scss'),
+ bComponentPath,
+ )
+ // Transform A last: its transitive dep on shared.component.scss must NOT
+ // clobber B's direct-owner mapping in resourceToComponent (which is
+ // single-owner). This order previously left B without updates.
+ await transformComponent(plugin, componentSource('app-a', 'a.component.scss'), aComponentPath)
+
+ const result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(sharedStylePath, mockServer),
+ )
+ expect(result).toEqual([])
+
+ // Both roles updated: A via the shared-dep branch, B via the
+ // direct-resource branch (previously the early return skipped B).
+ 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(aComponentPath))).toBe(true)
+ expect(updatedIds.some((id) => id.startsWith(bComponentPath))).toBe(true)
+ })
+
+ it('registers style deps with the watcher on transform', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+
+ await transformComponent(
+ plugin,
+ componentSource('app-first', 'first.component.scss'),
+ firstComponentPath,
+ )
+
+ // The style and every preprocessor dep must be added to the watcher so
+ // edits reach handleHotUpdate even outside the dev-server root. Compare
+ // canonical paths: Sass resolves deps to long names on Windows while the
+ // temp dir may carry 8.3 short names (e.g. RUNNER~1).
+ const added = mockServer.watcher.add.mock.calls
+ .flat()
+ .map((p: string) => realpathSync.native(p))
+ expect(added).toContain(realpathSync.native(join(appDir, 'first.component.scss')))
+ expect(added).toContain(realpathSync.native(sharedScssPath))
+ })
+
+ it('refreshes deps for a direct style that initially failed to preprocess', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+
+ // Dedicated fixtures so this test never interferes with the shared ones.
+ const stylePath = join(appDir, 'broken.component.scss')
+ const partialPath = join(appDir, '_broken-partial.scss')
+ const componentPath = join(appDir, 'broken.component.ts')
+
+ writeFileSync(partialPath, 'h1 { color: red; }')
+ writeFileSync(stylePath, "@use './broken-missing';")
+ writeFileSync(componentPath, componentSource('app-broken', 'broken.component.scss'))
+
+ // Initial transform: the import is missing, so preprocessing fails and no
+ // deps are registered.
+ await transformComponent(
+ plugin,
+ componentSource('app-broken', 'broken.component.scss'),
+ componentPath,
+ )
+
+ // Developer fixes the style to import an existing partial.
+ writeFileSync(stylePath, "@use './broken-partial';")
+ let result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(stylePath, mockServer),
+ )
+ expect(result).toEqual([])
+ expect(componentUpdateCount(mockServer)).toBe(1)
+
+ // The newly valid import must now be tracked: editing it dispatches.
+ result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(partialPath, mockServer),
+ )
+ expect(result).toEqual([])
+ expect(componentUpdateCount(mockServer)).toBe(2)
+ })
+
+ it('preserves Vite modules for partials also imported by global stylesheets', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+
+ await transformComponent(
+ plugin,
+ componentSource('app-first', 'first.component.scss'),
+ firstComponentPath,
+ )
+
+ // Simulate the partial also being imported by a global stylesheet, which
+ // puts that stylesheet's module in Vite's HMR context.
+ const globalModule = { id: join(appDir, 'styles.scss') } as ModuleNode
+ const ctx = createMockHmrContext(sharedScssPath, mockServer, [globalModule])
+ const result = await (plugin.handleHotUpdate as Function).call(plugin, ctx)
+
+ // Component HMR is dispatched for the owning style...
+ expect(componentUpdateCount(mockServer)).toBe(1)
+ // ...and the global stylesheet's module survives for Vite's pipeline
+ // (previously the handled branch returned [], starving it).
+ expect(result).toBe(ctx.modules)
+ expect(result).toContain(globalModule)
+ })
+
+ it('dispatches HMR to every component sharing a style that imports the changed partial', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+
+ // Both components reference the same style file directly; it imports a
+ // partial that the dev edits.
+ const sharedStylePath = join(appDir, 'multi-owner.component.scss')
+ const partialPath = join(appDir, '_multi-owner-partial.scss')
+ const firstComponentPath = join(appDir, 'multi-a.component.ts')
+ const secondComponentPath = join(appDir, 'multi-b.component.ts')
+
+ writeFileSync(partialPath, 'h1 { color: red; }')
+ writeFileSync(sharedStylePath, "@use './multi-owner-partial';")
+ writeFileSync(firstComponentPath, componentSource('app-multi-a', 'multi-owner.component.scss'))
+ writeFileSync(secondComponentPath, componentSource('app-multi-b', 'multi-owner.component.scss'))
+
+ await transformComponent(
+ plugin,
+ componentSource('app-multi-a', 'multi-owner.component.scss'),
+ firstComponentPath,
+ )
+ await transformComponent(
+ plugin,
+ componentSource('app-multi-b', 'multi-owner.component.scss'),
+ secondComponentPath,
+ )
+
+ const result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(partialPath, mockServer),
+ )
+ expect(result).toEqual([])
+
+ // Every component that uses the shared style receives an update
+ // (previously only the last-transformed owner did).
+ 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('dispatches HMR to remaining owners when one owner switches styles', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+
+ // Both components use the same root style directly.
+ const sharedStylePath = join(appDir, 'shared-root.component.scss')
+ const aComponentPath = join(appDir, 'owner-a.component.ts')
+ const bComponentPath = join(appDir, 'owner-b.component.ts')
+ const bNewStylePath = join(appDir, 'owner-b-other.component.scss')
+
+ writeFileSync(sharedStylePath, 'h1 { color: red; }')
+ writeFileSync(bNewStylePath, 'h2 { color: blue; }')
+ writeFileSync(aComponentPath, componentSource('app-owner-a', 'shared-root.component.scss'))
+ writeFileSync(bComponentPath, componentSource('app-owner-b', 'shared-root.component.scss'))
+
+ await transformComponent(
+ plugin,
+ componentSource('app-owner-a', 'shared-root.component.scss'),
+ aComponentPath,
+ )
+ await transformComponent(
+ plugin,
+ componentSource('app-owner-b', 'shared-root.component.scss'),
+ bComponentPath,
+ )
+
+ // B switches to a different style; its prune removes the single-valued
+ // resourceToComponent entry for the shared style.
+ writeFileSync(bComponentPath, componentSource('app-owner-b', 'owner-b-other.component.scss'))
+ await transformComponent(
+ plugin,
+ componentSource('app-owner-b', 'owner-b-other.component.scss'),
+ bComponentPath,
+ )
+
+ // Editing the shared root style must still update A (reachable via
+ // styleComponentOwners even though resourceToComponent no longer has it).
+ const result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(sharedStylePath, mockServer),
+ )
+ expect(result).toEqual([])
+
+ 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(aComponentPath))).toBe(true)
+ })
+
+ it('dispatches HMR for Stylus styles like other stylesheet languages', async () => {
+ const plugin = getAngularPlugin()
+ const mockServer = await setupPluginWithServer(plugin)
+
+ // A .styl styleUrl: without the `stylus` package installed preprocessing
+ // fails, but the file is still tracked as a direct style — what matters
+ // here is that the HMR branch routes non-css/scss/sass/less extensions.
+ const stylePath = join(appDir, 'styl.component.styl')
+ const componentPath = join(appDir, 'styl.component.ts')
+
+ writeFileSync(stylePath, 'h1\n color red')
+ writeFileSync(componentPath, componentSource('app-styl', 'styl.component.styl'))
+
+ await transformComponent(
+ plugin,
+ componentSource('app-styl', 'styl.component.styl'),
+ componentPath,
+ )
+
+ const result = await (plugin.handleHotUpdate as Function).call(
+ plugin,
+ createMockHmrContext(stylePath, mockServer),
+ )
+ expect(result).toEqual([])
+ expect(componentUpdateCount(mockServer)).toBe(1)
+ })
+})
diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts
index 31df1f320..8d53e9238 100644
--- a/napi/angular-compiler/vite-plugin/index.ts
+++ b/napi/angular-compiler/vite-plugin/index.ts
@@ -245,6 +245,93 @@ 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>()
+
+ // Every file used as a direct `styleUrl` of some component (normalized
+ // paths). Tracked independently of `styleDepsCache`: a direct style that
+ // fails preprocessing never gets a deps-cache entry, yet must still be
+ // refreshed (and its now-valid imports registered) once the developer fixes
+ // it. Keys are normalized so lookups from `handleHotUpdate` match on
+ // Windows, where cache keys keep the platform-native separators.
+ const directStyleUrls = new Set()
+
+ // Direct component owners of each compiled style (normalized style path →
+ // component files referencing it as a styleUrl). Unlike
+ // `resourceToComponent`, this is multi-valued: a style shared by several
+ // components must dispatch HMR to every one of them when the style or one of
+ // its preprocessor deps changes.
+ const styleComponentOwners = new Map>()
+
+ // Record the preprocessor dependencies of a compiled style and rebuild the
+ // reverse map (dep -> owning styles). Replaces any previous registration for
+ // the style, so it is safe to call again whenever the style is (re)compiled
+ // with fresh deps — the initial transform, or HMR after the style file
+ // changed its `@use`/`@import` list. The style itself is never registered as
+ // its own dependency.
+ function registerStyleDeps(stylePath: string, deps: Iterable | undefined): void {
+ const normalizedStylePath = normalizePath(stylePath)
+ const fresh = deps ? Array.from(deps, (dep) => normalizePath(dep)) : []
+
+ // Drop this style from its previously registered deps' owner sets.
+ for (const oldDep of styleDepsCache.get(normalizedStylePath) ?? []) {
+ if (oldDep === normalizedStylePath) continue
+ const owners = styleDepOwners.get(oldDep)
+ if (owners) {
+ owners.delete(normalizedStylePath)
+ if (owners.size === 0) styleDepOwners.delete(oldDep)
+ }
+ }
+
+ styleDepsCache.set(normalizedStylePath, fresh)
+
+ // Register this style as an owner of each fresh dep. Cache keys and owner
+ // values are normalized so lookups from handleHotUpdate (which receives
+ // normalized ctx.file paths) match on Windows, where path.resolve keeps
+ // backslashes.
+ for (const dep of fresh) {
+ if (dep === normalizedStylePath) continue
+ let owners = styleDepOwners.get(dep)
+ if (!owners) styleDepOwners.set(dep, (owners = new Set()))
+ owners.add(normalizedStylePath)
+ }
+ }
+
+ // Re-read and re-preprocess a style file so its dependency registration in
+ // `styleDepsCache`/`styleDepOwners` reflects the current `@use`/`@import`
+ // set. Without this, a partial added or switched via HMR would never be
+ // registered, and edits to it would not dispatch component updates until a
+ // full reload or another component transform. Best-effort: on unreadable or
+ // transiently-empty files (truncate phase of an atomic write) the previous
+ // registration is kept.
+ async function refreshStyleDeps(stylePath: string): Promise {
+ if (!resolvedConfig) return
+ let content: string
+ try {
+ content = await readFile(stylePath, 'utf-8')
+ } catch {
+ return
+ }
+ if (!content.trim()) return
+ try {
+ const processed = await preprocessCSS(content, stylePath, resolvedConfig as any)
+ registerStyleDeps(stylePath, processed.deps)
+ // A style edited via HMR can pick up new deps (possibly outside the
+ // dev-server root); register them with the watcher so their edits reach
+ // `handleHotUpdate`.
+ if (watchMode && viteServer && processed.deps) {
+ for (const dep of processed.deps) viteServer.watcher?.add?.(dep)
+ }
+ } catch (e) {
+ console.warn(`Failed to preprocess style: ${stylePath}`, e)
+ }
+ }
+
// 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
@@ -306,14 +393,15 @@ export function angular(options: PluginOptions = {}): Plugin[] {
const templatePath = resolve(dir, templateUrl)
dependencies.push(templatePath)
- let content = resourceCache.get(templatePath)
+ const normalizedTemplatePath = normalizePath(templatePath)
+ let content = resourceCache.get(normalizedTemplatePath)
if (!content) {
try {
content = await readFile(templatePath, 'utf-8')
if (options.templateTransform) {
content = options.templateTransform(content, templatePath)
}
- resourceCache.set(templatePath, content)
+ resourceCache.set(normalizedTemplatePath, content)
} catch {
console.warn(`Failed to read template: ${templatePath}`)
continue
@@ -325,9 +413,13 @@ export function angular(options: PluginOptions = {}): Plugin[] {
// Resolve styles
for (const styleUrl of styleUrls) {
const stylePath = resolve(dir, styleUrl)
+ const normalizedStylePath = normalizePath(stylePath)
+ // Register as a direct style regardless of preprocessing outcome, so the
+ // HMR refresh still runs for styles that initially failed to compile.
+ directStyleUrls.add(normalizedStylePath)
dependencies.push(stylePath)
- let content = resourceCache.get(stylePath)
+ let content = resourceCache.get(normalizedStylePath)
if (!content) {
try {
content = await readFile(stylePath, 'utf-8')
@@ -336,16 +428,28 @@ export function angular(options: PluginOptions = {}): Plugin[] {
try {
const processed = await preprocessCSS(content, stylePath, resolvedConfig as any)
content = processed.code
+ registerStyleDeps(stylePath, processed.deps)
} catch (e) {
console.warn(`Failed to preprocess style: ${stylePath}`, e)
}
}
- resourceCache.set(stylePath, content)
+ resourceCache.set(normalizedStylePath, content)
} catch {
console.warn(`Failed to read style: ${stylePath}`)
continue
}
}
+
+ // Watch each transitive dep (it may resolve outside the dev-server
+ // root, e.g. a shared monorepo package), but never register it in
+ // `resourceToComponent`: that map is single-owner per resource, and a
+ // transitive dep would clobber the direct styleUrl/templateUrl mapping
+ // of another component.
+ for (const dep of styleDepsCache.get(normalizedStylePath) ?? []) {
+ if (dep === normalizedStylePath) continue
+ if (watchMode && viteServer) viteServer.watcher?.add?.(dep)
+ }
+
styles[styleUrl] = [content]
}
@@ -650,10 +754,12 @@ export function angular(options: PluginOptions = {}): Plugin[] {
// Track dependencies for resource cache invalidation and HMR.
// We don't call addWatchFile (which would create modules in Vite's
- // graph) or maintain a custom watcher — Vite's chokidar already
- // sees these files via its normal HMR pipeline, and our
- // `handleHotUpdate` hook below dispatches based on
- // `resourceToComponent` membership.
+ // graph) or maintain a custom watcher — Vite's chokidar sees the
+ // root tree via its normal HMR pipeline, and our `handleHotUpdate`
+ // hook below dispatches based on `resourceToComponent` membership.
+ // Preprocessor deps can resolve outside the root (shared monorepo
+ // packages, configured include paths), so those are registered with
+ // the watcher explicitly below.
if (watchMode && viteServer) {
// Prune stale reverse mappings: if this component previously
// referenced different resources (e.g., templateUrl was renamed),
@@ -665,11 +771,26 @@ export function angular(options: PluginOptions = {}): Plugin[] {
resourceToComponent.delete(resource)
}
}
+ for (const [style, owners] of styleComponentOwners) {
+ if (owners.has(actualId) && !newDeps.has(style)) {
+ owners.delete(actualId)
+ if (owners.size === 0) styleComponentOwners.delete(style)
+ }
+ }
for (const dep of dependencies) {
const normalizedDep = normalizePath(dep)
// Track reverse mapping for HMR: resource → component
resourceToComponent.set(normalizedDep, actualId)
+ // Every component that uses a style directly is an owner of it.
+ if (directStyleUrls.has(normalizedDep)) {
+ let owners = styleComponentOwners.get(normalizedDep)
+ if (!owners) styleComponentOwners.set(normalizedDep, (owners = new Set()))
+ owners.add(actualId)
+ }
+ // Watch the file so edits reach `handleHotUpdate` even when it
+ // lives outside the dev-server root.
+ viteServer.watcher?.add?.(dep)
}
}
@@ -833,19 +954,74 @@ export function angular(options: PluginOptions = {}): Plugin[] {
// resources (e.g. global stylesheets in main.ts) fall through to
// Vite's default CSS HMR pipeline so PostCSS/Tailwind etc. still
// process them.
- if (/\.(html?|css|scss|sass|less)$/.test(ctx.file)) {
- if (resourceToComponent.has(normalizedFile)) {
- const componentFile = resourceToComponent.get(normalizedFile)!
- resourceCache.delete(normalizedFile)
- // 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.
- if (dispatchAllComponentsInFile(componentFile)) {
- debugHmr('external resource HMR: %s -> %s', normalizedFile, componentFile)
- return []
+ // Every Vite-supported stylesheet language (CSS, Sass/SCSS, Less,
+ // Stylus, PostCSS, SugarSS) plus HTML templates: preprocessCSS reports
+ // deps for all of them, and their partials must reach this branch.
+ if (/\.(html?|css|scss|sass|less|styl|stylus|pcss|postcss|sss)$/.test(ctx.file)) {
+ let handled = false
+ // Shared preprocessor dependency (e.g. a Sass partial): rebuild every
+ // style compiled from it and HMR each owning component.
+ if (styleDepOwners.has(normalizedFile)) {
+ // Snapshot the owners: refreshStyleDeps mutates the owner sets via
+ // registerStyleDeps, and a re-added style would otherwise be
+ // visited again during live Set iteration.
+ for (const stylePath of Array.from(styleDepOwners.get(normalizedFile)!)) {
+ resourceCache.delete(stylePath)
+ // Rebuild the owning style's dependency registration: its dep
+ // list includes the edited partial transitively, and if that
+ // partial switched a nested `@use`/`@import`, the newly loaded
+ // file must be tracked here too.
+ await refreshStyleDeps(stylePath)
+ // A style shared by several components updates every one of
+ // them (resourceToComponent is single-valued).
+ for (const owner of styleComponentOwners.get(normalizePath(stylePath)) ?? []) {
+ if (dispatchAllComponentsInFile(owner)) {
+ debugHmr('style dep HMR: %s -> %s -> %s', normalizedFile, stylePath, owner)
+ handled = true
+ }
+ }
+ }
+ }
+ // A changed file can be BOTH a shared dep of one component's style
+ // and another component's direct templateUrl/styleUrl — process both
+ // roles before returning (no early return above). Direct styles are
+ // also reachable via styleComponentOwners alone: once the last
+ // resourceToComponent owner switches styles, its prune removes the
+ // single-valued entry while the remaining shared-style owners stay.
+ if (resourceToComponent.has(normalizedFile) || styleComponentOwners.has(normalizedFile)) {
+ // Stylesheets that only appear as transitive deps of other styles
+ // (never used as a direct styleUrl) were already handled by the
+ // shared-dep branch; skip them here to avoid a duplicate update.
+ const isDirectStyle = directStyleUrls.has(normalizedFile)
+ if (!(handled && !isDirectStyle)) {
+ resourceCache.delete(normalizedFile)
+ if (isDirectStyle) {
+ // Refresh dependency registration only for actual styles —
+ // never run HTML templates through the CSS preprocessor
+ // pipeline.
+ await refreshStyleDeps(ctx.file)
+ // A style shared by several components updates every one of
+ // them (resourceToComponent is single-valued).
+ for (const owner of styleComponentOwners.get(normalizedFile) ?? []) {
+ if (dispatchAllComponentsInFile(owner)) {
+ debugHmr('external resource HMR: %s -> %s', normalizedFile, owner)
+ handled = true
+ }
+ }
+ } else {
+ const componentFile = resourceToComponent.get(normalizedFile)
+ if (componentFile && dispatchAllComponentsInFile(componentFile)) {
+ debugHmr('external resource HMR: %s -> %s', normalizedFile, componentFile)
+ handled = true
+ }
+ }
}
}
- // Not a tracked component resource — let Vite handle it.
+ // Angular HMR (component updates) has been dispatched for any
+ // tracked resources. Modules still in Vite's graph — e.g. a global
+ // stylesheet that imports the same partial — must keep flowing
+ // through Vite's default pipeline; returning [] would drop them and
+ // leave that CSS stale.
return ctx.modules
}
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