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..b15c4ffc4 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 = 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 ReturnTypeposix
', 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) 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..9d683ffdd 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) { @@ -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) @@ -1023,7 +1026,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { ) const classNamesInFile = new Set