Skip to content

Commit 2078247

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 1b1c074 commit 2078247

File tree

7 files changed

+375
-13
lines changed

7 files changed

+375
-13
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
}
@@ -1215,7 +1228,12 @@ fn build_jit_decorator_text(
12151228
///
12161229
/// This runs as a post-pass after JIT text-edits, converting TypeScript → JavaScript.
12171230
/// It handles abstract members, type annotations, parameter properties, etc.
1218-
fn strip_typescript(allocator: &Allocator, path: &str, code: &str) -> String {
1231+
fn strip_typescript(
1232+
allocator: &Allocator,
1233+
path: &str,
1234+
code: &str,
1235+
strip_uninitialized_fields: bool,
1236+
) -> String {
12191237
let source_type = SourceType::from_path(path).unwrap_or_default();
12201238
let parser_ret = Parser::new(allocator, code, source_type).parse();
12211239
if parser_ret.panicked {
@@ -1227,8 +1245,11 @@ fn strip_typescript(allocator: &Allocator, path: &str, code: &str) -> String {
12271245
let semantic_ret =
12281246
oxc_semantic::SemanticBuilder::new().with_excess_capacity(2.0).build(&program);
12291247

1230-
let ts_options =
1231-
oxc_transformer::TypeScriptOptions { only_remove_type_imports: true, ..Default::default() };
1248+
let ts_options = oxc_transformer::TypeScriptOptions {
1249+
only_remove_type_imports: true,
1250+
remove_class_fields_without_initializer: strip_uninitialized_fields,
1251+
..Default::default()
1252+
};
12321253

12331254
let transform_options =
12341255
oxc_transformer::TransformOptions { typescript: ts_options, ..Default::default() };
@@ -1560,7 +1581,8 @@ fn transform_angular_file_jit(
15601581
}
15611582

15621583
// 5. Strip TypeScript syntax from JIT output
1563-
result.code = strip_typescript(allocator, path, &result.code);
1584+
result.code =
1585+
strip_typescript(allocator, path, &result.code, options.strip_uninitialized_fields);
15641586

15651587
result
15661588
}
@@ -2300,6 +2322,50 @@ pub fn transform_angular_file(
23002322
}
23012323
}
23022324

2325+
// 4b. Strip uninitialized class fields (useDefineForClassFields: false behavior).
2326+
// This must process ALL classes, not just Angular-decorated ones, because
2327+
// legacy decorators (@Select, @Dispatch) set up prototype getters that get
2328+
// shadowed by _defineProperty(this, "field", void 0) when fields aren't stripped.
2329+
let mut uninitialized_field_spans: Vec<Span> = Vec::new();
2330+
if options.strip_uninitialized_fields {
2331+
for stmt in &parser_ret.program.body {
2332+
let class = match stmt {
2333+
Statement::ClassDeclaration(class) => Some(class.as_ref()),
2334+
Statement::ExportDefaultDeclaration(export) => match &export.declaration {
2335+
ExportDefaultDeclarationKind::ClassDeclaration(class) => {
2336+
Some(class.as_ref())
2337+
}
2338+
_ => None,
2339+
},
2340+
Statement::ExportNamedDeclaration(export) => match &export.declaration {
2341+
Some(Declaration::ClassDeclaration(class)) => Some(class.as_ref()),
2342+
_ => None,
2343+
},
2344+
_ => None,
2345+
};
2346+
let Some(class) = class else { continue };
2347+
for element in &class.body.body {
2348+
if let ClassElement::PropertyDefinition(prop) = element {
2349+
// Skip static fields — they follow different rules
2350+
if prop.r#static {
2351+
continue;
2352+
}
2353+
// Strip if: no initializer (value is None) OR has `declare` keyword
2354+
if prop.value.is_none() || prop.declare {
2355+
let field_span = prop.span;
2356+
// Remove any decorator spans that fall within this field span
2357+
// to avoid overlapping edits (which cause byte boundary panics).
2358+
decorator_spans_to_remove.retain(|dec_span| {
2359+
!(dec_span.start >= field_span.start
2360+
&& dec_span.end <= field_span.end)
2361+
});
2362+
uninitialized_field_spans.push(field_span);
2363+
}
2364+
}
2365+
}
2366+
}
2367+
}
2368+
23032369
// 5. Generate output code using span-based edits from the original AST.
23042370
// All edits reference positions in the original source and are applied in one pass.
23052371

@@ -2364,6 +2430,21 @@ pub fn transform_angular_file(
23642430
edits.push(Edit::delete(span.start, end as u32));
23652431
}
23662432

2433+
// Uninitialized field removal edits
2434+
for span in &uninitialized_field_spans {
2435+
let mut end = span.end as usize;
2436+
let bytes = source.as_bytes();
2437+
while end < bytes.len() {
2438+
let c = bytes[end];
2439+
if c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' {
2440+
end += 1;
2441+
} else {
2442+
break;
2443+
}
2444+
}
2445+
edits.push(Edit::delete(span.start, end as u32));
2446+
}
2447+
23672448
if let Some(edit) = ns_edit {
23682449
edits.push(edit);
23692450
}

0 commit comments

Comments
 (0)