From 0850aeaff7df0265708cb42cfe498dc8ed859a71 Mon Sep 17 00:00:00 2001 From: Alexis Date: Mon, 3 Aug 2026 23:50:15 +0200 Subject: [PATCH] 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