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