diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 0facc40b0..e8ac5035c 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -1313,7 +1313,16 @@ pub fn transform_angular_file( if let Some(id) = &class.id { let name = id.name.to_string(); if class_definitions.contains_key(&name) { - class_positions.push((name, stmt_start, class.body.span.end)); + // Account for non-Angular decorators that precede the class. + // Decorators like @Log(...) appear before `export class` in source, + // so we must insert decls_before_class before those decorators. + let effective_start = class + .decorators + .iter() + .map(|d| d.span.start) + .min() + .map_or(stmt_start, |dec_start| dec_start.min(stmt_start)); + class_positions.push((name, effective_start, class.body.span.end)); } } } diff --git a/napi/angular-compiler/e2e/compare/fixtures/edge-cases/custom-decorators.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/edge-cases/custom-decorators.fixture.ts new file mode 100644 index 000000000..6f9cfe073 --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/edge-cases/custom-decorators.fixture.ts @@ -0,0 +1,61 @@ +/** + * Custom (non-Angular) class decorators. + * + * Tests that non-Angular decorators are preserved in the Angular compiler output + * without breaking the generated code. The Angular compiler strips @Component + * but must leave custom decorators intact for downstream TS-to-JS tools + * (e.g., Rolldown) to lower. + */ +import type { Fixture } from '../types.js' + +export const fixtures: Fixture[] = [ + { + name: 'single-custom-decorator', + category: 'edge-cases', + description: 'Component with a single custom class decorator', + className: 'MyComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +function Log(message: string) { + return function any>(target: T): T { + console.log(message); + return target; + }; +} + +@Log('MyComponent loaded') +@Component({ + selector: 'app-my', + template: 'hello', +}) +export class MyComponent {} +`, + expectedFeatures: ['ɵɵdefineComponent', 'ɵfac'], + }, + { + name: 'multiple-custom-decorators', + category: 'edge-cases', + description: 'Component with multiple custom class decorators', + className: 'MultiDecoratorComponent', + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +function Sealed(target: any) { Object.seal(target); return target; } +function Track(name: string) { + return function(target: any) { return target; }; +} + +@Sealed +@Track('multi') +@Component({ + selector: 'app-multi', + template: '
multi
', +}) +export class MultiDecoratorComponent {} +`, + expectedFeatures: ['ɵɵdefineComponent', 'ɵfac'], + }, +] diff --git a/napi/playground/src/app/app.component.ts b/napi/playground/src/app/app.component.ts index 3b1ee578e..ca90c7f52 100644 --- a/napi/playground/src/app/app.component.ts +++ b/napi/playground/src/app/app.component.ts @@ -1,6 +1,9 @@ import { Component, signal } from '@angular/core' import { RouterOutlet } from '@angular/router' +import { Log } from './custom-decorator' + +@Log('App component loaded') @Component({ selector: 'app-root', imports: [RouterOutlet], diff --git a/napi/playground/src/app/custom-decorator.ts b/napi/playground/src/app/custom-decorator.ts new file mode 100644 index 000000000..666002971 --- /dev/null +++ b/napi/playground/src/app/custom-decorator.ts @@ -0,0 +1,6 @@ +export function Log(message: string) { + return function any>(target: T): T { + console.log(message) + return target + } +}