From 4c1bc50da28ba64e573dbc05b4164af1a06e2deb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 16:36:27 +0800 Subject: [PATCH 01/54] perf(vite): skip the resource URL parse when no URL is spelled transform() called extractComponentUrls for every file with an Angular decorator, a second full Rust parse of the file. Most files (services, pipes, inline-template components) never spell `templateUrl` or `styleUrl`, so they are now compiled with empty resources directly. The compiler still reads keys the way the parser does, so a component can name a resource through an escaped key (`styleUrls`) or a computed key folded from a same-file constant. On that path the compiler reports COMPONENT_RESOURCE_NOT_FOUND, so any error on the fast path reruns the file through resolveResources. Without URLs resolveResources returned the same empty resources and touched no state, so output is unchanged. JIT mode now skips resolveResources entirely: the Rust JIT transform never reads resources (it emits `angular:jit:*` imports that jitPlugin loads) and registers no components for HMR, so the reads and the owner bookkeeping were unused. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test/plugin-fast-paths.test.ts | 269 ++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 44 ++- 2 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 napi/angular-compiler/test/plugin-fast-paths.test.ts diff --git a/napi/angular-compiler/test/plugin-fast-paths.test.ts b/napi/angular-compiler/test/plugin-fast-paths.test.ts new file mode 100644 index 000000000..700599c81 --- /dev/null +++ b/napi/angular-compiler/test/plugin-fast-paths.test.ts @@ -0,0 +1,269 @@ +/** + * Tests for the plugin's fast paths and HMR option parity. + * + * - `transform` skips the `extractComponentUrls` parse for a file that never + * spells `templateUrl`/`styleUrl`, and still compiles a file that names a + * resource through an escaped key. + * - JIT `transform` never reads external resources: its output imports them. + * - The `@ng/component` endpoint compiles with the decorator's + * `preserveWhitespaces`, like the full transform. + * - One stylesheet edit is preprocessed once per HMR cycle, not once by + * `handleHotUpdate`, once by the endpoint, and once by the next transform. + */ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import type { HmrContext, ModuleNode, Plugin } from 'vite' +import { normalizePath, resolveConfig } from 'vite' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { angular } from '../vite-plugin/index.js' + +// Spy on the binding call and on Vite's preprocessor, keeping their real +// behavior, so the tests can count how often the plugin reaches them. +const spies = vi.hoisted(() => ({ + extractComponentUrls: undefined as unknown as ReturnType, + preprocessCSS: undefined as unknown as ReturnType, +})) + +vi.mock('#binding', async (importOriginal) => { + const actual = await importOriginal() + spies.extractComponentUrls = vi.fn(actual.extractComponentUrls) + return { ...actual, extractComponentUrls: spies.extractComponentUrls } +}) + +vi.mock('vite', async (importOriginal) => { + const actual = await importOriginal() + spies.preprocessCSS = vi.fn(actual.preprocessCSS) + return { ...actual, preprocessCSS: spies.preprocessCSS } +}) + +let tempDir: string +let appDir: string + +beforeAll(() => { + // realpath: Sass canonicalizes loaded URLs (macOS /var -> /private/var). + tempDir = realpathSync(mkdtempSync(join(tmpdir(), 'plugin-fast-paths-test-'))) + appDir = join(tempDir, 'src', 'app') + mkdirSync(appDir, { recursive: true }) +}) + +afterAll(() => { + rmSync(tempDir, { recursive: true, force: true }) +}) + +beforeEach(() => { + spies.extractComponentUrls.mockClear() + spies.preprocessCSS.mockClear() +}) + +function getAngularPlugin(options: Parameters[0] = { liveReload: true }) { + const plugin = angular(options).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, + } +} + +async function callPluginHook(hook: any, ...args: unknown[]) { + if (!hook) return undefined + if (typeof hook === 'function') return hook(...args) + return hook.handler(...args) +} + +/** Run the plugin through `config`/`configResolved`/`configureServer`. */ +async function setupPlugin(plugin: Plugin, command: 'serve' | 'build' = 'serve') { + const mockServer = createMockServer() + await callPluginHook(plugin.config, {}, { command, mode: 'development' }) + // A real resolved config: preprocessCSS needs one to run Sass. + const resolved = await resolveConfig( + { configFile: false, root: tempDir, logLevel: 'silent' }, + command, + ) + await callPluginHook(plugin.configResolved, resolved) + if (command === 'serve' && typeof plugin.configureServer === 'function') { + await (plugin.configureServer as Function)(mockServer) + } + return mockServer +} + +async function transform(plugin: Plugin, source: string, path: string) { + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + const errors: string[] = [] + const warnings: string[] = [] + const result = (await plugin.transform.handler.call( + { + error(message: string) { + errors.push(message) + }, + warn(message: string) { + warnings.push(message) + }, + addWatchFile() {}, + } as any, + source, + path, + )) as { code: string } | undefined + return { code: result?.code ?? '', errors, warnings } +} + +function hotUpdate(plugin: Plugin, file: string, server: any) { + const ctx = { + file: normalizePath(file), + timestamp: Date.now(), + modules: [{ id: normalizePath(file) }] as unknown as ModuleNode[], + read: async () => '', + server, + } as HmrContext + return (plugin.handleHotUpdate as Function).call(plugin, ctx) +} + +async function requestUpdateModule(server: any, componentId: string): Promise { + const middleware = (server.middlewares.use as ReturnType).mock.calls[0]?.[0] + expect(middleware, 'expected the @ng/component middleware').toBeDefined() + const req = { url: `/@ng/component?c=${encodeURIComponent(componentId)}&t=${Date.now()}` } + return new Promise((resolve) => { + middleware( + req, + { + statusCode: 200, + setHeader() {}, + end(data: string = '') { + resolve(data ?? '') + }, + }, + () => resolve(''), + ) + }) +} + +function preprocessCallsFor(path: string): number { + return spies.preprocessCSS.mock.calls.filter( + ([, filename]) => normalizePath(filename as string) === normalizePath(path), + ).length +} + +describe('transform skips the resource URL parse when no URL is spelled', () => { + it('does not call extractComponentUrls for an inline-only component', async () => { + const plugin = getAngularPlugin() + await setupPlugin(plugin, 'build') + + const { code, errors } = await transform( + plugin, + ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-inline', + template: '

