From 932bf3e8ce8ad35833079c2e788c6a88df37ecb2 Mon Sep 17 00:00:00 2001 From: Damian Kaczmarek Date: Wed, 21 Oct 2026 18:12:53 -0500 Subject: [PATCH 1/4] fix(linker): link declarations nested in arrays, objects and other expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Babel's linker links every ɵɵngDeclare* call; this one walked only some expression kinds and missed declarations inside array and object literals, `new` arguments, member and binary expressions, templates and awaits. A library compiled with Deepkit's runtime types records each static initializer as a function in a `static __type = [...]` array, so every class carried an unlinked copy of its declarations, whose `ngImport: i0` kept all of @angular/core in the bundle (+130 KB minified in an app using @deepkit/desktop-ui). --- crates/oxc_angular_compiler/src/linker/mod.rs | 62 +++++++++++++++++++ .../oxc_angular_compiler/tests/linker_test.rs | 18 ++++++ 2 files changed, 80 insertions(+) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 0e9e43695..28f330127 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -386,6 +386,68 @@ fn walk_expression( Expression::ClassExpression(class_expr) => { walk_class_body(&class_expr.body, source, filename, options, edits); } + // Declarations nested in data, as Babel's linker visits every call: Deepkit's runtime types record each + // static property's initializer as a function in a `static __type = [...]` array, so a class compiled + // with them carries a second `ɵɵngDeclareComponent(...)` there. Unlinked, its `ngImport: i0` keeps all of + // @angular/core in the bundle. + Expression::ArrayExpression(array) => { + for element in &array.elements { + match element { + oxc_ast::ast::ArrayExpressionElement::SpreadElement(spread) => { + walk_expression(&spread.argument, source, filename, options, edits); + } + oxc_ast::ast::ArrayExpressionElement::Elision(_) => {} + other => { + if let Some(expr) = other.as_expression() { + walk_expression(expr, source, filename, options, edits); + } + } + } + } + } + Expression::ObjectExpression(object) => { + for property in &object.properties { + match property { + oxc_ast::ast::ObjectPropertyKind::ObjectProperty(prop) => { + walk_expression(&prop.value, source, filename, options, edits); + } + oxc_ast::ast::ObjectPropertyKind::SpreadProperty(spread) => { + walk_expression(&spread.argument, source, filename, options, edits); + } + } + } + } + Expression::NewExpression(new_expr) => { + walk_expression(&new_expr.callee, source, filename, options, edits); + for arg in &new_expr.arguments { + if let Argument::SpreadElement(_) = arg { + continue; + } + walk_expression(arg.to_expression(), source, filename, options, edits); + } + } + Expression::StaticMemberExpression(member) => { + walk_expression(&member.object, source, filename, options, edits); + } + Expression::ComputedMemberExpression(member) => { + walk_expression(&member.object, source, filename, options, edits); + walk_expression(&member.expression, source, filename, options, edits); + } + Expression::UnaryExpression(unary) => { + walk_expression(&unary.argument, source, filename, options, edits); + } + Expression::BinaryExpression(binary) => { + walk_expression(&binary.left, source, filename, options, edits); + walk_expression(&binary.right, source, filename, options, edits); + } + Expression::AwaitExpression(await_expr) => { + walk_expression(&await_expr.argument, source, filename, options, edits); + } + Expression::TemplateLiteral(template) => { + for expr in &template.expressions { + walk_expression(expr, source, filename, options, edits); + } + } _ => {} } } diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index b98c0f551..4dabd34f2 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -551,3 +551,21 @@ export { P }; result.code ); } + +/// Babel's linker links every declaration call; Deepkit's runtime types keep a copy of each static initializer in a +/// `__type` array, whose declarations must not stay unlinked (their `ngImport: i0` keeps all of @angular/core) +#[test] +fn test_link_declarations_nested_in_arrays_and_objects() { + let allocator = Allocator::default(); + let code = r#"import * as i0 from "@angular/core"; +class Cmp { + static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }); } + static { this.__type = ['ɵfac', function () { return i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }); }]; } +} +const table = { factory: () => i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }) }; +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!(!result.code.contains("ɵɵngDeclare"), "every declaration links:\n{}", result.code); + assert!(!result.code.contains("ngImport"), "{}", result.code); +} From 20f0e0a57a232c5fdc15222f8c984a963e3d73c8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 23 Oct 2026 15:56:17 +0800 Subject: [PATCH 2/4] fix(linker): visit every call, like Babel's linker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-written walker only went into the statements and expressions it listed, so declarations in an IIFE callee, a spread argument, a statement head, a computed key, a superclass, a tagged template, an optional call or a default value stayed unlinked. An oxc Visit now reaches every call. A linked factory that starts a longer statement, such as `ɵɵngDeclareFactory(…).name;`, gets parentheses (and a `;` before them), like Babel's output, so it is not read as a function declaration. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/oxc_angular_compiler/src/linker/mod.rs | 339 +++--------------- .../oxc_angular_compiler/tests/linker_test.rs | 89 ++++- 2 files changed, 121 insertions(+), 307 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 28f330127..6b3d272f6 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -35,11 +35,13 @@ use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, ArrayExpressionElement, BindingPattern, CallExpression, Expression, ObjectExpression, - ObjectPropertyKind, Program, PropertyKey, Statement, + Argument, ArrayExpressionElement, BindingPattern, CallExpression, ExportDefaultDeclaration, + Expression, ExpressionStatement, ObjectExpression, ObjectPropertyKind, Program, PropertyKey, + Statement, }; +use oxc_ast_visit::{Visit, walk}; use oxc_parser::Parser; -use oxc_span::{GetSpan, SourceType}; +use oxc_span::{GetSpan, SourceType, Span}; use crate::AngularVersion; use crate::optimizer::Edit; @@ -143,7 +145,10 @@ pub fn link_with_options( LinkResult { code: linked_code, map, linked: true } } -/// Recursively walk the AST to find all ɵɵngDeclare* calls and generate edits. +/// Find all ɵɵngDeclare* calls and generate edits. +/// +/// Like Babel's linker, which visits every `CallExpression`, this visits every call wherever it sits +/// (class members, array and object literals, IIFE callees, conditions, decorators, ...). fn collect_declaration_edits( program: &Program<'_>, source: &str, @@ -151,304 +156,60 @@ fn collect_declaration_edits( options: &LinkOptions, edits: &mut Vec, ) { - for stmt in &program.body { - walk_statement(stmt, source, filename, options, edits); - } + DeclarationCollector { source, filename, options, edits, statement_start: None } + .visit_program(program); } -/// Walk a statement looking for ɵɵngDeclare* calls. -fn walk_statement( - stmt: &Statement<'_>, - source: &str, - filename: &str, - options: &LinkOptions, - edits: &mut Vec, -) { - match stmt { - Statement::ExpressionStatement(expr_stmt) => { - walk_expression(&expr_stmt.expression, source, filename, options, edits); - } - Statement::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, filename, options, edits); - } - Statement::VariableDeclaration(var_decl) => { - for decl in &var_decl.declarations { - if let Some(init) = &decl.init { - walk_expression(init, source, filename, options, edits); - } - } - } - Statement::ReturnStatement(ret) => { - if let Some(ref arg) = ret.argument { - walk_expression(arg, source, filename, options, edits); - } - } - Statement::BlockStatement(block) => { - for stmt in &block.body { - walk_statement(stmt, source, filename, options, edits); - } - } - Statement::IfStatement(if_stmt) => { - walk_statement(&if_stmt.consequent, source, filename, options, edits); - if let Some(ref alt) = if_stmt.alternate { - walk_statement(alt, source, filename, options, edits); - } - } - Statement::ForStatement(for_stmt) => { - walk_statement(&for_stmt.body, source, filename, options, edits); - } - Statement::ForInStatement(for_in) => { - walk_statement(&for_in.body, source, filename, options, edits); - } - Statement::ForOfStatement(for_of) => { - walk_statement(&for_of.body, source, filename, options, edits); - } - Statement::WhileStatement(while_stmt) => { - walk_statement(&while_stmt.body, source, filename, options, edits); - } - Statement::DoWhileStatement(do_while) => { - walk_statement(&do_while.body, source, filename, options, edits); - } - Statement::TryStatement(try_stmt) => { - for stmt in &try_stmt.block.body { - walk_statement(stmt, source, filename, options, edits); - } - if let Some(ref handler) = try_stmt.handler { - for stmt in &handler.body.body { - walk_statement(stmt, source, filename, options, edits); - } - } - if let Some(ref finalizer) = try_stmt.finalizer { - for stmt in &finalizer.body { - walk_statement(stmt, source, filename, options, edits); - } - } - } - Statement::SwitchStatement(switch_stmt) => { - for case in &switch_stmt.cases { - for stmt in &case.consequent { - walk_statement(stmt, source, filename, options, edits); - } - } - } - Statement::LabeledStatement(labeled) => { - walk_statement(&labeled.body, source, filename, options, edits); - } - Statement::FunctionDeclaration(func_decl) => { - if let Some(ref body) = func_decl.body { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); - } - } - } - Statement::ExportDeclaration(export_decl) => { - walk_declaration(&export_decl.declaration, source, filename, options, edits); - } - Statement::ExportDefaultDeclaration(export_default) => match &export_default.declaration { - oxc_ast::ast::ExportDefaultDeclarationKind::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, filename, options, edits); - } - oxc_ast::ast::ExportDefaultDeclarationKind::FunctionDeclaration(func_decl) => { - if let Some(ref body) = func_decl.body { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); - } - } - } - _ => { - if let Some(expr) = export_default.declaration.as_expression() { - walk_expression(expr, source, filename, options, edits); - } - } - }, - _ => {} - } +struct DeclarationCollector<'s> { + source: &'s str, + filename: &'s str, + options: &'s LinkOptions, + edits: &'s mut Vec, + /// The expression of the expression statement (`true`) or `export default` (`false`) being visited + statement_start: Option<(Span, bool)>, } -/// Walk a class body looking for ɵɵngDeclare* calls in property definitions and static blocks. -fn walk_class_body( - body: &oxc_ast::ast::ClassBody<'_>, - source: &str, - filename: &str, - options: &LinkOptions, - edits: &mut Vec, -) { - for element in &body.body { - if let oxc_ast::ast::ClassElement::PropertyDefinition(prop) = element - && let Some(ref value) = prop.value - { - walk_expression(value, source, filename, options, edits); - } - if let oxc_ast::ast::ClassElement::StaticBlock(block) = element { - for stmt in &block.body { - walk_statement(stmt, source, filename, options, edits); - } - } - if let oxc_ast::ast::ClassElement::MethodDefinition(method) = element - && let Some(ref body) = method.value.body - { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); - } - } +impl DeclarationCollector<'_> { + fn visit_statement_expression<'a>(&mut self, expr: &Expression<'a>, is_statement: bool) { + let outer = self.statement_start.replace((expr.span(), is_statement)); + self.visit_expression(expr); + self.statement_start = outer; } } -/// Walk a declaration (from export statements) looking for ɵɵngDeclare* calls. -fn walk_declaration( - decl: &oxc_ast::ast::Declaration<'_>, - source: &str, - filename: &str, - options: &LinkOptions, - edits: &mut Vec, -) { - match decl { - oxc_ast::ast::Declaration::VariableDeclaration(var_decl) => { - for d in &var_decl.declarations { - if let Some(init) = &d.init { - walk_expression(init, source, filename, options, edits); - } - } - } - oxc_ast::ast::Declaration::FunctionDeclaration(func_decl) => { - if let Some(ref body) = func_decl.body { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); - } - } - } - oxc_ast::ast::Declaration::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, filename, options, edits); +impl<'a> Visit<'a> for DeclarationCollector<'_> { + fn visit_expression_statement(&mut self, stmt: &ExpressionStatement<'a>) { + self.visit_statement_expression(&stmt.expression, true); + } + + fn visit_export_default_declaration(&mut self, decl: &ExportDefaultDeclaration<'a>) { + match decl.declaration.as_expression() { + Some(expr) => self.visit_statement_expression(expr, false), + None => walk::walk_export_default_declaration(self, decl), } - _ => {} } -} -/// Walk an expression looking for ɵɵngDeclare* calls. -fn walk_expression( - expr: &Expression<'_>, - source: &str, - filename: &str, - options: &LinkOptions, - edits: &mut Vec, -) { - match expr { - Expression::CallExpression(call) => { - if let Some(name) = get_declare_name(call) - && let Some(edit) = link_declaration(name, call, source, filename, options) + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if let Some(name) = get_declare_name(call) + && let Some(mut edit) = + link_declaration(name, call, self.source, self.filename, self.options) + { + // A linked factory is a `function` expression: where it starts a longer statement, such as + // `ɵɵngDeclareFactory(…).name;`, it would be read as a declaration, so it gets parentheses (and a `;`, so + // that they do not call the line before) + if let Some((expr, is_statement)) = self.statement_start + && expr.start == call.span.start + && expr != call.span { - edits.push(edit); - return; - } - // Walk arguments recursively - for arg in &call.arguments { - if let Argument::SpreadElement(_) = arg { - continue; - } - walk_expression(arg.to_expression(), source, filename, options, edits); - } - } - Expression::AssignmentExpression(assign) => { - walk_expression(&assign.right, source, filename, options, edits); - } - Expression::SequenceExpression(seq) => { - for expr in &seq.expressions { - walk_expression(expr, source, filename, options, edits); - } - } - Expression::ConditionalExpression(cond) => { - walk_expression(&cond.consequent, source, filename, options, edits); - walk_expression(&cond.alternate, source, filename, options, edits); - } - Expression::LogicalExpression(logical) => { - walk_expression(&logical.left, source, filename, options, edits); - walk_expression(&logical.right, source, filename, options, edits); - } - Expression::ParenthesizedExpression(paren) => { - walk_expression(&paren.expression, source, filename, options, edits); - } - Expression::ArrowFunctionExpression(arrow) => { - if let Some(body) = arrow.get_function_body() { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); - } - } else if let Some(expr) = arrow.get_expression() { - walk_expression(expr, source, filename, options, edits); - } - } - Expression::FunctionExpression(func) => { - if let Some(body) = &func.body { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); - } - } - } - Expression::ClassExpression(class_expr) => { - walk_class_body(&class_expr.body, source, filename, options, edits); - } - // Declarations nested in data, as Babel's linker visits every call: Deepkit's runtime types record each - // static property's initializer as a function in a `static __type = [...]` array, so a class compiled - // with them carries a second `ɵɵngDeclareComponent(...)` there. Unlinked, its `ngImport: i0` keeps all of - // @angular/core in the bundle. - Expression::ArrayExpression(array) => { - for element in &array.elements { - match element { - oxc_ast::ast::ArrayExpressionElement::SpreadElement(spread) => { - walk_expression(&spread.argument, source, filename, options, edits); - } - oxc_ast::ast::ArrayExpressionElement::Elision(_) => {} - other => { - if let Some(expr) = other.as_expression() { - walk_expression(expr, source, filename, options, edits); - } - } - } - } - } - Expression::ObjectExpression(object) => { - for property in &object.properties { - match property { - oxc_ast::ast::ObjectPropertyKind::ObjectProperty(prop) => { - walk_expression(&prop.value, source, filename, options, edits); - } - oxc_ast::ast::ObjectPropertyKind::SpreadProperty(spread) => { - walk_expression(&spread.argument, source, filename, options, edits); - } - } - } - } - Expression::NewExpression(new_expr) => { - walk_expression(&new_expr.callee, source, filename, options, edits); - for arg in &new_expr.arguments { - if let Argument::SpreadElement(_) = arg { - continue; - } - walk_expression(arg.to_expression(), source, filename, options, edits); - } - } - Expression::StaticMemberExpression(member) => { - walk_expression(&member.object, source, filename, options, edits); - } - Expression::ComputedMemberExpression(member) => { - walk_expression(&member.object, source, filename, options, edits); - walk_expression(&member.expression, source, filename, options, edits); - } - Expression::UnaryExpression(unary) => { - walk_expression(&unary.argument, source, filename, options, edits); - } - Expression::BinaryExpression(binary) => { - walk_expression(&binary.left, source, filename, options, edits); - walk_expression(&binary.right, source, filename, options, edits); - } - Expression::AwaitExpression(await_expr) => { - walk_expression(&await_expr.argument, source, filename, options, edits); - } - Expression::TemplateLiteral(template) => { - for expr in &template.expressions { - walk_expression(expr, source, filename, options, edits); + let semicolon = if is_statement { ";" } else { "" }; + edit.replacement = format!("{semicolon}({})", edit.replacement); } + // The edit replaces the whole call, so a declaration nested in its arguments + // must not get an overlapping edit of its own. + self.edits.push(edit); + return; } - _ => {} + walk::walk_call_expression(self, call); } } diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 4dabd34f2..cb30784ec 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -3,6 +3,8 @@ use oxc_allocator::Allocator; use oxc_angular_compiler::AngularVersion; use oxc_angular_compiler::linker::{LinkOptions, link, link_with_options}; +use oxc_parser::Parser; +use oxc_span::SourceType; /// Helper to build a ɵɵngDeclareDirective source with a given inputs block. fn make_directive_source(inputs_block: &str) -> String { @@ -409,6 +411,75 @@ MyDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.0.0 ); } +/// Babel's linker links every declaration call; Deepkit's runtime types keep a copy of each static initializer in a +/// `__type` array, whose declarations must not stay unlinked (their `ngImport: i0` keeps all of @angular/core) +#[test] +fn test_link_declarations_nested_in_arrays_and_objects() { + let allocator = Allocator::default(); + let code = r#"import * as i0 from "@angular/core"; +class Cmp { + static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }); } + static { this.__type = ['ɵfac', function () { return i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }); }]; } +} +const table = { factory: () => i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }) }; +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!(!result.code.contains("ɵɵngDeclare"), "every declaration links:\n{}", result.code); + assert!(!result.code.contains("ngImport"), "{}", result.code); +} + +/// Like Babel's linker, every call is visited, whatever expression or statement holds it. +#[test] +fn test_link_declarations_in_any_position() { + let d = r#"i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: Svc, providedIn: "root" })"#; + let positions = [ + format!("(function () {{ Svc.ɵprov = {d}; }})();"), + format!("(() => {{ Svc.ɵprov = {d}; }})();"), + format!("f(...[{d}]);"), + format!("if ({d}) {{}}"), + format!("for (let x = {d}; {d}; {d}) break;"), + format!("for (const x of [{d}]);"), + format!("switch ({d}) {{ case {d}: break; }}"), + format!("class A extends ({d}, Object) {{ [{d}] = 1; static accessor p = {d}; }}"), + format!("({{ [{d}]: 1 }});"), + format!("tag`${{{d}}}`;"), + format!("f?.({d}); a?.[{d}];"), + format!("function* g(a = {d}) {{ yield {d}; }}"), + format!("const {{ a = {d} }} = {{}}; x[{d}] = 1;"), + ]; + for position in positions { + let allocator = Allocator::default(); + let code = format!("import * as i0 from \"@angular/core\";\nclass Svc {{}}\n{position}\n"); + let result = link(&allocator, &code, "test.mjs"); + assert!(!result.code.contains("ɵɵngDeclare"), "every declaration links:\n{}", result.code); + } +} + +/// A linked factory is a `function` expression, so where it starts a longer statement it gets parentheses, like +/// Babel's output: `function X_Factory() {}.x;` would not parse, and `export default function … + ""` would +/// export the function +#[test] +fn test_link_factory_starting_a_statement_keeps_its_meaning() { + let d = r#"i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: X, deps: [], target: i0.ɵɵFactoryTarget.Injectable })"#; + for (position, expected) in [ + (format!("let y = 1\n{d}.x;"), "let y = 1\n;(function X_Factory("), + (format!("{d} + 1;"), "\n;(function X_Factory("), + (format!("{d}.call(null);"), "\n;(function X_Factory("), + (format!("export default {d} + \"\";"), "export default (function X_Factory("), + // the whole statement, or not its start: as before + (format!("{d};"), "\nfunction X_Factory("), + (format!("const z = {d}.x;"), "const z = function X_Factory("), + ] { + let allocator = Allocator::default(); + let code = format!("import * as i0 from \"@angular/core\";\nclass X {{}}\n{position}\n"); + let result = link(&allocator, &code, "test.mjs"); + assert!(result.code.contains(expected), "expected `{expected}`:\n{}", result.code); + let reparsed = Parser::new(&allocator, &result.code, SourceType::mjs()).parse(); + assert!(reparsed.diagnostics.is_empty(), "{}", result.code); + } +} + /// Every `ɵɵdefine*` call carries `/*@__PURE__*/`, like the output of Angular's /// babel linker (`@angular/compiler-cli/linker/babel` 22.2.3) on these declarations. /// Factories (function expressions) and `ɵsetClassMetadata` get no annotation. @@ -551,21 +622,3 @@ export { P }; result.code ); } - -/// Babel's linker links every declaration call; Deepkit's runtime types keep a copy of each static initializer in a -/// `__type` array, whose declarations must not stay unlinked (their `ngImport: i0` keeps all of @angular/core) -#[test] -fn test_link_declarations_nested_in_arrays_and_objects() { - let allocator = Allocator::default(); - let code = r#"import * as i0 from "@angular/core"; -class Cmp { - static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }); } - static { this.__type = ['ɵfac', function () { return i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }); }]; } -} -const table = { factory: () => i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }) }; -"#; - let result = link(&allocator, code, "test.mjs"); - assert!(result.linked); - assert!(!result.code.contains("ɵɵngDeclare"), "every declaration links:\n{}", result.code); - assert!(!result.code.contains("ngImport"), "{}", result.code); -} From 7420e133ee4493398c8d11cb4453ac94c584caac Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 23 Oct 2026 16:47:45 +0800 Subject: [PATCH 3/4] fix(linker): put the `;` before a wrapped factory only in a statement list In the bare body of `if`, `else`, a loop or a label, the `;` became the body itself, so the rest of the statement ran unconditionally. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/oxc_angular_compiler/src/linker/mod.rs | 21 +++++++++++++++---- .../oxc_angular_compiler/tests/linker_test.rs | 6 ++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 6b3d272f6..426359095 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -165,7 +165,8 @@ struct DeclarationCollector<'s> { filename: &'s str, options: &'s LinkOptions, edits: &'s mut Vec, - /// The expression of the expression statement (`true`) or `export default` (`false`) being visited + /// The expression of the statement being visited, and whether a `;` may go before it: true for a statement in + /// a statement list, false for `export default` or the bare body of `if`, a loop or a label statement_start: Option<(Span, bool)>, } @@ -178,8 +179,20 @@ impl DeclarationCollector<'_> { } impl<'a> Visit<'a> for DeclarationCollector<'_> { + fn visit_statements(&mut self, stmts: &oxc_allocator::Vec<'a, Statement<'a>>) { + for stmt in stmts { + match stmt { + Statement::ExpressionStatement(stmt) => { + self.visit_statement_expression(&stmt.expression, true); + } + _ => self.visit_statement(stmt), + } + } + } + + /// Only reached outside a statement list, such as `if (c) x;`, where a `;` would become the whole body fn visit_expression_statement(&mut self, stmt: &ExpressionStatement<'a>) { - self.visit_statement_expression(&stmt.expression, true); + self.visit_statement_expression(&stmt.expression, false); } fn visit_export_default_declaration(&mut self, decl: &ExportDefaultDeclaration<'a>) { @@ -195,8 +208,8 @@ impl<'a> Visit<'a> for DeclarationCollector<'_> { link_declaration(name, call, self.source, self.filename, self.options) { // A linked factory is a `function` expression: where it starts a longer statement, such as - // `ɵɵngDeclareFactory(…).name;`, it would be read as a declaration, so it gets parentheses (and a `;`, so - // that they do not call the line before) + // `ɵɵngDeclareFactory(…).name;`, it would be read as a declaration, so it gets parentheses (and, in a + // statement list, a `;` so that they do not call the line before) if let Some((expr, is_statement)) = self.statement_start && expr.start == call.span.start && expr != call.span diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index cb30784ec..7bae76aca 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -467,6 +467,12 @@ fn test_link_factory_starting_a_statement_keeps_its_meaning() { (format!("{d} + 1;"), "\n;(function X_Factory("), (format!("{d}.call(null);"), "\n;(function X_Factory("), (format!("export default {d} + \"\";"), "export default (function X_Factory("), + // a bare body gets no `;`, which would become the body itself + (format!("if (c) {d}.x;"), "if (c) (function X_Factory("), + (format!("if (c) {{}} else {d}.x;"), "else (function X_Factory("), + (format!("l: {d}.x;"), "l: (function X_Factory("), + (format!("switch (c) {{ case 1: {d}.x; }}"), "case 1: ;(function X_Factory("), + (format!("const f = () => {d}.x;"), "const f = () => function X_Factory("), // the whole statement, or not its start: as before (format!("{d};"), "\nfunction X_Factory("), (format!("const z = {d}.x;"), "const z = function X_Factory("), From fc3309c2d3b272fb7c0e3c065f329455cab2e49c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 23 Oct 2026 16:54:30 +0800 Subject: [PATCH 4/4] fix(linker): parenthesize a factory that starts a decorator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decorators are now visited, and `@function X_Factory(…) {…}` does not parse. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/oxc_angular_compiler/src/linker/mod.rs | 30 +++++++++++++++---- .../oxc_angular_compiler/tests/linker_test.rs | 2 ++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 426359095..86c055ef7 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -35,9 +35,9 @@ use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, ArrayExpressionElement, BindingPattern, CallExpression, ExportDefaultDeclaration, - Expression, ExpressionStatement, ObjectExpression, ObjectPropertyKind, Program, PropertyKey, - Statement, + Argument, ArrayExpressionElement, BindingPattern, CallExpression, Decorator, + ExportDefaultDeclaration, Expression, ExpressionStatement, ObjectExpression, + ObjectPropertyKind, Program, PropertyKey, Statement, }; use oxc_ast_visit::{Visit, walk}; use oxc_parser::Parser; @@ -156,8 +156,15 @@ fn collect_declaration_edits( options: &LinkOptions, edits: &mut Vec, ) { - DeclarationCollector { source, filename, options, edits, statement_start: None } - .visit_program(program); + DeclarationCollector { + source, + filename, + options, + edits, + statement_start: None, + decorator: None, + } + .visit_program(program); } struct DeclarationCollector<'s> { @@ -168,6 +175,8 @@ struct DeclarationCollector<'s> { /// The expression of the statement being visited, and whether a `;` may go before it: true for a statement in /// a statement list, false for `export default` or the bare body of `if`, a loop or a label statement_start: Option<(Span, bool)>, + /// The expression of the decorator being visited + decorator: Option, } impl DeclarationCollector<'_> { @@ -195,6 +204,12 @@ impl<'a> Visit<'a> for DeclarationCollector<'_> { self.visit_statement_expression(&stmt.expression, false); } + fn visit_decorator(&mut self, decorator: &Decorator<'a>) { + let outer = self.decorator.replace(decorator.expression.span()); + walk::walk_decorator(self, decorator); + self.decorator = outer; + } + fn visit_export_default_declaration(&mut self, decl: &ExportDefaultDeclaration<'a>) { match decl.declaration.as_expression() { Some(expr) => self.visit_statement_expression(expr, false), @@ -210,7 +225,10 @@ impl<'a> Visit<'a> for DeclarationCollector<'_> { // A linked factory is a `function` expression: where it starts a longer statement, such as // `ɵɵngDeclareFactory(…).name;`, it would be read as a declaration, so it gets parentheses (and, in a // statement list, a `;` so that they do not call the line before) - if let Some((expr, is_statement)) = self.statement_start + // `@function …` does not parse either + if self.decorator.is_some_and(|expr| expr.start == call.span.start) { + edit.replacement = format!("({})", edit.replacement); + } else if let Some((expr, is_statement)) = self.statement_start && expr.start == call.span.start && expr != call.span { diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index 7bae76aca..9d6ed7251 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -473,6 +473,8 @@ fn test_link_factory_starting_a_statement_keeps_its_meaning() { (format!("l: {d}.x;"), "l: (function X_Factory("), (format!("switch (c) {{ case 1: {d}.x; }}"), "case 1: ;(function X_Factory("), (format!("const f = () => {d}.x;"), "const f = () => function X_Factory("), + // nor does `@function …` + (format!("@{d} class Y {{}}"), "@(function X_Factory("), // the whole statement, or not its start: as before (format!("{d};"), "\nfunction X_Factory("), (format!("const z = {d}.x;"), "const z = function X_Factory("),