diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index cd741e804..e4b137c2c 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -9,8 +9,9 @@ use std::path::Path; use oxc_allocator::{Allocator, Vec as OxcVec}; use oxc_ast::ast::{ - Argument, ArrayExpressionElement, Declaration, ExportDefaultDeclarationKind, Expression, - ImportDeclarationSpecifier, ImportOrExportKind, ObjectPropertyKind, PropertyKey, Statement, + Argument, ArrayExpressionElement, ClassElement, Declaration, ExportDefaultDeclarationKind, + Expression, ImportDeclarationSpecifier, ImportOrExportKind, ObjectPropertyKind, PropertyKey, + Statement, }; use oxc_diagnostics::OxcDiagnostic; use oxc_parser::Parser; @@ -182,6 +183,17 @@ pub struct TransformOptions { /// This runs after Angular style encapsulation, so it applies to the same /// final CSS strings that are embedded in component definitions. pub minify_component_styles: bool, + + /// Strip uninitialized class fields (matching `useDefineForClassFields: false` behavior). + /// + /// When true, class fields without an explicit initializer (`= ...`) are removed + /// from the output. This matches TypeScript's behavior when `useDefineForClassFields` + /// is `false` (Angular's default), where such fields are type-only declarations. + /// + /// Static fields and fields with initializers are always preserved. + /// + /// Default: true (Angular projects always use `useDefineForClassFields: false`) + pub strip_uninitialized_fields: bool, } /// Input for host metadata when passed via TransformOptions. @@ -232,6 +244,7 @@ impl Default for TransformOptions { // Class metadata for TestBed support (disabled by default) emit_class_metadata: false, minify_component_styles: false, + strip_uninitialized_fields: true, } } } @@ -1215,7 +1228,12 @@ fn build_jit_decorator_text( /// /// This runs as a post-pass after JIT text-edits, converting TypeScript → JavaScript. /// It handles abstract members, type annotations, parameter properties, etc. -fn strip_typescript(allocator: &Allocator, path: &str, code: &str) -> String { +fn strip_typescript( + allocator: &Allocator, + path: &str, + code: &str, + strip_uninitialized_fields: bool, +) -> String { let source_type = SourceType::from_path(path).unwrap_or_default(); let parser_ret = Parser::new(allocator, code, source_type).parse(); if parser_ret.panicked { @@ -1227,8 +1245,11 @@ fn strip_typescript(allocator: &Allocator, path: &str, code: &str) -> String { let semantic_ret = oxc_semantic::SemanticBuilder::new().with_excess_capacity(2.0).build(&program); - let ts_options = - oxc_transformer::TypeScriptOptions { only_remove_type_imports: true, ..Default::default() }; + let ts_options = oxc_transformer::TypeScriptOptions { + only_remove_type_imports: true, + remove_class_fields_without_initializer: strip_uninitialized_fields, + ..Default::default() + }; let transform_options = oxc_transformer::TransformOptions { typescript: ts_options, ..Default::default() }; @@ -1560,7 +1581,8 @@ fn transform_angular_file_jit( } // 5. Strip TypeScript syntax from JIT output - result.code = strip_typescript(allocator, path, &result.code); + result.code = + strip_typescript(allocator, path, &result.code, options.strip_uninitialized_fields); result } @@ -2300,6 +2322,111 @@ pub fn transform_angular_file( } } + // 4b. Strip uninitialized class fields (useDefineForClassFields: false behavior). + // This must process ALL classes, not just Angular-decorated ones, because + // legacy decorators (@Select, @Dispatch) set up prototype getters that get + // shadowed by _defineProperty(this, "field", void 0) when fields aren't stripped. + // + // For fields with non-Angular decorators, we also emit __decorate() calls on + // the prototype so the decorator can set up its getter/value (matching tsc). + struct StrippedFieldDecorate { + class_name: String, + class_body_end: u32, + member_name: String, + decorator_texts: std::vec::Vec, + } + let mut uninitialized_field_spans: Vec = Vec::new(); + let mut stripped_field_decorates: std::vec::Vec = std::vec::Vec::new(); + if options.strip_uninitialized_fields { + for stmt in &parser_ret.program.body { + let class = match stmt { + Statement::ClassDeclaration(class) => Some(class.as_ref()), + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => Some(class.as_ref()), + _ => None, + }, + Statement::ExportNamedDeclaration(export) => match &export.declaration { + Some(Declaration::ClassDeclaration(class)) => Some(class.as_ref()), + _ => None, + }, + _ => None, + }; + let Some(class) = class else { continue }; + let class_name = class.id.as_ref().map(|id| id.name.to_string()); + let class_body_end = class.body.span.end; + + for element in &class.body.body { + if let ClassElement::PropertyDefinition(prop) = element { + // Skip static fields — they follow different rules + if prop.r#static { + continue; + } + // Skip private fields (#foo) — these are JS runtime syntax, + // not TS type annotations. They declare a private slot on + // the class and must be preserved even without an initializer. + if matches!(prop.key, PropertyKey::PrivateIdentifier(_)) { + continue; + } + // Strip if: no initializer (value is None) OR has `declare` keyword + if prop.value.is_none() || prop.declare { + let field_span = prop.span; + // Remove any decorator spans that fall within this field span + // to avoid overlapping edits (which cause byte boundary panics). + decorator_spans_to_remove.retain(|dec_span| { + !(dec_span.start >= field_span.start && dec_span.end <= field_span.end) + }); + uninitialized_field_spans.push(field_span); + + // If the field has non-Angular decorators, collect them for + // __decorate() emission after the class. + if !prop.decorators.is_empty() { + let member_name = match &prop.key { + PropertyKey::StaticIdentifier(id) => id.name.to_string(), + _ => continue, + }; + let mut non_angular_texts: std::vec::Vec = std::vec::Vec::new(); + for decorator in &prop.decorators { + // Extract decorator name to check if it's Angular + let dec_name = match &decorator.expression { + Expression::CallExpression(call) => match &call.callee { + Expression::Identifier(id) => Some(id.name.as_str()), + Expression::StaticMemberExpression(m) => { + Some(m.property.name.as_str()) + } + _ => None, + }, + Expression::Identifier(id) => Some(id.name.as_str()), + _ => None, + }; + // Only emit __decorate for non-Angular decorators + if let Some(name) = dec_name { + if !ANGULAR_DECORATOR_NAMES.contains(&name) { + let expr_span = decorator.expression.span(); + non_angular_texts.push( + source + [expr_span.start as usize..expr_span.end as usize] + .to_string(), + ); + } + } + } + if !non_angular_texts.is_empty() { + if let Some(ref cn) = class_name { + stripped_field_decorates.push(StrippedFieldDecorate { + class_name: cn.clone(), + class_body_end, + member_name, + decorator_texts: non_angular_texts, + }); + } + } + } + } + } + } + } + } + // 5. Generate output code using span-based edits from the original AST. // All edits reference positions in the original source and are applied in one pass. @@ -2364,6 +2491,42 @@ pub fn transform_angular_file( edits.push(Edit::delete(span.start, end as u32)); } + // Uninitialized field removal edits + for span in &uninitialized_field_spans { + let mut end = span.end as usize; + let bytes = source.as_bytes(); + while end < bytes.len() { + let c = bytes[end]; + if c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' { + end += 1; + } else { + break; + } + } + edits.push(Edit::delete(span.start, end as u32)); + } + + // Emit __decorate() calls for non-Angular decorators on stripped fields. + // These go after the class body, matching tsc's output pattern. + if !stripped_field_decorates.is_empty() { + // Add tslib import if not already present + if !source.contains("__decorate") { + edits.push( + Edit::insert(0, "import { __decorate } from \"tslib\";\n".to_string()) + .with_priority(10), + ); + } + for entry in &stripped_field_decorates { + let decorate_call = format!( + "\n__decorate([{}], {}.prototype, \"{}\", void 0);", + entry.decorator_texts.join(", "), + entry.class_name, + entry.member_name, + ); + edits.push(Edit::insert(entry.class_body_end, decorate_call)); + } + } + if let Some(edit) = ns_edit { edits.push(edit); } diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 0e75b95a8..565381df3 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -9105,3 +9105,421 @@ export class MyComponent {} result.code ); } + +// ============================================================================= +// Strip uninitialized class fields (useDefineForClassFields: false) +// ============================================================================= + +#[test] +fn test_strip_uninitialized_class_fields() { + // When useDefineForClassFields is false (Angular default), uninitialized fields + // are type-only declarations and should produce no JavaScript output. + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ selector: 'app-test', template: '
' }) +export class TestComponent { + name: string; + count = 0; +} +"#; + let options = + ComponentTransformOptions { strip_uninitialized_fields: true, ..Default::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // Uninitialized field should be stripped + assert!( + !result.code.contains("name;") && !result.code.contains("name:"), + "Uninitialized field 'name' should be stripped. Got:\n{}", + result.code + ); + // Initialized field should be preserved + assert!( + result.code.contains("count = 0"), + "Initialized field 'count' should be preserved. Got:\n{}", + result.code + ); +} + +#[test] +fn test_strip_uninitialized_fields_non_angular_class() { + // Non-Angular classes should also have uninitialized fields stripped. + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +class PolicyStore { + allPolicies$: Observable; + initialized = false; +} + +@Component({ selector: 'app-test', template: '
' }) +export class TestComponent { + count = 0; +} +"#; + let options = + ComponentTransformOptions { strip_uninitialized_fields: true, ..Default::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + assert!( + !result.code.contains("allPolicies$"), + "Uninitialized field 'allPolicies$' should be stripped. Got:\n{}", + result.code + ); + assert!( + result.code.contains("initialized = false"), + "Initialized field should be preserved. Got:\n{}", + result.code + ); +} + +#[test] +fn test_static_fields_not_stripped() { + // Static uninitialized fields should NOT be stripped. + let allocator = Allocator::default(); + let source = r#" +class MyClass { + static label: string; + name: string; + count = 0; +} +"#; + let options = + ComponentTransformOptions { strip_uninitialized_fields: true, ..Default::default() }; + let result = transform_angular_file(&allocator, "test.ts", source, Some(&options), None); + + assert!( + result.code.contains("static label"), + "Static uninitialized field should be preserved. Got:\n{}", + result.code + ); + assert!( + !result.code.contains("\n name"), + "Instance uninitialized field should be stripped. Got:\n{}", + result.code + ); + assert!( + result.code.contains("count = 0"), + "Initialized field should be preserved. Got:\n{}", + result.code + ); +} + +#[test] +fn test_declare_fields_stripped() { + // Fields with the `declare` keyword should always be stripped. + let allocator = Allocator::default(); + let source = r#" +class MyClass { + declare name: string; + count = 0; +} +"#; + let options = + ComponentTransformOptions { strip_uninitialized_fields: true, ..Default::default() }; + let result = transform_angular_file(&allocator, "test.ts", source, Some(&options), None); + + assert!( + !result.code.contains("declare") && !result.code.contains("name"), + "Declare field should be stripped. Got:\n{}", + result.code + ); + assert!( + result.code.contains("count = 0"), + "Initialized field should be preserved. Got:\n{}", + result.code + ); +} + +#[test] +fn test_initialized_fields_preserved() { + // All forms of initialized fields must be preserved. + let allocator = Allocator::default(); + let source = r#" +class MyClass { + items = []; + callback = () => {}; + flag = false; + ref = null; + str = 'hello'; +} +"#; + let options = + ComponentTransformOptions { strip_uninitialized_fields: true, ..Default::default() }; + let result = transform_angular_file(&allocator, "test.ts", source, Some(&options), None); + + assert!(result.code.contains("items = []"), "items should be preserved. Got:\n{}", result.code); + assert!( + result.code.contains("callback = () => {}"), + "callback should be preserved. Got:\n{}", + result.code + ); + assert!( + result.code.contains("flag = false"), + "flag should be preserved. Got:\n{}", + result.code + ); + assert!(result.code.contains("ref = null"), "ref should be preserved. Got:\n{}", result.code); + assert!( + result.code.contains("str = 'hello'"), + "str should be preserved. Got:\n{}", + result.code + ); +} + +#[test] +fn test_strip_uninitialized_fields_disabled() { + // When the option is false, uninitialized fields should be preserved. + let allocator = Allocator::default(); + let source = r#" +class MyClass { + name: string; + count = 0; +} +"#; + let options = + ComponentTransformOptions { strip_uninitialized_fields: false, ..Default::default() }; + let result = transform_angular_file(&allocator, "test.ts", source, Some(&options), None); + + assert!( + result.code.contains("name"), + "With option disabled, uninitialized field should be preserved. Got:\n{}", + result.code + ); + assert!( + result.code.contains("count = 0"), + "Initialized field should be preserved. Got:\n{}", + result.code + ); +} + +#[test] +fn test_strip_decorated_uninitialized_field() { + // Fields with non-Angular decorators and no initializer should be fully stripped. + // This is the @Select/@Dispatch scenario from the bug report. + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ selector: 'app-test', template: '
' }) +export class TestComponent { + @Select(SecurityOverviewState.filters) filters$: Observable; + @Dispatch() clearChartsData = () => new ClearChartsData(); + count = 0; +} +"#; + let options = + ComponentTransformOptions { strip_uninitialized_fields: true, ..Default::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + + // The uninitialized @Select field should be stripped from the class body + assert!( + !result.code.contains(" filters$"), + "Uninitialized @Select field declaration should be stripped from class body. Got:\n{}", + result.code + ); + // But a __decorate call should be emitted for the @Select decorator + assert!( + result.code.contains("__decorate([Select(SecurityOverviewState.filters)]"), + "__decorate call should be emitted for @Select decorator. Got:\n{}", + result.code + ); + assert!( + result.code.contains("TestComponent.prototype, \"filters$\", void 0"), + "__decorate should target prototype. Got:\n{}", + result.code + ); + // Initialized @Dispatch field should be preserved + assert!( + result.code.contains("clearChartsData"), + "Initialized @Dispatch field should be preserved. Got:\n{}", + result.code + ); + assert!( + result.code.contains("count = 0"), + "Initialized field should be preserved. Got:\n{}", + result.code + ); +} + +#[test] +fn test_strip_fields_jit_mode() { + // JIT mode should also strip uninitialized fields. + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ selector: 'app-test', template: '
' }) +export class TestComponent { + name: string; + count = 0; +} +"#; + let options = ComponentTransformOptions { + jit: true, + strip_uninitialized_fields: true, + ..Default::default() + }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // Uninitialized field should be stripped + assert!( + !result.code.contains("name;") && !result.code.contains("name:"), + "Uninitialized field 'name' should be stripped in JIT mode. Got:\n{}", + result.code + ); + // Initialized field should be preserved + assert!( + result.code.contains("count = 0"), + "Initialized field 'count' should be preserved in JIT mode. Got:\n{}", + result.code + ); +} + +#[test] +fn test_private_fields_not_stripped() { + // JavaScript private fields (#foo) are real runtime declarations, not TypeScript + // type annotations. Even without an initializer, #foo declares a private slot + // on the class and must NOT be stripped. Stripping them causes rolldown to panic + // because this.#foo references remain but the declaration is gone. + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ selector: 'test', template: '' }) +export class TestComponent { + #privateUninitialized: string; + #privateInitialized = 'hello'; + publicUninitialized: string; + publicInitialized = 'world'; + + method() { + console.log(this.#privateUninitialized); + } +} +"#; + let options = + ComponentTransformOptions { strip_uninitialized_fields: true, ..Default::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // Private fields must NOT be stripped (they are JS runtime syntax). + // Check for the field DECLARATION (not just any reference like this.#foo in methods). + // A private field declaration looks like " #privateUninitialized;" on its own line. + assert!( + result.code.contains(" #privateUninitialized"), + "Uninitialized private field declaration must be preserved. Got:\n{}", + result.code + ); + assert!( + result.code.contains("#privateInitialized = 'hello'"), + "Initialized private field must be preserved. Got:\n{}", + result.code + ); + // Public uninitialized field SHOULD be stripped (TS type annotation) + assert!( + !result.code.contains("publicUninitialized"), + "Public uninitialized field should be stripped. Got:\n{}", + result.code + ); + // Public initialized field must be preserved + assert!( + result.code.contains("publicInitialized = 'world'"), + "Public initialized field must be preserved. Got:\n{}", + result.code + ); +} + +#[test] +fn test_strip_decorated_field_emits_decorate_call() { + // When an uninitialized field has a non-Angular decorator (@Select, @Dispatch, etc.), + // the field declaration must be stripped but a __decorate() call must be emitted + // on the prototype so the decorator can set up its getter/value. + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +@Component({ selector: 'test', template: '' }) +export class TestComponent { + uninitialized: string; + @Select('someSelector') selectedValue$: Observable; + @Dispatch() doAction = () => ({ type: 'ACTION' }); + initialized = 'hello'; + #privateField: string; + + method() { + console.log(this.selectedValue$); + } +} +"#; + let options = + ComponentTransformOptions { strip_uninitialized_fields: true, ..Default::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + // The @Select field should be stripped from class body + // (check it's not declared as a field — but references in methods are fine) + assert!( + !result.code.contains(" selectedValue$"), + "Uninitialized @Select field should be stripped from class body. Got:\n{}", + result.code + ); + + // A __decorate call should be emitted for the @Select decorator + assert!( + result.code.contains("__decorate([Select('someSelector')]"), + "__decorate call should be emitted for @Select decorator. Got:\n{}", + result.code + ); + assert!( + result.code.contains("TestComponent.prototype, \"selectedValue$\", void 0"), + "__decorate should target prototype with void 0 descriptor. Got:\n{}", + result.code + ); + + // tslib import should be present + assert!( + result.code.contains("import { __decorate } from \"tslib\""), + "Should import __decorate from tslib. Got:\n{}", + result.code + ); + + // Initialized @Dispatch field should be preserved in class body + assert!( + result.code.contains("doAction"), + "Initialized @Dispatch field should be preserved. Got:\n{}", + result.code + ); + + // Plain uninitialized field should be stripped entirely (no __decorate) + assert!( + !result.code.contains("uninitialized"), + "Plain uninitialized field should be stripped. Got:\n{}", + result.code + ); + + // Private field should be preserved + assert!( + result.code.contains(" #privateField"), + "Private field should be preserved. Got:\n{}", + result.code + ); + + // Initialized plain field should be preserved + assert!( + result.code.contains("initialized = 'hello'"), + "Initialized field should be preserved. Got:\n{}", + result.code + ); +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_angular_param_decorators_on_members.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_angular_param_decorators_on_members.snap index f4b64ba62..f00ec02d4 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_angular_param_decorators_on_members.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_angular_param_decorators_on_members.snap @@ -8,8 +8,6 @@ function Custom() { return function(t, k) {}; } let MyService = class MyService { - token; - optionalDep; customProp = ""; }; __decorate([Custom()], MyService.prototype, "customProp", void 0); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_full_component.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_full_component.snap index fc1b97be4..79b81a4a6 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_full_component.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_full_component.snap @@ -1,6 +1,5 @@ --- source: crates/oxc_angular_compiler/tests/integration_test.rs -assertion_line: 6295 expression: result.code --- import { Component, signal } from "@angular/core"; @@ -11,7 +10,6 @@ import { __decorate } from "tslib"; import __NG_CLI_RESOURCE__0 from "angular:jit:template:file;./app.html"; import __NG_CLI_RESOURCE__1 from "angular:jit:style:file;./app.css"; let App = class App { - titleService; title = signal("app"); constructor(titleService) { this.titleService = titleService; diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_reference_angular_member_decorators.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_reference_angular_member_decorators.snap index 2f5c3fe7b..8a917668d 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_reference_angular_member_decorators.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__jit_reference_angular_member_decorators.snap @@ -6,11 +6,8 @@ import { Injectable, Inject, Optional, Input, Output, ViewChild, HostListener, H import { __decorate } from "tslib"; let MyService = class MyService { myInput = ""; - myOutput; - myViewChild; isActive = false; onClick(event) {} - myContent; constructor(token, optService) { this.token = token; this.optService = optService; diff --git a/napi/angular-compiler/e2e/compare/fixtures/edge-cases/class-field-declarations.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/edge-cases/class-field-declarations.fixture.ts index ac2d54489..943286fbc 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/edge-cases/class-field-declarations.fixture.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/edge-cases/class-field-declarations.fixture.ts @@ -46,8 +46,6 @@ export class ParameterPropertyComponent { } `.trim(), expectedFeatures: ['ɵɵdefineComponent'], - // Skip because class field declarations are a known cosmetic difference - // that doesn't affect runtime behavior skip: true, skipReason: 'Known cosmetic difference: TypeScript emits explicit field declarations for parameter properties with useDefineForClassFields:true, OXC does not. Both are functionally equivalent.', @@ -89,7 +87,6 @@ export class ClassFieldInitializerComponent { } `.trim(), expectedFeatures: ['ɵɵdefineComponent'], - // Skip because class field declarations are a known cosmetic difference skip: true, skipReason: 'Known cosmetic difference: TypeScript emits explicit field declarations for parameter properties with useDefineForClassFields:true, OXC does not. Both are functionally equivalent.', diff --git a/napi/angular-compiler/e2e/compare/fixtures/regressions/bitwarden-nonexported-class.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/regressions/bitwarden-nonexported-class.fixture.ts index 14ca45c1f..47ea7e4b9 100644 --- a/napi/angular-compiler/e2e/compare/fixtures/regressions/bitwarden-nonexported-class.fixture.ts +++ b/napi/angular-compiler/e2e/compare/fixtures/regressions/bitwarden-nonexported-class.fixture.ts @@ -2,11 +2,10 @@ * Regression fixture: Non-exported class field declarations * * This tests the difference in how oxc and ng handle non-exported classes - * that appear alongside Angular components. The difference is cosmetic - - * both produce identical runtime behavior, but the code structure differs: - * - * - oxc: Field declarations appear at class level, then constructor assigns - * - ng: All field assignments happen only in constructor body + * that appear alongside Angular components. OXC strips uninitialized fields + * (matching useDefineForClassFields:false, Angular's default), but the + * comparison test's TS compiler uses useDefineForClassFields:true (ESNext default) + * which preserves them as bare declarations. In real Angular projects both agree. * * Found in bitwarden-clients project in files like: * - import-chrome.component.ts (ChromeLogin class) @@ -50,9 +49,13 @@ const fixture: Fixture = { className: 'TestComponent', sourceCode, expectedFeatures: ['ɵɵdefineComponent', 'ɵɵtext'], - // This is a cosmetic difference - both outputs work correctly at runtime - // The difference is in class field declaration style, not functionality - skip: false, + // OXC strips uninitialized fields (useDefineForClassFields:false behavior) but the + // comparison test's TS compiler uses useDefineForClassFields:true (ESNext default), + // which preserves them as `name;` declarations. In real Angular projects both compilers + // agree because tsconfig sets useDefineForClassFields:false. + skip: true, + skipReason: + 'Known cosmetic difference: comparison test uses useDefineForClassFields:true (ESNext default) while OXC strips uninitialized fields (useDefineForClassFields:false, Angular default). Both match in real Angular projects.', } export const fixtures = [fixture] diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 375efcc0c..e7da93e9a 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -833,6 +833,13 @@ export interface TransformOptions { * final CSS strings that are embedded in generated component definitions. */ minifyComponentStyles?: boolean + /** + * Strip uninitialized class fields (matching `useDefineForClassFields: false` behavior). + * + * When true (default), class fields without initializers are removed, + * matching TypeScript's `useDefineForClassFields: false` behavior. + */ + stripUninitializedFields?: boolean /** * Resolved import paths for host directives and other imports. * diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 231a1a99c..b6e7740b1 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -196,6 +196,12 @@ pub struct TransformOptions { /// final CSS strings that are embedded in generated component definitions. pub minify_component_styles: Option, + /// Strip uninitialized class fields (matching `useDefineForClassFields: false` behavior). + /// + /// When true (default), class fields without initializers are removed, + /// matching TypeScript's `useDefineForClassFields: false` behavior. + pub strip_uninitialized_fields: Option, + /// Resolved import paths for host directives and other imports. /// /// Maps local identifier name (e.g., "AriaDisableDirective") to the resolved @@ -238,6 +244,7 @@ impl From for RustTransformOptions { // Class metadata for TestBed support emit_class_metadata: options.emit_class_metadata.unwrap_or(false), minify_component_styles: options.minify_component_styles.unwrap_or(false), + strip_uninitialized_fields: options.strip_uninitialized_fields.unwrap_or(true), } } }