inline

', + styles: ['p { color: red; }'], + }) + export class InlineComponent {} + `, + join(appDir, 'inline.component.ts'), + ) + + expect(errors).toEqual([]) + expect(code).toContain('ɵɵdefineComponent') + expect(spies.extractComponentUrls).not.toHaveBeenCalled() + }) + + it('still calls extractComponentUrls for a component with a templateUrl', async () => { + const plugin = getAngularPlugin() + await setupPlugin(plugin, 'build') + writeFileSync(join(appDir, 'url.component.html'), '

URL_TEMPLATE_MARKER

') + + const { code, errors } = await transform( + plugin, + ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-url', templateUrl: './url.component.html' }) + export class UrlComponent {} + `, + join(appDir, 'url.component.ts'), + ) + + expect(errors).toEqual([]) + expect(code).toContain('URL_TEMPLATE_MARKER') + expect(spies.extractComponentUrls).toHaveBeenCalledTimes(1) + }) + + it('resolves resources named only through escaped keys', async () => { + const plugin = getAngularPlugin() + await setupPlugin(plugin, 'build') + writeFileSync(join(appDir, 'escaped.component.html'), '

ESCAPED_TEMPLATE_MARKER

') + writeFileSync(join(appDir, 'escaped.component.css'), '.ESCAPED_STYLE_MARKER { color: red; }') + + // `templateUrl` IS `templateUrl` to the parser and to the compiler, + // but the text holds neither name, so this takes the fast path first. + const { code, errors } = await transform( + plugin, + ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-escaped', + template\\u0055rl: './escaped.component.html', + 'style\\u0055rls': ['./escaped.component.css'], + }) + export class EscapedComponent {} + `, + join(appDir, 'escaped.component.ts'), + ) + + expect(errors).toEqual([]) + expect(code).toContain('ESCAPED_TEMPLATE_MARKER') + expect(code).toContain('ESCAPED_STYLE_MARKER') + }) +}) + +describe('JIT transform', () => { + it('does not read or parse external resources', async () => { + const plugin = getAngularPlugin({ jit: true, liveReload: true }) + const server = await setupPlugin(plugin) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + // Neither file exists: AOT would warn that it cannot read them, JIT + // only imports them. + const { code, errors } = await transform( + plugin, + ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-jit', + templateUrl: './missing-jit.component.html', + styleUrl: './missing-jit.component.scss', + }) + export class JitComponent {} + `, + join(appDir, 'jit.component.ts'), + ) + + expect(errors).toEqual([]) + expect(code).toContain('angular:jit:template:file;./missing-jit.component.html') + expect(code).toContain('angular:jit:style:file;./missing-jit.component.scss') + expect(spies.extractComponentUrls).not.toHaveBeenCalled() + expect(warn).not.toHaveBeenCalled() + expect(server.watcher.add).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) +}) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 3dfb9a0e0..4d927e491 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -525,6 +525,17 @@ export function angular(options: PluginOptions = {}): Plugin[] { return { resources: { templates, styles } as unknown as ResolvedResources, dependencies } } + /** + * What `resolveResources` returns for a file with no external template or + * stylesheet. + */ + function emptyResources(): { resources: ResolvedResources; dependencies: string[] } { + return { + resources: { templates: {}, styles: {} } as unknown as ResolvedResources, + dependencies: [], + } + } + /** * Main Angular plugin for file transformation. */ @@ -959,8 +970,21 @@ export function angular(options: PluginOptions = {}): Plugin[] { // Apply file replacements const actualId = pluginOptions.fileReplacements?.[id] ?? id - // Resolve external resources - const { resources, dependencies } = await resolveResources(code, actualId) + // Resolve external resources. JIT output never reads them: the Rust + // JIT transform turns `templateUrl`/`styleUrl(s)` into + // `angular:jit:*` imports that `jitPlugin` loads, and it registers + // no components for HMR, so nothing downstream uses the reads or + // the bookkeeping below. + // + // Outside JIT, `extractComponentUrls` is a second full parse of the + // file. A file that never spells `templateUrl` or `styleUrl` almost + // never has one, so it is compiled with no resources first; the + // retry after the transform covers the rare file that does. + const mayHaveResourceUrls = + !pluginOptions.jit && (code.includes('templateUrl') || code.includes('styleUrl')) + let { resources, dependencies } = mayHaveResourceUrls + ? await resolveResources(code, actualId) + : emptyResources() // Disable HMR for SSR transforms. SSR bundles must not contain HMR // initialization code that dynamically imports @ng/component virtual @@ -986,7 +1010,21 @@ export function angular(options: PluginOptions = {}): Plugin[] { tsconfigPath: pluginOptions.tsconfig, } - const result = await transformAngularFile(code, actualId, transformOptions, resources) + let result = await transformAngularFile(code, actualId, transformOptions, resources) + + // The compiler reads a key the way the parser does, so a component + // can still name a resource without the plain text: an escaped key + // (`style\u0055rls`) or a computed key folded from a same-file + // constant. It then reports the URL as COMPONENT_RESOURCE_NOT_FOUND, + // since resources were passed but held none. Any error on the fast + // path therefore reruns the file through the full resolution, which + // makes the fast path give exactly the old result: with no URL in + // the file, `resolveResources` would have passed these same empty + // resources. + if (!pluginOptions.jit && !mayHaveResourceUrls && result.errors.length > 0) { + ;({ resources, dependencies } = await resolveResources(code, actualId)) + result = await transformAngularFile(code, actualId, transformOptions, resources) + } // Resource deps (templates/styles) plus files the compiler read to // resolve imported decorator-metadata values (`result.dependencies`). From 490d0cc6691dda3d664bbd214782b405c28b3e73 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 16:36:36 +0800 Subject: [PATCH 02/54] fix(vite): pass preserveWhitespaces to the HMR template compile The @ng/component endpoint compiled the new template without the decorator's `preserveWhitespaces`, so compileForHmrSync always stripped whitespace. For a component with `preserveWhitespaces: true` the update module then built a different view than the full transform (missing padding and inter-element text nodes), and text shifted after every HMR. The endpoint already has the class's extracted metadata; pass its `preserveWhitespaces` like `encapsulation`. The other template options compileForHmrSync reads (i18nUseExternalIds, advancedOptimizations, legacyOptionalChaining) are not set by the full transform either, so both use the same defaults. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test/plugin-fast-paths.test.ts | 50 +++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 5 ++ 2 files changed, 55 insertions(+) diff --git a/napi/angular-compiler/test/plugin-fast-paths.test.ts b/napi/angular-compiler/test/plugin-fast-paths.test.ts index 700599c81..0bf777466 100644 --- a/napi/angular-compiler/test/plugin-fast-paths.test.ts +++ b/napi/angular-compiler/test/plugin-fast-paths.test.ts @@ -267,3 +267,53 @@ describe('JIT transform', () => { } }) }) + +describe('@ng/component endpoint template options', () => { + const TEMPLATE = '

a

b' + + async function hmrModuleFor(preserveWhitespaces: boolean) { + const name = preserveWhitespaces ? 'ws-keep' : 'ws-strip' + const templatePath = join(appDir, `${name}.component.html`) + const componentPath = join(appDir, `${name}.component.ts`) + const source = ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-${name}', + templateUrl: './${name}.component.html', + preserveWhitespaces: ${preserveWhitespaces}, + }) + export class WsComponent {} + ` + writeFileSync(templatePath, TEMPLATE) + writeFileSync(componentPath, source) + + const plugin = getAngularPlugin() + const server = await setupPlugin(plugin) + const { code } = await transform(plugin, source, componentPath) + + await hotUpdate(plugin, templatePath, server) + const body = await requestUpdateModule(server, `${componentPath}@WsComponent`) + return { code, body } + } + + it('keeps whitespace when the decorator sets preserveWhitespaces', async () => { + const { code, body } = await hmrModuleFor(true) + + // The full transform keeps the padding and the text node between the + // elements; the update module must build the same view. + expect(code).toMatch(/ɵɵtext\(\d+,\s*["'] {2}a {2}["']\)/) + expect(code).toMatch(/ɵɵtext\(\d+,\s*["'] {3}["']\)/) + expect(body).not.toBe('') + expect(body).toMatch(/ɵɵtext\(\d+,\s*["'] {2}a {2}["']\)/) + expect(body).toMatch(/ɵɵtext\(\d+,\s*["'] {3}["']\)/) + }) + + it('strips whitespace when the decorator does not preserve it', async () => { + const { code, body } = await hmrModuleFor(false) + + expect(code).not.toMatch(/ɵɵtext\(\d+,\s*["'] {3}["']\)/) + expect(body).not.toBe('') + expect(body).toMatch(/ɵɵtext\(\d+,\s*["'] a ["']\)/) + expect(body).not.toMatch(/ɵɵtext\(\d+,\s*["'] {3}["']\)/) + }) +}) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 4d927e491..4459e7023 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -862,9 +862,14 @@ export function angular(options: PluginOptions = {}): Plugin[] { ? merged : null + // Same template options as the full transform, which reads + // `preserveWhitespaces` from the decorator: without it the + // update module strips whitespace the compiled component + // keeps, and text nodes shift after every HMR. const result = compileForHmrSync(templateContent, className, resolvedId, styles, { angularVersion: pluginOptions.angularVersion, encapsulation: classMetadata.encapsulation, + preserveWhitespaces: classMetadata.preserveWhitespaces, minifyComponentStyles: getMinifyComponentStyles(), }) From e340aa5523152aac3f3d136760f0b326b3d6606f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 16:36:49 +0800 Subject: [PATCH 03/54] perf(vite): preprocess an edited stylesheet once per HMR cycle One stylesheet edit went through preprocessCSS up to three times: refreshStyleDeps in handleHotUpdate, the @ng/component endpoint, and the next transform of the invalidated component. Add one compileStyle() helper that all three use. It keeps the output per stylesheet, tagged with the source text it was built from, so a file that changed again in between is rebuilt (the endpoint still reads from disk every time). Entries only live for one HMR cycle: handleHotUpdate clears the map on entry, and a generation counter keeps a compile from an earlier cycle from storing into the new one. The output can depend on files outside the reported deps (Tailwind scans templates), so a later template edit still rebuilds the styles the endpoint serves, as before. Builds keep no entries; resourceCache already serves their repeat reads. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test/plugin-fast-paths.test.ts | 77 +++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 66 ++++++++++++++-- 2 files changed, 135 insertions(+), 8 deletions(-) diff --git a/napi/angular-compiler/test/plugin-fast-paths.test.ts b/napi/angular-compiler/test/plugin-fast-paths.test.ts index 0bf777466..e038ababd 100644 --- a/napi/angular-compiler/test/plugin-fast-paths.test.ts +++ b/napi/angular-compiler/test/plugin-fast-paths.test.ts @@ -317,3 +317,80 @@ describe('@ng/component endpoint template options', () => { expect(body).not.toMatch(/ɵɵtext\(\d+,\s*["'] {3}["']\)/) }) }) + +describe('stylesheet preprocessing during HMR', () => { + const componentSource = (name: string) => ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-${name}', + template: '

Hello

', + styleUrl: './${name}.component.scss', + }) + export class StyledComponent {} + ` + + async function setupStyled(name: string) { + const stylePath = join(appDir, `${name}.component.scss`) + const componentPath = join(appDir, `${name}.component.ts`) + const source = componentSource(name) + writeFileSync(stylePath, `$c: red; h1 { color: $c; }`) + writeFileSync(componentPath, source) + + const plugin = getAngularPlugin() + const server = await setupPlugin(plugin) + await transform(plugin, source, componentPath) + return { plugin, server, stylePath, componentPath, source } + } + + it('preprocesses an edited stylesheet once per HMR cycle', async () => { + const { plugin, server, stylePath, componentPath, source } = await setupStyled('once') + expect(preprocessCallsFor(stylePath)).toBe(1) + spies.preprocessCSS.mockClear() + + writeFileSync(stylePath, `$c: green; h1 { color: $c; }`) + await hotUpdate(plugin, stylePath, server) + const body = await requestUpdateModule(server, `${componentPath}@StyledComponent`) + // The component module was invalidated, so the next request re-runs it. + const { code } = await transform(plugin, source, componentPath) + + expect(body).toContain('color: green') + expect(code).toContain('color: green') + expect(preprocessCallsFor(stylePath)).toBe(1) + }) + + it('serves the stylesheet as it is on disk when it changed after the cycle started', async () => { + const { plugin, server, stylePath, componentPath } = await setupStyled('fresh') + + writeFileSync(stylePath, `$c: green; h1 { color: $c; }`) + await hotUpdate(plugin, stylePath, server) + // A second write lands before the browser asks for the update. + writeFileSync(stylePath, `$c: blue; h1 { color: $c; }`) + const body = await requestUpdateModule(server, `${componentPath}@StyledComponent`) + + expect(body).toContain('color: blue') + expect(body).not.toContain('color: green') + }) + + it('rebuilds the styles in a later cycle even when the stylesheet is unchanged', async () => { + const { plugin, server, stylePath, componentPath } = await setupStyled('cycle') + + writeFileSync(stylePath, `$c: green; h1 { color: $c; }`) + await hotUpdate(plugin, stylePath, server) + await requestUpdateModule(server, `${componentPath}@StyledComponent`) + spies.preprocessCSS.mockClear() + + // A new file event (here the component's inline template) starts a new + // cycle: preprocessor output can depend on files outside its reported + // deps (Tailwind scans templates), so the styles are built again. + writeFileSync( + componentPath, + componentSource('cycle').replace('

Hello

', '

Hello again

'), + ) + await hotUpdate(plugin, componentPath, server) + const body = await requestUpdateModule(server, `${componentPath}@StyledComponent`) + + expect(body).toContain('Hello again') + expect(body).toContain('color: green') + expect(preprocessCallsFor(stylePath)).toBe(1) + }) +}) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 4459e7023..53075a8ee 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -196,6 +196,13 @@ function markModuleSelfAccepting(mod: ModuleNode): void { } } +/** A component stylesheet after `preprocessCSS`, with the source it came from. */ +type CompiledStyle = { + source: string + code: string + deps: string[] | undefined +} + type InlineBuildMinifyOptions = { cssMinify?: boolean | string minify?: boolean | string @@ -365,6 +372,47 @@ export function angular(options: PluginOptions = {}): Plugin[] { } } + // `preprocessCSS` output for each component stylesheet (normalized path), + // tagged with the source text it was built from. Without it one stylesheet + // edit is preprocessed up to three times: by `refreshStyleDeps` in + // `handleHotUpdate`, by the `@ng/component` endpoint, and by the next + // `transform` of the component. + // + // An entry only lives for one HMR cycle: `handleHotUpdate` clears the map + // as it starts. The output can depend on files that are not in the + // reported `deps` (Tailwind scans templates for class names), so a later + // template edit must still rebuild the styles the endpoint serves, as it + // always has. + const compiledStyles = new Map() + // Bumped with every clear, so a compile that started in an earlier cycle + // cannot store its (possibly stale) output in the new one. + let styleCycle = 0 + + function startStyleCycle(): void { + styleCycle++ + compiledStyles.clear() + } + + // Preprocess a component stylesheet (SCSS, Less, PostCSS, ...), reusing the + // output built earlier in this HMR cycle from the same source text. Throws + // what `preprocessCSS` throws; failures are not cached. + async function compileStyle(stylePath: string, source: string): Promise { + const key = normalizePath(stylePath) + const cached = compiledStyles.get(key) + if (cached && cached.source === source) return cached + const cycle = styleCycle + const processed = await preprocessCSS(source, stylePath, resolvedConfig as any) + const compiled: CompiledStyle = { + source, + code: processed.code, + deps: processed.deps ? Array.from(processed.deps) : undefined, + } + // Builds never start a cycle and `resourceCache` already serves their + // repeat reads, so only the dev server keeps entries. + if (watchMode && cycle === styleCycle) compiledStyles.set(key, compiled) + return compiled + } + // 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 @@ -382,7 +430,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { } if (!content.trim()) return try { - const processed = await preprocessCSS(content, stylePath, resolvedConfig as any) + const processed = await compileStyle(stylePath, content) 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 @@ -494,7 +542,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // Preprocess styles (SCSS, Less, etc.) if (resolvedConfig) { try { - const processed = await preprocessCSS(content, stylePath, resolvedConfig as any) + const processed = await compileStyle(stylePath, content) content = processed.code registerStyleDeps(stylePath, processed.deps) } catch (e) { @@ -755,12 +803,10 @@ export function angular(options: PluginOptions = {}): Plugin[] { try { let styleContent = await readFile(stylePath, 'utf-8') if (resolvedConfig) { - const processed = await preprocessCSS( - styleContent, - stylePath, - resolvedConfig as any, - ) - styleContent = processed.code + // Read from disk every time; only the preprocessing + // is shared, and only for unchanged text built in + // this HMR cycle (see `compileStyle`). + styleContent = (await compileStyle(stylePath, styleContent)).code } contents.push(styleContent) } catch { @@ -1195,6 +1241,10 @@ export function angular(options: PluginOptions = {}): Plugin[] { }, }, async handleHotUpdate(ctx) { + // Every file event starts a new HMR cycle for compiled styles, before + // anything below can preprocess one (see `compiledStyles`). + startStyleCycle() + if (!pluginOptions.liveReload) return debugHmr('handleHotUpdate file=%s', ctx.file) From f64f27f3168fe905e913d05b64acc47846102f93 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 16:36:55 +0800 Subject: [PATCH 04/54] chore(vite): remove the unused styles plugin `@oxc-angular/vite-styles` only transformed module ids carrying an `ngcomp` query, and nothing creates such ids: neither the plugin, the binding, nor the compiler emit `?ngcomp=`. HMR styles are encapsulated inside compileForHmrSync. Drop the plugin and its encapsulateStyle import; the binding export itself stays. Co-Authored-By: Claude Opus 5.5 (1M context) --- napi/angular-compiler/vite-plugin/index.ts | 34 ---------------------- 1 file changed, 34 deletions(-) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 53075a8ee..b02cf3967 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -25,7 +25,6 @@ import { transformAngularFile, extractComponentMetadataSync, extractComponentUrls, - encapsulateStyle, compileForHmrSync, type TransformOptions, type ResolvedResources, @@ -1537,9 +1536,6 @@ export function angular(options: PluginOptions = {}): Plugin[] { } } - /** - * Plugin to encapsulate component styles. - */ /** * Augment library `.d.ts` files with Angular's Ivy type declarations. * @@ -1606,38 +1602,8 @@ export function angular(options: PluginOptions = {}): Plugin[] { } } - function stylesPlugin(): Plugin { - return { - name: '@oxc-angular/vite-styles', - transform: { - filter: { - id: /ngcomp/, - }, - handler(code, id) { - if (!pluginOptions.liveReload) return - - const params = new URL(id, 'http://localhost').searchParams - const componentId = params.get('ngcomp') - const encapsulation = params.get('e') - - // Only encapsulate for emulated encapsulation (e=0) - if (encapsulation === '0' && componentId) { - const encapsulated = encapsulateStyle(code, componentId) - return { - code: encapsulated, - map: null, - } - } - - return undefined - }, - }, - } - } - return [ angularPlugin(), - stylesPlugin(), dtsPlugin(), angularLinkerPlugin({ angularVersion: pluginOptions.angularVersion }), pluginOptions.jit && From 23d2874360e9b73cb691417c9b7d6bcc4c648d77 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 21 Oct 2026 16:49:23 +0800 Subject: [PATCH 05/54] fix(i18n): port parseI18nMeta 1:1 so i18n metadata is trimmed once Angular's `parseI18nMeta` trims the whole `i18n` / `i18n-*` value once and then splits it on `@@` and `|` without trimming the parts. We trimmed the meaning and description separately, so `i18n=" m | d "` gave meaning `m` and description `d` where ngtsc gives `m ` and ` d`. These strings are emitted in `$localize` (`:m | d:`) and JSDoc and feed the message id, so the output differed from ngtsc. Add `parse_i18n_meta`, a direct port: trim with `String.prototype.trim` semantics, find `|` in the whole value (so a `|` after `@@` leaves the description empty, as in Angular), and use it both for the message factory input and for the r3 i18n metadata, replacing a second ad-hoc parser. Pinned by unit tests, an integration test with ngtsc 22.2.2 output, and the `i18n-meta-untrimmed-parts` compare fixture. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/transform/html_to_r3.rs | 106 ++++++++++++------ .../tests/integration_test.rs | 18 +++ .../regressions/i18n-meta-trim.fixture.ts | 35 ++++++ 3 files changed, 126 insertions(+), 33 deletions(-) create mode 100644 napi/angular-compiler/e2e/compare/fixtures/regressions/i18n-meta-trim.fixture.ts diff --git a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index c5437fbe9..e8ef492ae 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -497,30 +497,14 @@ impl<'a> HtmlToR3Transform<'a> { self.source_text, "