From 0850aeaff7df0265708cb42cfe498dc8ed859a71 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 3 Aug 2026 23:50:15 +0200 Subject: [PATCH 01/11] fix(vite): rebuild component styles when a transitive preprocessor dependency changes resolveResources runs each styleUrl through Vite's preprocessCSS but discarded the deps it reports (Sass partials pulled in through @use, @import or meta.load-css, Less imports, ...). Editing such a shared partial therefore never invalidated the compiled style nor dispatched component HMR: the dev server kept serving the stale CSS until the styleUrl file itself was touched. Track the reported deps per compiled style, map each dep back to every style built on top of it, and on a hot update of a dep invalidate those styles and dispatch HMR for each owning component. A dep shared by several components (a design-system partial, typically) updates all of them, not just the last registered owner. --- napi/angular-compiler/package.json | 1 + .../test/style-deps-hmr.test.ts | 203 ++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 40 ++++ pnpm-lock.yaml | 3 + 4 files changed, 247 insertions(+) create mode 100644 napi/angular-compiler/test/style-deps-hmr.test.ts 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 From 5fbb8bb5a1ccabfa7f7842fcd700ac3ebf8849f4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 10 Aug 2026 22:34:38 +0800 Subject: [PATCH 02/11] fix(vite): re-register style deps when a style switches imports via HMR When a component stylesheet was edited so its `@use`/`@import` list changed, handleHotUpdate dropped the style's cached dependency list but nothing re-recorded the fresh processed.deps: the `@ng/component` HMR endpoint recompiles the CSS without touching the dep maps, and returning [] skips the component transform. The newly imported partial was therefore never registered in styleDepOwners, so subsequent edits to it produced no component update until a full reload or another transform. Refactor dep registration into registerStyleDeps (idempotent: prunes stale owner entries and records fresh ones) and use it from both the initial transform and a new refreshStyleDeps in handleHotUpdate's style branch, which re-reads and re-preprocesses the edited style before dispatching HMR. Adds a regression test that switches a style's Sass `@use` import via HMR and asserts the newly imported partial then dispatches component updates. --- .../test/style-deps-hmr.test.ts | 52 ++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 71 ++++++++++++++++--- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/napi/angular-compiler/test/style-deps-hmr.test.ts b/napi/angular-compiler/test/style-deps-hmr.test.ts index e0286c892..755089d7c 100644 --- a/napi/angular-compiler/test/style-deps-hmr.test.ts +++ b/napi/angular-compiler/test/style-deps-hmr.test.ts @@ -158,6 +158,10 @@ async function transformComponent(plugin: Plugin, source: string, path: string) 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() @@ -200,4 +204,52 @@ describe('handleHotUpdate for transitive style dependencies', () => { 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) + }) }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index ae54d182a..9ea0a1635 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -253,6 +253,61 @@ export function angular(options: PluginOptions = {}): Plugin[] { const styleDepsCache = new Map() const styleDepOwners = 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(stylePath) ?? []) { + if (oldDep === normalizedStylePath) continue + const owners = styleDepOwners.get(oldDep) + if (owners) { + owners.delete(stylePath) + if (owners.size === 0) styleDepOwners.delete(oldDep) + } + } + + styleDepsCache.set(stylePath, fresh) + + // Register this style as an owner of each fresh dep. + for (const dep of fresh) { + if (dep === normalizedStylePath) continue + let owners = styleDepOwners.get(dep) + if (!owners) styleDepOwners.set(dep, (owners = new Set())) + owners.add(stylePath) + } + } + + // 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) + } 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 @@ -344,10 +399,7 @@ 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)) : [], - ) + registerStyleDeps(stylePath, processed.deps) } catch (e) { console.warn(`Failed to preprocess style: ${stylePath}`, e) } @@ -359,13 +411,13 @@ export function angular(options: PluginOptions = {}): Plugin[] { } } + // Re-register each dep in `dependencies` (owner registration is already + // handled by `registerStyleDeps`), so the transform's resource tracking + // and prune loop see them. 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] @@ -876,7 +928,10 @@ export function angular(options: PluginOptions = {}): Plugin[] { if (resourceToComponent.has(normalizedFile)) { const componentFile = resourceToComponent.get(normalizedFile)! resourceCache.delete(normalizedFile) - styleDepsCache.delete(ctx.file) + // Re-read the style and refresh its dependency registration so a + // newly added / switched `@use`/`@import` is tracked: otherwise + // edits to the new partial would not dispatch component HMR. + await refreshStyleDeps(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. From 0ff38e50a9d6beefcb3899f7b31d457446dfa85e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 10 Aug 2026 22:48:17 +0800 Subject: [PATCH 03/11] fix(vite): refresh owning style deps when a partial switches nested imports The shared-dependency branch of handleHotUpdate dropped each owning style's cached dependency list when an edited partial changed, but did not re-register the fresh processed.deps the way the direct-resource branch now does. If the partial itself switched a nested `@use`/`@import`, the newly loaded nested file never entered styleDepOwners, so its next edit fell through to Vite and produced no component update. Call refreshStyleDeps for each owning style during the rebuild, iterating over a snapshot of the owners: refreshStyleDeps mutates the owner sets via registerStyleDeps, and a re-added style would otherwise be visited again during live Set iteration. Adds a regression test where a partial switches its own nested Sass `@use` via HMR and asserts the newly imported nested file then dispatches component updates. --- .../test/style-deps-hmr.test.ts | 51 +++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 11 +++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/napi/angular-compiler/test/style-deps-hmr.test.ts b/napi/angular-compiler/test/style-deps-hmr.test.ts index 755089d7c..c5d442773 100644 --- a/napi/angular-compiler/test/style-deps-hmr.test.ts +++ b/napi/angular-compiler/test/style-deps-hmr.test.ts @@ -252,4 +252,55 @@ describe('handleHotUpdate for transitive style dependencies', () => { 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) + }) }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 9ea0a1635..5e2cec118 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -912,9 +912,16 @@ export function angular(options: PluginOptions = {}): Plugin[] { // style compiled from it and HMR each owning component. if (styleDepOwners.has(normalizedFile)) { let handled = false - for (const stylePath of styleDepOwners.get(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) - styleDepsCache.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) const componentFile = resourceToComponent.get(normalizePath(stylePath)) if (componentFile && dispatchAllComponentsInFile(componentFile)) { debugHmr('style dep HMR: %s -> %s -> %s', normalizedFile, stylePath, componentFile) From 134986a55d69d2caed39e0b9b77abcc35c6c1dcd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 10 Aug 2026 22:55:51 +0800 Subject: [PATCH 04/11] fix(vite): handle styles that are both deps and direct resources; guard refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related gaps in the external-resource HMR branch: - A changed file can be both a shared preprocessor dep of one component's style and another component's direct templateUrl/styleUrl. The shared-dep branch returned early after updating the importing styles, so the direct owner's compiled-CSS cache was never dropped and no update was dispatched for it — its rendered CSS stayed stale. Process both roles before returning: fall through to the direct-resource branch and skip only files that are purely transitive deps (already handled, avoiding a duplicate update for the same component). - refreshStyleDeps ran unconditionally in the direct-resource branch, feeding HTML template contents and filenames to preprocessCSS. With PostCSS plugins configured this runs the CSS pipeline on HTML during template HMR (parse errors or side-effectful processing). Guard the refresh so it runs only for actual styles (files compiled as styles during a transform). Adds a regression test where shared.component.scss is both imported by component A and used directly by component B, asserting both receive component-update events when it changes. --- .../test/style-deps-hmr.test.ts | 41 +++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 40 +++++++++++------- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/napi/angular-compiler/test/style-deps-hmr.test.ts b/napi/angular-compiler/test/style-deps-hmr.test.ts index c5d442773..c2fef8be1 100644 --- a/napi/angular-compiler/test/style-deps-hmr.test.ts +++ b/napi/angular-compiler/test/style-deps-hmr.test.ts @@ -303,4 +303,45 @@ describe('handleHotUpdate for transitive style dependencies', () => { 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-a', 'a.component.scss'), aComponentPath) + // Transform B last so resourceToComponent[shared] maps to B (the direct + // styleUrl owner) rather than A (which only imports it). + await transformComponent( + plugin, + componentSource('app-b', 'shared.component.scss'), + bComponentPath, + ) + + 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) + }) }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 5e2cec118..cf6cf0ff9 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -908,10 +908,10 @@ 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)) { + 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)) { - let handled = false // Snapshot the owners: refreshStyleDeps mutates the owner sets via // registerStyleDeps, and a re-added style would otherwise be // visited again during live Set iteration. @@ -928,25 +928,35 @@ export function angular(options: PluginOptions = {}): Plugin[] { handled = true } } - if (handled) { - return [] - } } + // 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). if (resourceToComponent.has(normalizedFile)) { const componentFile = resourceToComponent.get(normalizedFile)! - resourceCache.delete(normalizedFile) - // Re-read the style and refresh its dependency registration so a - // newly added / switched `@use`/`@import` is tracked: otherwise - // edits to the new partial would not dispatch component HMR. - await refreshStyleDeps(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. - if (dispatchAllComponentsInFile(componentFile)) { - debugHmr('external resource HMR: %s -> %s', normalizedFile, componentFile) - return [] + // Stylesheets that only appear as transitive deps of other styles + // (never compiled as styles themselves) were already handled by the + // shared-dep branch; skip them here to avoid a duplicate update. + const isDirectStyle = styleDepsCache.has(normalizedFile) + if (!(handled && !isDirectStyle)) { + resourceCache.delete(normalizedFile) + // Refresh dependency registration only for actual styles — never + // run HTML templates through the CSS preprocessor pipeline. + if (isDirectStyle) { + await refreshStyleDeps(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. + if (dispatchAllComponentsInFile(componentFile)) { + debugHmr('external resource HMR: %s -> %s', normalizedFile, componentFile) + handled = true + } } } + if (handled) { + return [] + } // Not a tracked component resource — let Vite handle it. return ctx.modules } From 6d5a757d3b4441562dd2cca6dda14a6aff95f9b9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 10 Aug 2026 23:06:15 +0800 Subject: [PATCH 05/11] fix(vite): watch discovered style deps; track direct styles independently Two gaps in style-dependency HMR: - Vite's watcher only observes the dev-server root (plus config/env files and publicDir). Preprocessor deps that resolve outside it (shared monorepo packages, configured include paths) were added to resourceToComponent but never to the watcher, so their edits never reached handleHotUpdate and the component CSS stayed stale. Register every discovered dependency (and any dep picked up by an HMR-driven style refresh) with the watcher via server.watcher.add. - The direct-style marker used styleDepsCache presence, which is absent for a direct styleUrl whose initial preprocessing failed (e.g. a temporarily missing import), so fixing the style refreshed nothing and the now-valid imports were never registered. It also missed on Windows, where cache keys keep native separators while handleHotUpdate lookups are normalized. Track direct styleUrls in a dedicated normalized set that is populated regardless of preprocessing outcome. Adds regression tests: deps registered with the watcher on transform, and a broken-then-fixed direct style whose imports become trackable. --- .../test/style-deps-hmr.test.ts | 57 +++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 34 +++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/napi/angular-compiler/test/style-deps-hmr.test.ts b/napi/angular-compiler/test/style-deps-hmr.test.ts index c2fef8be1..09bd8c55c 100644 --- a/napi/angular-compiler/test/style-deps-hmr.test.ts +++ b/napi/angular-compiler/test/style-deps-hmr.test.ts @@ -73,6 +73,7 @@ function createMockServer() { return { watcher: { + add: vi.fn(), unwatch: vi.fn(), on: vi.fn(), emit: vi.fn(), @@ -344,4 +345,60 @@ describe('handleHotUpdate for transitive style dependencies', () => { 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. + const added = mockServer.watcher.add.mock.calls.flat() + expect(added).toContain(join(appDir, 'first.component.scss')) + expect(added).toContain(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) + }) }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index cf6cf0ff9..2aa37d5f6 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -253,6 +253,14 @@ export function angular(options: PluginOptions = {}): Plugin[] { 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() + // 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 @@ -303,6 +311,12 @@ export function angular(options: PluginOptions = {}): Plugin[] { 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) } @@ -388,6 +402,9 @@ export function angular(options: PluginOptions = {}): Plugin[] { // Resolve styles for (const styleUrl of styleUrls) { const stylePath = resolve(dir, styleUrl) + // Register as a direct style regardless of preprocessing outcome, so the + // HMR refresh still runs for styles that initially failed to compile. + directStyleUrls.add(normalizePath(stylePath)) dependencies.push(stylePath) let content = resourceCache.get(stylePath) @@ -724,10 +741,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), @@ -744,6 +763,9 @@ export function angular(options: PluginOptions = {}): Plugin[] { const normalizedDep = normalizePath(dep) // Track reverse mapping for HMR: resource → component resourceToComponent.set(normalizedDep, actualId) + // Watch the file so edits reach `handleHotUpdate` even when it + // lives outside the dev-server root. + viteServer.watcher?.add?.(dep) } } @@ -935,9 +957,9 @@ export function angular(options: PluginOptions = {}): Plugin[] { if (resourceToComponent.has(normalizedFile)) { const componentFile = resourceToComponent.get(normalizedFile)! // Stylesheets that only appear as transitive deps of other styles - // (never compiled as styles themselves) were already handled by the + // (never used as a direct styleUrl) were already handled by the // shared-dep branch; skip them here to avoid a duplicate update. - const isDirectStyle = styleDepsCache.has(normalizedFile) + const isDirectStyle = directStyleUrls.has(normalizedFile) if (!(handled && !isDirectStyle)) { resourceCache.delete(normalizedFile) // Refresh dependency registration only for actual styles — never From ebbd4ba5a0d19d9517f8523971bfc9713d6ffb67 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 10 Aug 2026 23:16:37 +0800 Subject: [PATCH 06/11] fix(vite): keep direct-owner mapping; canonicalize watcher test paths - Transitive style deps were written into resourceToComponent, which is single-owner per resource. A dep of one component's style could therefore overwrite another component's direct styleUrl mapping (and a later prune pass could delete it outright), leaving the direct owner without updates when the shared file changed. Watch the deps but never register them as owners; the direct-owner test now transforms the importing component last, the order that previously reproduced the bug. - The watcher regression test compared raw paths, which differ on Windows CI: the temp dir uses 8.3 short names (RUNNER~1) while Sass canonicalizes loaded URLs to long names. Compare realpath-canonicalized paths instead. --- .../test/style-deps-hmr.test.ts | 19 ++++++++++++------- napi/angular-compiler/vite-plugin/index.ts | 10 ++++++---- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/napi/angular-compiler/test/style-deps-hmr.test.ts b/napi/angular-compiler/test/style-deps-hmr.test.ts index 09bd8c55c..f088509f5 100644 --- a/napi/angular-compiler/test/style-deps-hmr.test.ts +++ b/napi/angular-compiler/test/style-deps-hmr.test.ts @@ -321,14 +321,15 @@ describe('handleHotUpdate for transitive style dependencies', () => { writeFileSync(aComponentPath, componentSource('app-a', 'a.component.scss')) writeFileSync(bComponentPath, componentSource('app-b', 'shared.component.scss')) - await transformComponent(plugin, componentSource('app-a', 'a.component.scss'), aComponentPath) - // Transform B last so resourceToComponent[shared] maps to B (the direct - // styleUrl owner) rather than A (which only imports it). 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, @@ -357,10 +358,14 @@ describe('handleHotUpdate for transitive style dependencies', () => { ) // The style and every preprocessor dep must be added to the watcher so - // edits reach handleHotUpdate even outside the dev-server root. - const added = mockServer.watcher.add.mock.calls.flat() - expect(added).toContain(join(appDir, 'first.component.scss')) - expect(added).toContain(sharedScssPath) + // 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 () => { diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 2aa37d5f6..a1c5c1e01 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -428,13 +428,15 @@ export function angular(options: PluginOptions = {}): Plugin[] { } } - // Re-register each dep in `dependencies` (owner registration is already - // handled by `registerStyleDeps`), so the transform's resource tracking - // and prune loop see them. + // 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. const normalizedStylePath = normalizePath(stylePath) for (const dep of styleDepsCache.get(stylePath) ?? []) { if (dep === normalizedStylePath) continue - dependencies.push(dep) + if (watchMode && viteServer) viteServer.watcher?.add?.(dep) } styles[styleUrl] = [content] From 9361c8141a4b77222d206daeee64b68584c7c29d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 10 Aug 2026 23:25:14 +0800 Subject: [PATCH 07/11] fix(vite): preserve Vite modules for partials shared with global stylesheets When a Sass/Less partial is consumed both by a component stylesheet and by Vite's ordinary CSS graph (e.g. a global styles.scss imports the same partial), the Angular branch returned [] after dispatching component updates. That filtered every module out of Vite's default HMR pipeline, so the component updated but the global stylesheet stayed stale. Return ctx.modules instead: component updates are dispatched via the ws endpoint, and Vite's remaining modules (the importing global stylesheet) keep flowing through its default pipeline. Update the two hmr-hot-update tests that encoded the old swallow-everything contract, and the style-deps mock context no longer fabricates a module for the changed file. Adds a regression test: a partial shared with a global stylesheet keeps that module in the returned context while still dispatching component HMR. --- .../test/hmr-hot-update.test.ts | 17 +++++++---- .../test/style-deps-hmr.test.ts | 28 +++++++++++++++++-- napi/angular-compiler/vite-plugin/index.ts | 9 +++--- 3 files changed, 42 insertions(+), 12 deletions(-) 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 index f088509f5..7b4f55157 100644 --- a/napi/angular-compiler/test/style-deps-hmr.test.ts +++ b/napi/angular-compiler/test/style-deps-hmr.test.ts @@ -98,11 +98,11 @@ function createMockServer() { } } -function createMockHmrContext(file: string, server: any): HmrContext { +function createMockHmrContext(file: string, server: any, modules: ModuleNode[] = []): HmrContext { return { file, timestamp: Date.now(), - modules: [{ id: file } as ModuleNode], + modules, read: async () => '', server, } as HmrContext @@ -406,4 +406,28 @@ describe('handleHotUpdate for transitive style dependencies', () => { 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) + }) }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index a1c5c1e01..2216aa57d 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -978,10 +978,11 @@ export function angular(options: PluginOptions = {}): Plugin[] { } } } - if (handled) { - return [] - } - // 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 } From 64a85fd061877ea62962bc340d7345c8d5bbe7af Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 10 Aug 2026 23:32:59 +0800 Subject: [PATCH 08/11] fix(vite): dispatch HMR to every owner of a shared style resourceToComponent is single-valued, so when two component files used the same styleUrl and that style imported a changed partial, the shared-dep branch looked up only the last-transformed owner and the other component kept stale CSS. The direct-resource branch had the same gap when the shared style itself was edited. Track all direct component owners per compiled style in a multi-valued map (style -> Set of component files), populated and pruned alongside resourceToComponent, and dispatch the update to every owner in both the shared-dep and direct-resource branches. Adds a regression test: two components sharing one style file that imports an edited partial both receive component-update events. --- .../test/style-deps-hmr.test.ts | 43 ++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 49 ++++++++++++++----- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/napi/angular-compiler/test/style-deps-hmr.test.ts b/napi/angular-compiler/test/style-deps-hmr.test.ts index 7b4f55157..79a2198ed 100644 --- a/napi/angular-compiler/test/style-deps-hmr.test.ts +++ b/napi/angular-compiler/test/style-deps-hmr.test.ts @@ -430,4 +430,47 @@ describe('handleHotUpdate for transitive style dependencies', () => { 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) + }) }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 2216aa57d..95a22f776 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -261,6 +261,13 @@ export function angular(options: PluginOptions = {}): Plugin[] { // 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 @@ -760,11 +767,23 @@ 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) @@ -946,10 +965,13 @@ export function angular(options: PluginOptions = {}): Plugin[] { // partial switched a nested `@use`/`@import`, the newly loaded // file must be tracked here too. await refreshStyleDeps(stylePath) - const componentFile = resourceToComponent.get(normalizePath(stylePath)) - if (componentFile && dispatchAllComponentsInFile(componentFile)) { - debugHmr('style dep HMR: %s -> %s -> %s', normalizedFile, stylePath, componentFile) - handled = true + // 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 + } } } } @@ -964,15 +986,20 @@ export function angular(options: PluginOptions = {}): Plugin[] { const isDirectStyle = directStyleUrls.has(normalizedFile) if (!(handled && !isDirectStyle)) { resourceCache.delete(normalizedFile) - // Refresh dependency registration only for actual styles — never - // run HTML templates through the CSS preprocessor pipeline. if (isDirectStyle) { + // Refresh dependency registration only for actual styles — + // never run HTML templates through the CSS preprocessor + // pipeline. await refreshStyleDeps(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. - if (dispatchAllComponentsInFile(componentFile)) { + // 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 if (dispatchAllComponentsInFile(componentFile)) { debugHmr('external resource HMR: %s -> %s', normalizedFile, componentFile) handled = true } From 2bb16f07f5126f4b421ea070a9c2f0c78116472e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 10 Aug 2026 23:39:18 +0800 Subject: [PATCH 09/11] fix(vite): keep shared-style owners reachable after an owner switches styles When components A and B shared a direct style and B was transformed last, resourceToComponent pointed at B. If B later switched to another style, its prune deleted that single-valued entry; styleComponentOwners correctly kept A, but the direct-resource branch was gated on resourceToComponent.has, so subsequent edits to the shared root style never dispatched an update to A. Enter the direct-resource branch when styleComponentOwners also has the file, so direct-style edits dispatch to every remaining owner. Templates still dispatch through the single-valued resourceToComponent entry. Adds a regression test: two components share a style, one switches away, and editing the shared style still updates the remaining owner. --- .../test/style-deps-hmr.test.ts | 50 +++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 17 ++++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/napi/angular-compiler/test/style-deps-hmr.test.ts b/napi/angular-compiler/test/style-deps-hmr.test.ts index 79a2198ed..c19456ff0 100644 --- a/napi/angular-compiler/test/style-deps-hmr.test.ts +++ b/napi/angular-compiler/test/style-deps-hmr.test.ts @@ -473,4 +473,54 @@ describe('handleHotUpdate for transitive style dependencies', () => { 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) + }) }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 95a22f776..d917e4190 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -977,9 +977,11 @@ export function angular(options: PluginOptions = {}): Plugin[] { } // 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). - if (resourceToComponent.has(normalizedFile)) { - const componentFile = resourceToComponent.get(normalizedFile)! + // 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. @@ -999,9 +1001,12 @@ export function angular(options: PluginOptions = {}): Plugin[] { handled = true } } - } else if (dispatchAllComponentsInFile(componentFile)) { - debugHmr('external resource HMR: %s -> %s', normalizedFile, componentFile) - handled = true + } else { + const componentFile = resourceToComponent.get(normalizedFile) + if (componentFile && dispatchAllComponentsInFile(componentFile)) { + debugHmr('external resource HMR: %s -> %s', normalizedFile, componentFile) + handled = true + } } } } From f17b16d4a22fad865757226f9aa3be4d472a6be1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 10 Aug 2026 23:49:48 +0800 Subject: [PATCH 10/11] fix(vite): normalize style cache keys and owner values On Windows, the transform registers styles under the raw path.resolve result (backslashes) while handleHotUpdate supplies a normalized ctx.file to refreshStyleDeps. registerStyleDeps therefore created a second registration instead of replacing the first: removed imports stayed tracked and current partial edits could dispatch duplicate component updates. Use the normalized style path consistently as the styleDepsCache key and as the owner value in styleDepOwners, and normalize resourceCache keys (templates and styles) to match, so every lookup from handleHotUpdate hits on all platforms. --- napi/angular-compiler/vite-plugin/index.ts | 28 ++++++++++++---------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index d917e4190..7a0cfc113 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -279,23 +279,26 @@ export function angular(options: PluginOptions = {}): Plugin[] { 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(stylePath) ?? []) { + for (const oldDep of styleDepsCache.get(normalizedStylePath) ?? []) { if (oldDep === normalizedStylePath) continue const owners = styleDepOwners.get(oldDep) if (owners) { - owners.delete(stylePath) + owners.delete(normalizedStylePath) if (owners.size === 0) styleDepOwners.delete(oldDep) } } - styleDepsCache.set(stylePath, fresh) + styleDepsCache.set(normalizedStylePath, fresh) - // Register this style as an owner of each fresh dep. + // 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(stylePath) + owners.add(normalizedStylePath) } } @@ -390,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 @@ -409,12 +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(normalizePath(stylePath)) + 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') @@ -428,7 +433,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { 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 @@ -440,8 +445,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // `resourceToComponent`: that map is single-owner per resource, and a // transitive dep would clobber the direct styleUrl/templateUrl mapping // of another component. - const normalizedStylePath = normalizePath(stylePath) - for (const dep of styleDepsCache.get(stylePath) ?? []) { + for (const dep of styleDepsCache.get(normalizedStylePath) ?? []) { if (dep === normalizedStylePath) continue if (watchMode && viteServer) viteServer.watcher?.add?.(dep) } From 3e05aeb1beec975bde3e02a6056143102327927b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 11 Aug 2026 00:15:00 +0800 Subject: [PATCH 11/11] fix(vite): route Stylus and other preprocessor extensions through HMR The external-resource HMR branch only matched html/css/scss/sass/less files, but preprocessCSS reports deps for every stylesheet language Vite supports (Stylus, PostCSS, SugarSS included). Edits to a .styl/.stylus component style or one of its partials therefore fell through without invalidating the compiled style or dispatching component HMR. Widen the extension filter to Vite's full CSS_LANGS_RE set. Adds a regression test using a .styl styleUrl: preprocessing fails without the stylus package, but the file is still tracked as a direct style and must dispatch HMR when edited. --- .../test/style-deps-hmr.test.ts | 27 +++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 5 +++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/napi/angular-compiler/test/style-deps-hmr.test.ts b/napi/angular-compiler/test/style-deps-hmr.test.ts index c19456ff0..2db7faf8f 100644 --- a/napi/angular-compiler/test/style-deps-hmr.test.ts +++ b/napi/angular-compiler/test/style-deps-hmr.test.ts @@ -523,4 +523,31 @@ describe('handleHotUpdate for transitive style dependencies', () => { 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 7a0cfc113..8d53e9238 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -954,7 +954,10 @@ 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)) { + // 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.