Skip to content

Commit 0c3d78c

Browse files
ashley-hunterclaude
andcommitted
fix: strip uninitialized class fields (useDefineForClassFields: false)
When `useDefineForClassFields: false` (Angular's default), TypeScript treats uninitialized fields as type-only declarations that produce no JS output. The compiler was preserving these fields, causing `vite:oxc` to lower them to `_defineProperty(this, "field", void 0)` which shadows prototype getters set up by legacy decorators (@select, @dispatch). AOT path: scan all classes for PropertyDefinition nodes without initializers and emit Edit::delete() spans for them. JIT path: enable oxc_transformer's existing `remove_class_fields_without_initializer` option in strip_typescript(). Controlled by new `strip_uninitialized_fields` option (default: true). Closes #73 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7d4e9a0 commit 0c3d78c

File tree

5 files changed

+375
-8
lines changed

5 files changed

+375
-8
lines changed

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use std::path::Path;
99

1010
use oxc_allocator::{Allocator, Vec as OxcVec};
1111
use oxc_ast::ast::{
12-
Argument, ArrayExpressionElement, Declaration, ExportDefaultDeclarationKind, Expression,
13-
ImportDeclarationSpecifier, ImportOrExportKind, ObjectPropertyKind, PropertyKey, Statement,
12+
Argument, ArrayExpressionElement, ClassElement, Declaration, ExportDefaultDeclarationKind,
13+
Expression, ImportDeclarationSpecifier, ImportOrExportKind, ObjectPropertyKind, PropertyKey,
14+
Statement,
1415
};
1516
use oxc_diagnostics::OxcDiagnostic;
1617
use oxc_parser::Parser;
@@ -182,6 +183,17 @@ pub struct TransformOptions {
182183
/// This runs after Angular style encapsulation, so it applies to the same
183184
/// final CSS strings that are embedded in component definitions.
184185
pub minify_component_styles: bool,
186+
187+
/// Strip uninitialized class fields (matching `useDefineForClassFields: false` behavior).
188+
///
189+
/// When true, class fields without an explicit initializer (`= ...`) are removed
190+
/// from the output. This matches TypeScript's behavior when `useDefineForClassFields`
191+
/// is `false` (Angular's default), where such fields are type-only declarations.
192+
///
193+
/// Static fields and fields with initializers are always preserved.
194+
///
195+
/// Default: true (Angular projects always use `useDefineForClassFields: false`)
196+
pub strip_uninitialized_fields: bool,
185197
}
186198

187199
/// Input for host metadata when passed via TransformOptions.
@@ -232,6 +244,7 @@ impl Default for TransformOptions {
232244
// Class metadata for TestBed support (disabled by default)
233245
emit_class_metadata: false,
234246
minify_component_styles: false,
247+
strip_uninitialized_fields: true,
235248
}
236249
}
237250
}
@@ -1146,7 +1159,12 @@ fn build_jit_decorator_text(
11461159
///
11471160
/// This runs as a post-pass after JIT text-edits, converting TypeScript → JavaScript.
11481161
/// It handles abstract members, type annotations, parameter properties, etc.
1149-
fn strip_typescript(allocator: &Allocator, path: &str, code: &str) -> String {
1162+
fn strip_typescript(
1163+
allocator: &Allocator,
1164+
path: &str,
1165+
code: &str,
1166+
strip_uninitialized_fields: bool,
1167+
) -> String {
11501168
let source_type = SourceType::from_path(path).unwrap_or_default();
11511169
let parser_ret = Parser::new(allocator, code, source_type).parse();
11521170
if parser_ret.panicked {
@@ -1158,8 +1176,11 @@ fn strip_typescript(allocator: &Allocator, path: &str, code: &str) -> String {
11581176
let semantic_ret =
11591177
oxc_semantic::SemanticBuilder::new().with_excess_capacity(2.0).build(&program);
11601178

1161-
let ts_options =
1162-
oxc_transformer::TypeScriptOptions { only_remove_type_imports: true, ..Default::default() };
1179+
let ts_options = oxc_transformer::TypeScriptOptions {
1180+
only_remove_type_imports: true,
1181+
remove_class_fields_without_initializer: strip_uninitialized_fields,
1182+
..Default::default()
1183+
};
11631184

11641185
let transform_options =
11651186
oxc_transformer::TransformOptions { typescript: ts_options, ..Default::default() };
@@ -1442,7 +1463,8 @@ fn transform_angular_file_jit(
14421463
}
14431464

14441465
// 5. Strip TypeScript syntax from JIT output
1445-
result.code = strip_typescript(allocator, path, &result.code);
1466+
result.code =
1467+
strip_typescript(allocator, path, &result.code, options.strip_uninitialized_fields);
14461468

14471469
result
14481470
}
@@ -2170,6 +2192,50 @@ pub fn transform_angular_file(
21702192
}
21712193
}
21722194

2195+
// 4b. Strip uninitialized class fields (useDefineForClassFields: false behavior).
2196+
// This must process ALL classes, not just Angular-decorated ones, because
2197+
// legacy decorators (@Select, @Dispatch) set up prototype getters that get
2198+
// shadowed by _defineProperty(this, "field", void 0) when fields aren't stripped.
2199+
let mut uninitialized_field_spans: Vec<Span> = Vec::new();
2200+
if options.strip_uninitialized_fields {
2201+
for stmt in &parser_ret.program.body {
2202+
let class = match stmt {
2203+
Statement::ClassDeclaration(class) => Some(class.as_ref()),
2204+
Statement::ExportDefaultDeclaration(export) => match &export.declaration {
2205+
ExportDefaultDeclarationKind::ClassDeclaration(class) => {
2206+
Some(class.as_ref())
2207+
}
2208+
_ => None,
2209+
},
2210+
Statement::ExportNamedDeclaration(export) => match &export.declaration {
2211+
Some(Declaration::ClassDeclaration(class)) => Some(class.as_ref()),
2212+
_ => None,
2213+
},
2214+
_ => None,
2215+
};
2216+
let Some(class) = class else { continue };
2217+
for element in &class.body.body {
2218+
if let ClassElement::PropertyDefinition(prop) = element {
2219+
// Skip static fields — they follow different rules
2220+
if prop.r#static {
2221+
continue;
2222+
}
2223+
// Strip if: no initializer (value is None) OR has `declare` keyword
2224+
if prop.value.is_none() || prop.declare {
2225+
let field_span = prop.span;
2226+
// Remove any decorator spans that fall within this field span
2227+
// to avoid overlapping edits (which cause byte boundary panics).
2228+
decorator_spans_to_remove.retain(|dec_span| {
2229+
!(dec_span.start >= field_span.start
2230+
&& dec_span.end <= field_span.end)
2231+
});
2232+
uninitialized_field_spans.push(field_span);
2233+
}
2234+
}
2235+
}
2236+
}
2237+
}
2238+
21732239
// 5. Generate output code using span-based edits from the original AST.
21742240
// All edits reference positions in the original source and are applied in one pass.
21752241

@@ -2234,6 +2300,21 @@ pub fn transform_angular_file(
22342300
edits.push(Edit::delete(span.start, end as u32));
22352301
}
22362302

2303+
// Uninitialized field removal edits
2304+
for span in &uninitialized_field_spans {
2305+
let mut end = span.end as usize;
2306+
let bytes = source.as_bytes();
2307+
while end < bytes.len() {
2308+
let c = bytes[end];
2309+
if c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' {
2310+
end += 1;
2311+
} else {
2312+
break;
2313+
}
2314+
}
2315+
edits.push(Edit::delete(span.start, end as u32));
2316+
}
2317+
22372318
if let Some(edit) = ns_edit {
22382319
edits.push(edit);
22392320
}

0 commit comments

Comments
 (0)