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