voidzero-dev / voidzero-dev/oxc-angular-compiler
Support useDefineForClassFields: false (Class Field Lowering)
Chưa có ai nhận issue này.
- Ngôn ngữ chính
- Rust
- Star
- 228
- Fork
- 20
- Merge trung bình
- 1 ngày 15 giờ
- Pull request đã merge (30 ngày)
- 36
Mô tả
Summary
The OXC Angular compiler (@oxc-angular/vite) outputs native ES class fields without lowering them to constructor assignments. This causes runtime errors in Angular projects that use useDefineForClassFields: false in their tsconfig — which is the standard Angular configuration.
Runtime Errors
Error 1: Properties of undefined
TypeError: Cannot read properties of undefined (reading 'onClose')
at <instance_members_initializer> ...
at new CasesComponent
The <instance_members_initializer> in the V8 stack trace confirms that native class fields are being used at runtime, when they should have been lowered to constructor assignments.
Error 2: Private field SyntaxError
SyntaxError: Private field '#view' must be declared in an enclosing class
This occurs when private fields (#field) are completely removed from the class body during lowering — ES private fields require a class-level declaration for the private name slot.
Root Cause
The project's tsconfig.base.json has:
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": false
}
}
With useDefineForClassFields: false, TypeScript lowers class field initializers into the constructor body as assignments (legacy behavior). This is critical for Angular because:
-
inject()in class fields: Angular'sinject()function requires an active injection context. WithuseDefineForClassFields: false,inject()calls in class fields are lowered to constructor body assignments, where the injection context is guaranteed to be active. -
Constructor parameter properties: Angular components often use constructor DI (
constructor(private router: Router)). WithuseDefineForClassFields: false, parameter properties are assigned before class field initializers in the constructor body. With native class fields, field initializers run before parameter property assignments. -
Inheritance: When a component extends a parent class, the parent's constructor sets properties via parameter properties. With
useDefineForClassFields: false, the child's field initializers can safely reference these properties because they run after the parent constructor AND after the child's parameter property assignments.
Minimal Reproduction
Input TypeScript:
import { Component, inject } from '@angular/core';
class ParentClass {
protected constructor(protected dep: SomeService) {}
}
class CifaPanelService {
onClose: Observable<boolean>;
}
@Component({
selector: 'app-cases',
template: '<div>cases</div>',
standalone: true
})
export class CasesComponent extends ParentClass {
// Field using inject()
private cifaPanelService = inject(CifaPanelService);
// Field referencing the inject() field above — CRASHES with native class fields
#casesDrawerCloseChangeEvent$ = this.cifaPanelService.onClose.pipe(delay(0));
// Private field with signal
#view = signal<string>('home');
view = this.#view.asReadonly();
constructor(protected dep: SomeService) {
super(dep);
console.log(this.#view());
}
}
Current OXC Output (WRONG — keeps native class fields):
export class CasesComponent extends ParentClass {
// These are NATIVE class fields — they run after super() but BEFORE constructor body
cifaPanelService = inject(CifaPanelService);
#casesDrawerCloseChangeEvent$ = this.cifaPanelService.onClose.pipe(delay(0));
#view = signal('home');
view = this.#view.asReadonly();
constructor(dep) {
super(dep);
console.log(this.#view());
}
static ɵfac = function CasesComponent_Factory(__ngFactoryType__) { ... };
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ ... });
}
Expected OXC Output (with useDefineForClassFields: false):
export class CasesComponent extends ParentClass {
#casesDrawerCloseChangeEvent$; // ← Private field declaration KEPT
#view; // ← Private field declaration KEPT
constructor(dep) {
super(dep);
// Field initializers lowered to assignments (BEFORE existing constructor body)
this.cifaPanelService = inject(CifaPanelService);
this.#casesDrawerCloseChangeEvent$ = this.cifaPanelService.onClose.pipe(delay(0));
this.#view = signal('home');
this.view = this.#view.asReadonly();
// Original constructor body
console.log(this.#view());
}
static ɵfac = function CasesComponent_Factory(__ngFactoryType__) { ... };
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ ... });
}
Lowering Rules
| Field type | Class body | Constructor body |
|---|---|---|
Regular field (field = value) |
Remove declaration entirely | Add this.field = value; |
ES private field (#field = value) |
Keep declaration as #field; (no initializer) |
Add this.#field = value; |
Static field (static field = value) |
Keep as-is (no lowering) | Do NOT move |
Field without initializer (field;) |
Keep as-is (no lowering) | Do NOT move |
declare field |
Keep as-is (no lowering) | Do NOT move |
Why private fields need special handling
ES private fields use a "private name" slot that must be established via a class-level declaration. Unlike regular properties, you cannot dynamically create # fields — the browser throws SyntaxError: Private field '#field' must be declared in an enclosing class.
Constructor body ordering
When lowering, the order in the constructor must be:
super()call (if present)- Lowered field initializer assignments (in declaration order)
- Original constructor body statements
This matches TypeScript's tsc behavior exactly.
Affected Patterns
Any Angular component/directive/service that:
- Uses
inject()in a class field AND has another field that references the injected service - References a constructor parameter property in a class field initializer
- Extends a parent class and references parent properties in class field initializers
- Uses ES private fields (
#field) with initializers
Suggested Implementation
1. Add useDefineForClassFields option to TransformOptions
Rust (crates/oxc_angular_compiler/src/component/transform.rs):
pub struct TransformOptions {
// ... existing fields ...
/// Controls whether class fields use `[[Define]]` semantics (native ES class fields)
/// or are lowered to constructor assignments.
///
/// When `true` (default), class fields are kept as native ES class fields.
/// When `false`, instance class field initializers are moved into the constructor body.
pub use_define_for_class_fields: bool,
}
NAPI (napi/angular-compiler/src/lib.rs):
pub struct TransformOptions {
// ... existing fields ...
pub use_define_for_class_fields: Option<bool>,
}
2. Implement class field lowering pass
Create a new module crates/oxc_angular_compiler/src/component/class_field_lowering.rs with a lower_class_fields() function that:
- Parses the final transformed code
- For each class, identifies instance property definitions (non-static, with initializers)
- For regular fields: removes the declaration entirely from the class body
- For private fields: replaces the declaration with
#field;(no initializer) - Builds
this.field = value;assignment statements - Inserts assignments into the constructor body (after
super()if present, before existing body) - If no constructor exists, creates one (with
super(...args)for subclasses)
Call this pass at the end of transform_angular_file() when use_define_for_class_fields is false.
3. Wire through Vite plugin
Read from tsconfig (napi/angular-compiler/vite-plugin/index.ts):
export interface PluginOptions {
// ... existing options ...
useDefineForClassFields?: boolean;
}
The plugin should:
- Accept an explicit
useDefineForClassFieldsoption - If not provided, read it from the project's
tsconfig.json(following theextendschain) - Pass it to the Rust compiler via
TransformOptions
4. Suggested tests
Unit tests (in class_field_lowering.rs):
test_lower_simple_class_fields— basic field loweringtest_lower_fields_with_super— lowering withsuper()calltest_static_fields_not_lowered— static fields preservedtest_no_constructor_creates_one— constructor created when missingtest_no_constructor_with_super_class— constructor withsuper(...args)for subclassestest_private_fields_lowered— private field declaration kept, initializer movedtest_private_fields_declaration_kept_mixed— mixed private/regular fieldstest_lowered_fields_before_existing_constructor_body— ordering verificationtest_fields_without_initializer_not_lowered— declaration-only fields preserved
Integration tests (in integration_test.rs):
test_class_field_lowering_basic— full pipeline with@Componenttest_class_field_lowering_disabled_by_default— no lowering when option is truetest_class_field_lowering_with_inheritance—extends+super()+ private fieldstest_class_field_lowering_directive—@Directiveclasses
Verification
const { transformAngularFileSync } = require('@oxc-angular/vite/api');
const code = `
import { Component, inject, signal } from '@angular/core';
class ParentClass {
constructor(protected dep: any) {}
}
class MyService { onClose: any; }
@Component({ selector: 'app-test', template: '<div/>', standalone: true })
export class TestComponent extends ParentClass {
private svc = inject(MyService);
#event$ = this.svc.onClose.pipe();
#view = signal('home');
view = this.#view.asReadonly();
constructor(protected dep: any) {
super(dep);
console.log(this.#view());
}
}
`;
const result = transformAngularFileSync(code, 'test.ts',
{ sourcemap: false, jit: false, hmr: false, useDefineForClassFields: false },
{ templates: {}, styles: {} }
);
console.log(result.code);
// Expected output should show:
// 1. #event$; and #view; declarations kept in class body
// 2. All initializers moved to constructor body after super()
// 3. Original console.log() after the lowered assignments
// 4. Static ɵfac/ɵcmp fields untouched
Context
@oxc-angular/viteversion:0.0.8- Vite version:
8.0.0-beta.16(uses Rolldown for bundling) - Angular version: 19/20
- The OXC Angular compiler is used as a Vite plugin with
order: 'pre' - Vite 8's built-in OXC transformer runs after the plugin and strips remaining TypeScript
- Angular's standard tsconfig uses
useDefineForClassFields: false
Hướng dẫn đóng góp
Chưa lập chỉ mục được hướng dẫn đóng góp cho kho mã nguồn này
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Hướng nghiên cứu
Bắt đầu với crates/oxc_angular_compiler/src/component/transform.rs và napi/angular-compiler/src/lib.rs để theo dõi TransformOptions, sau đó kiểm tra napi/angular-compiler/vite-plugin/index.ts để xem cách cấu hình tsconfig. Xem lại integration_test.rs và các test được đề xuất trong class_field_lowering.rs, đồng thời xác minh rằng false hạ cấp các trình khởi tạo của instance trong khi vẫn giữ nguyên các khai báo private, các field static và thứ tự của constructor.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Đánh giá
- Công nghệ
- angular, rust, typescript, vite
- Lĩnh vực
- build-system, compilers, tooling
- Loại issue
- Lỗi
- Độ khó
- 5/5
- Thời gian dự kiến
- Hơn một tuần
- Mức độ hoạt động
- Đình trệ
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức phù hợp với người mới
- 35/100