From d4d25b96040dffe80136229dd19e35a70b20dc5e Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 23 Sep 2026 17:38:56 +0100 Subject: [PATCH 1/4] fix(hmr): split component ids on the last `@` A component id is `{path}@{ClassName}` and the path itself can contain `@` (`node_modules/@scope/...`, `packages/@company/...`). Taking the text after the first `@` produced update functions named after the path, e.g. `function scope/pkg/src/a.ts_UpdateMetadata(...)`, which is a syntax error, and made the vite plugin serve an empty HMR module for such components. A class name is a JS identifier and cannot contain `@`, so the last `@` is the separator. `compileForHmrSync` now uses the class name it is given instead of re-parsing the id, matching Angular, which never parses ids. --- .../src/hmr/update_module.rs | 22 ++++- napi/angular-compiler/src/lib.rs | 22 ++--- .../test/hmr-hot-update.test.ts | 85 ++++++++++++++++++- napi/angular-compiler/test/ssr-hmr.test.ts | 47 +++++++++- napi/angular-compiler/vite-plugin/index.ts | 4 +- 5 files changed, 164 insertions(+), 16 deletions(-) diff --git a/crates/oxc_angular_compiler/src/hmr/update_module.rs b/crates/oxc_angular_compiler/src/hmr/update_module.rs index 1032e821d..190fa3ae5 100644 --- a/crates/oxc_angular_compiler/src/hmr/update_module.rs +++ b/crates/oxc_angular_compiler/src/hmr/update_module.rs @@ -94,8 +94,9 @@ pub fn generate_hmr_update_module_from_js( declarations_js: Option<&str>, consts_js: Option<&str>, ) -> String { - // Extract class name from component_id (format: "path@ClassName") - let class_name = component_id.split('@').nth(1).unwrap_or("Component"); + // Extract class name from component_id (format: "path@ClassName"). The path + // can contain `@` (e.g. `node_modules/@scope/...`) but a class name cannot. + let class_name = component_id.rsplit_once('@').map_or("Component", |(_, name)| name); generate_hmr_update_module_internal( component_id, @@ -270,6 +271,23 @@ mod tests { assert!(result.contains("function MyComponent_UpdateMetadata(MyComponent")); } + #[test] + fn test_class_name_extraction_path_with_at() { + for id in + ["node_modules/@scope/pkg/src/a.ts@MyComponent", "packages/@a/@b/x.ts@MyComponent"] + { + let result = generate_hmr_update_module_from_js(id, "", None, None, None); + assert!(result.contains("function MyComponent_UpdateMetadata(MyComponent"), "{id}"); + assert!(result.contains("MyComponent.ɵcmp ="), "{id}"); + } + } + + #[test] + fn test_class_name_extraction_no_at_falls_back() { + let result = generate_hmr_update_module_from_js("MyComponent", "", None, None, None); + assert!(result.contains("function Component_UpdateMetadata(Component")); + } + #[test] fn test_generate_hmr_update_module_with_declarations() { let declarations = "function App_For_1(rf, ctx) { }\nconst _c0 = [1, 2, 3];"; diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index e4ef593ed..796f1fc90 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -20,14 +20,14 @@ use napi_derive::napi; use oxc_allocator::Allocator; use oxc_angular_compiler::{ AngularVersion as RustAngularVersion, ChangeDetectionStrategy as RustChangeDetectionStrategy, - HostMetadataInput as RustHostMetadataInput, TransformOptions as RustTransformOptions, - ViewEncapsulation as RustViewEncapsulation, + HmrUpdateModuleOptions, HostMetadataInput as RustHostMetadataInput, + TransformOptions as RustTransformOptions, ViewEncapsulation as RustViewEncapsulation, build_ctor_params_metadata as core_build_ctor_params_metadata, build_decorator_metadata_array as core_build_decorator_metadata_array, build_prop_decorators_metadata as core_build_prop_decorators_metadata, compile_template_for_hmr, compile_template_to_js_with_options, - encapsulate_style as rust_encapsulate_style, generate_hmr_update_module_from_js, - generate_style_update_module, + encapsulate_style as rust_encapsulate_style, generate_hmr_update_module, + generate_hmr_update_module_from_js, generate_style_update_module, }; use oxc_napi::OxcError; @@ -595,13 +595,15 @@ pub fn compile_for_hmr_sync( }; // Generate HMR module with declarations, encapsulated styles, and consts - let hmr_module = generate_hmr_update_module_from_js( - &component_id, - &template_js, - encapsulated_styles.as_deref(), + let hmr_module = generate_hmr_update_module(&HmrUpdateModuleOptions { + component_id: &component_id, + class_name: &component_name, + template_js: Some(&template_js), + styles: encapsulated_styles.as_deref(), declarations_js, - output.consts_js.as_deref(), - ); + consts_js: output.consts_js.as_deref(), + include_full_metadata: false, + }); HmrCompileResult { hmr_module, component_id, template_js, errors: vec![] } } diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index cc00e8aff..87d54ea91 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -16,7 +16,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Plugin, ModuleNode, HmrContext } from 'vite' -import { normalizePath, resolveConfig } from 'vite' +import { normalizePath, parseSync, resolveConfig } from 'vite' import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest' import { angular } from '../vite-plugin/index.js' @@ -383,6 +383,40 @@ describe('pendingHmrUpdates race condition', () => { expect(bBody).toContain('BComponent') }) + it('serves the HMR module for a component whose path contains @', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + const scopedDir = join(tempDir, 'packages', '@company', 'app') + mkdirSync(scopedDir, { recursive: true }) + const scopedPath = normalizePath(join(scopedDir, 'scoped.component.ts')) + const source = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-scoped', template: '

S

' }) + export class ScopedComponent {} + ` + writeFileSync(scopedPath, source) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call( + { error() {}, warn() {}, addWatchFile() {} } as any, + source, + scopedPath, + ) + + writeFileSync(scopedPath, source.replace('

S

', '

S!

')) + const ctx = createMockHmrContext(scopedPath, [{ id: scopedPath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + const middleware = (mockServer.middlewares.use as ReturnType).mock.calls[0]?.[0] + const body = await invokeAngularMiddleware(middleware, `${scopedPath}@ScopedComponent`) + + expect(body).toContain('function ScopedComponent_UpdateMetadata(ScopedComponent') + expect(parseSync('hmr.js', body).errors).toEqual([]) + }) + it("dispatches HMR for both components when only one component's inline styles change", async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithServer(plugin) @@ -527,6 +561,55 @@ describe('pendingHmrUpdates race condition', () => { expect(dropBody2).toBe('') }) + it('prunes only the removed class when the file path contains @', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + const scopedDir = join(tempDir, 'packages', '@company', 'prune') + mkdirSync(scopedDir, { recursive: true }) + const stalePath = normalizePath(join(scopedDir, 'stale.component.ts')) + const originalSource = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-keep', template: '' }) + export class KeepComponent {} + @Component({ selector: 'app-drop', template: '' }) + export class DropComponent {} + ` + writeFileSync(stalePath, originalSource) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call( + { error() {}, warn() {}, addWatchFile() {} } as any, + originalSource, + stalePath, + ) + + writeFileSync(stalePath, originalSource.replace('', '')) + const ctx = createMockHmrContext(stalePath, [{ id: stalePath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + const reducedSource = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-keep', template: '' }) + export class KeepComponent {} + ` + writeFileSync(stalePath, reducedSource) + await plugin.transform.handler.call( + { error() {}, warn() {}, addWatchFile() {} } as any, + reducedSource, + stalePath, + ) + + const middleware = (mockServer.middlewares.use as ReturnType).mock.calls[0]?.[0] + + expect(await invokeAngularMiddleware(middleware, `${stalePath}@DropComponent`)).toBe('') + const keepBody = await invokeAngularMiddleware(middleware, `${stalePath}@KeepComponent`) + expect(keepBody).toContain('function KeepComponent_UpdateMetadata(KeepComponent') + expect(keepBody).toContain('keep-edited') + }) + it('triggers full reload when a multi-component .ts changes outside template/styles', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithServer(plugin) diff --git a/napi/angular-compiler/test/ssr-hmr.test.ts b/napi/angular-compiler/test/ssr-hmr.test.ts index ec9644e92..a873f71aa 100644 --- a/napi/angular-compiler/test/ssr-hmr.test.ts +++ b/napi/angular-compiler/test/ssr-hmr.test.ts @@ -11,9 +11,15 @@ * 2. `resolveId`/`load` hooks handle `@ng/component` as a safety net, returning * an empty module so the module runner never crashes. */ +import { parseSync } from 'vite' import { describe, it, expect } from 'vitest' -import { compileForHmrSync, transformAngularFile } from '../index.js' +import { + compileForHmrSync, + generateHmrModule, + parseComponentId, + transformAngularFile, +} from '../index.js' const COMPONENT_SOURCE = ` import { Component } from '@angular/core'; @@ -105,3 +111,42 @@ describe('Component style minification', () => { expect(result.hmrModule).toContain('.container[_ngcontent-%COMP%]{color:red;background:0 0}') }) }) + +describe('compileForHmrSync with @ in the file path', () => { + it.each(['node_modules/@scope/pkg/src/a.ts', 'packages/@a/@b/x.ts'])( + 'names the update function after the class for %s', + (filePath) => { + const result = compileForHmrSync('
', 'Foo', filePath, null, {}) + + expect(result.componentId).toBe(`${filePath}@Foo`) + expect(result.hmrModule).toContain( + 'export default function Foo_UpdateMetadata(Foo, ɵɵnamespaces) {', + ) + expect(parseSync('hmr.js', result.hmrModule).errors).toEqual([]) + }, + ) + + it.each([ + ['node_modules/@scope/pkg/src/a.ts@Foo', 'node_modules/@scope/pkg/src/a.ts', 'Foo'], + ['packages/@a/@b/x.ts@Foo', 'packages/@a/@b/x.ts', 'Foo'], + ['Foo', 'Foo', ''], + ])('parseComponentId splits %s on the last @', (id, filePath, className) => { + expect(parseComponentId(id)).toEqual({ filePath, className }) + }) + + it('generateHmrModule names the update function after the class', () => { + const hmrModule = generateHmrModule('node_modules/@scope/pkg/src/a.ts@Foo', 'null') + + expect(hmrModule).toContain('export default function Foo_UpdateMetadata(Foo, ɵɵnamespaces) {') + expect(parseSync('hmr.js', hmrModule).errors).toEqual([]) + }) + + it('transformAngularFile emits a parseable HMR initializer with the full id', async () => { + const filePath = 'node_modules/@scope/pkg/src/app.component.ts' + const result = await transformAngularFile(COMPONENT_SOURCE, filePath, { hmr: true }) + + expect(result.errors).toHaveLength(0) + expect(result.code).toContain(encodeURIComponent(`${filePath}@AppComponent`)) + expect(parseSync('app.component.js', result.code).errors).toEqual([]) + }) +}) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 0acf9bb74..d69ca4408 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -602,7 +602,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { } const decodedComponentId = decodeURIComponent(componentId) - const atIndex = decodedComponentId.indexOf('@') + const atIndex = decodedComponentId.lastIndexOf('@') // Validate component ID format: should be "filePath@ClassName" if (atIndex === -1) { @@ -1023,7 +1023,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { ) const classNamesInFile = new Set() for (const componentId of templateUpdateKeys) { - const atIdx = componentId.indexOf('@') + const atIdx = componentId.lastIndexOf('@') if (atIdx === -1) continue classNamesInFile.add(componentId.slice(atIdx + 1)) } From e216551fa86c0c1e3b6375d0229a11b82cbfb699 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 23 Sep 2026 17:56:39 +0100 Subject: [PATCH 2/4] test(hmr): use platform paths in the `@` path plugin tests The endpoint resolves the file path with `path.resolve`, which yields backslashes on Windows, so the tests must pass the same native path the other plugin tests use rather than a forward-slash one. --- napi/angular-compiler/test/hmr-hot-update.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index 87d54ea91..c340e932b 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -389,7 +389,7 @@ describe('pendingHmrUpdates race condition', () => { const scopedDir = join(tempDir, 'packages', '@company', 'app') mkdirSync(scopedDir, { recursive: true }) - const scopedPath = normalizePath(join(scopedDir, 'scoped.component.ts')) + const scopedPath = join(scopedDir, 'scoped.component.ts') const source = ` import { Component } from '@angular/core'; @Component({ selector: 'app-scoped', template: '

S

' }) @@ -567,7 +567,7 @@ describe('pendingHmrUpdates race condition', () => { const scopedDir = join(tempDir, 'packages', '@company', 'prune') mkdirSync(scopedDir, { recursive: true }) - const stalePath = normalizePath(join(scopedDir, 'stale.component.ts')) + const stalePath = join(scopedDir, 'stale.component.ts') const originalSource = ` import { Component } from '@angular/core'; @Component({ selector: 'app-keep', template: '' }) From 113f612333a3227ea5765c2742717eb39b6631a8 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 23 Sep 2026 17:58:15 +0100 Subject: [PATCH 3/4] test(hmr): serve the endpoint a Vite-normalized (forward-slash) id --- .../test/hmr-hot-update.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index c340e932b..b15c4ffc4 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -2126,6 +2126,44 @@ describe('@ng/component endpoint resolves the styles per class', () => { expect(body).toContain('styles: []') }) + // Vite hands `transform` forward-slash ids on every platform. The endpoint + // must look the file up by that same spelling, not a re-resolved one, or on + // Windows it serves nothing and never treats an empty style list as final. + it('serves the module for a forward-slash (Vite-normalized) component id', async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithRealConfig(plugin) + + const sibCssPath = normalizePath(join(appDir, 'ps-posix-sib.component.css')) + const posixPath = normalizePath(join(appDir, 'ps-posix.component.ts')) + writeFileSync(sibCssPath, '.PS_POSIX_SIB_MARKER { color: red; }') + + const source = ` + import { Component } from '@angular/core'; + @Component({ selector: 'app-ps-posix', template: '

posix

', styles: [] }) + export class PosixComponent {} + @Component({ + selector: 'app-ps-posix-sib', + template: '

sib

', + styleUrls: ['./ps-posix-sib.component.css'], + }) + export class PosixSiblingComponent {} + ` + writeFileSync(posixPath, source) + await transformSource(plugin, source, posixPath) + + writeFileSync(sibCssPath, '.PS_POSIX_SIB_MARKER { color: green; }') + const ctx = createMockHmrContext(sibCssPath, [{ id: sibCssPath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + expectDispatched(mockServer, `${posixPath}@PosixComponent`) + + const body = await invokeAngularMiddleware( + getMiddleware(mockServer), + `${posixPath}@PosixComponent`, + ) + expect(body).toContain('function PosixComponent_UpdateMetadata(PosixComponent') + expect(body).toContain('styles: []') + }) + it('serves both entries of a `styleUrls` array mixing a constant with a literal', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithRealConfig(plugin) From fdaf345fb0c25a4f6bceb09f920aa2e2669ac57d Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 23 Sep 2026 18:00:24 +0100 Subject: [PATCH 4/4] fix(hmr): look up the endpoint's file by the transform id, not a resolved path `componentsByFile` and `componentMetadataCache` are keyed by the transform id, which Vite gives with forward slashes on every platform. The endpoint looked them up by `path.resolve(fileId)`, which on Windows is a backslash path, so it served an empty module for every component and never treated an empty style list as definitive. Keep the resolved path for the disk reads only. --- napi/angular-compiler/vite-plugin/index.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index d69ca4408..9d683ffdd 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -614,6 +614,9 @@ export function angular(options: PluginOptions = {}): Plugin[] { const fileId = decodedComponentId.slice(0, atIndex) const className = decodedComponentId.slice(atIndex + 1) + // `fileId` is the transform id verbatim, which is what the per-file + // maps are keyed by. `resolvedId` is for the filesystem only: on + // Windows it swaps Vite's forward slashes for backslashes. const resolvedId = resolve(process.cwd(), fileId) // Only return an HMR update module if `handleHotUpdate` queued @@ -634,7 +637,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // around indefinitely because the transient-empty preservation // logic below assumes a future save will resolve it. Consume // and return empty. - if (!componentsByFile.get(resolvedId)?.has(className)) { + if (!componentsByFile.get(fileId)?.has(className)) { pendingHmrUpdates.delete(decodedComponentId) res.setHeader('Content-Type', 'text/javascript') res.setHeader('Cache-Control', 'no-cache') @@ -791,7 +794,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // read is still served on a mismatch — no worse than main, // which scanned the same disk source. const compiledFromThisSource = () => { - const cachedStripped = componentMetadataCache.get(resolvedId) + const cachedStripped = componentMetadataCache.get(fileId) return ( cachedStripped !== undefined && cachedStripped === stripComponentMetadata(source)